diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index bb3989d30..2d1213a4e 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') 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/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2f9b1416d..3d5312b14 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,3 +24,5 @@ jobs: with: app-name: gridcraft app-dir: GridCraft + install-relative-path: Programs/ArtCraft/GridCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 289cb12d0..e4fcf5570 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,10 +18,10 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. -- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). +- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). @@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. ## Contributor credits (About window) @@ -62,11 +60,13 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a1e4d8fbc..c93a94891 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. | | `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. | -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | -| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark | +| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | | | `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later | | `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via ``, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/CLAUDE.md b/CLAUDE.md index 8bf527c54..3297f122a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,6 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app). 2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. @@ -20,19 +18,19 @@ Standards and learnings shared across the crafting apps live in `../../craftrule - The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception. ## Never crash -People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). -- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`. -- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. +- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them. - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** @@ -50,4 +48,9 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done. ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 53b2326da..5da7c781d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -889,12 +889,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -917,9 +917,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree", "tempfile", @@ -930,7 +931,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -950,13 +951,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -975,7 +977,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "egui", "objc2 0.6.5", @@ -1455,7 +1457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1935,7 +1937,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.12.1" +version = "0.14.2" dependencies = [ "arboard", "chrono", @@ -1962,7 +1964,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1974,7 +1976,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1985,7 +1987,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1997,7 +1999,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.12.1" +version = "0.14.2" dependencies = [ "chrono", "js-sys", @@ -2008,7 +2010,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -2024,12 +2026,13 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "web-time", "zip 3.0.0", ] [[package]] name = "gridcraft-formula" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "serde", @@ -2038,7 +2041,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "regex", @@ -2046,7 +2049,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "log", @@ -2055,7 +2058,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -2067,18 +2070,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.12.1" +version = "0.14.2" [[package]] name = "gridcraft-ui-egui" -version = "0.12.1" +version = "0.14.2" dependencies = [ "craft-ui", "craft-update", @@ -2097,12 +2100,13 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "web-time", "zbus", ] [[package]] name = "gridcraft-web" -version = "0.12.1" +version = "0.14.2" dependencies = [ "eframe", "egui", @@ -2119,11 +2123,12 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.12.1" +version = "0.14.2" dependencies = [ "aes", "gridcraft-core", "gridcraft-formula", + "gridcraft-functions", "gridcraft-model", "quick-xml 0.41.0", "sha1 0.11.0", @@ -3457,7 +3462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3871,7 +3876,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4181,7 +4186,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4240,7 +4245,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4974,7 +4979,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5933,7 +5938,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6399,7 +6404,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.1" +version = "0.14.2" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e23c106e6..a43312950 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.12.1" +version = "0.14.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -26,7 +26,8 @@ gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -34,7 +35,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). -chrono = { version = "0.4.45", default-features = false, features = ["std"] } +chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } +web-time = "1.1.0" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" @@ -61,16 +63,17 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" -type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 diff --git a/ROADMAP.md b/ROADMAP.md index 0e94e97bb..0dd4affda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -22,7 +22,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| | 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -86,7 +86,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -135,8 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-25 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | +| 2026-11-01 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | +| 2026-10-24 | 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`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | @@ -180,6 +181,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | | 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | | 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | +| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | | 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | @@ -232,7 +234,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) | | 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) | | 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | -| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | +| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | | 2026-10-07 | About window with contributor and model credits; macOS release runner | @@ -243,8 +245,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Progress log: Python cells as data | +| 2026-11-01 | minor | Progress log: Python cells as data | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | +| 2026-10-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 | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | @@ -294,5 +298,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | -| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` | +| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` | | 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index bbd3103d8..5fc37b14c 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -26,22 +26,31 @@ use serde_json::{Value as Json, json}; use super::{Args, USAGE, number_text, pretty}; +mod calc; +mod fidelity; +mod package; +mod report; + +pub use calc::oracle; +#[cfg(test)] +use calc::volatile_names; +#[cfg(test)] +use calc::{bool_cached_as_number, values_match}; +#[cfg(test)] +use fidelity::diff_workbooks; +pub use fidelity::round_trip; +use package::full_calc_on_load; +#[cfg(test)] +use package::validate_package; +use report::print_human; +pub use report::report_json; + /// Version tag of the `--json` output; bumped when a field changes meaning or disappears. pub const SCHEMA: &str = "gridcraft-check/1"; - -/// Functions whose result depends on the clock, a random generator or the environment. -const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"]; /// Directory recursion limit (symlink loops end here). const MAX_DEPTH: usize = 32; /// Most files collected from the inputs. const MAX_FILES: usize = 100_000; -/// Largest package part read while validating a written package. -const MAX_XML_PART: u64 = 64 * 1024 * 1024; -/// Most zip entries looked at while validating a written package. -const MAX_ENTRIES: usize = 200_000; -/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is -/// a cache Excel rebuilds, and GridCraft never writes it. -const REBUILT_KINDS: [&str; 1] = ["calcchain"]; #[derive(Clone, Copy, Debug)] pub struct Options { @@ -357,1452 +366,5 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { report } -// ---------------------------------------------------------------- calc oracle - -fn functions_of(e: &Expr) -> Vec { - let mut set = BTreeSet::new(); - e.walk(&mut |x| { - if let Expr::Call(n, _) = x { - set.insert(n.clone()); - } - }); - set.into_iter().collect() -} - -fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { - let mut v = false; - e.walk(&mut |x| match x { - Expr::Call(n, args) => { - let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename")); - if filename || VOLATILE.contains(&n.as_str()) { - v = true; - } - } - Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true, - _ => {} - }); - v -} - -/// Upper-cased defined names whose formula is volatile, directly or through other names. -fn volatile_names(wb: &Workbook) -> HashSet { - let parsed: Vec<(String, Option)> = - wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); - let mut set = HashSet::new(); - // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. - for _ in 0..=parsed.len() { - let before = set.len(); - for (name, e) in &parsed { - if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { - set.insert(name.clone()); - } - } - if set.len() == before { - break; - } - } - set -} - -/// The formula as the formula bar shows it. -fn formula_text(cell: &Cell) -> String { - cell.bar_text() -} - -fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { - let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?"); - format!("{}!{}", quote_sheet(name), c.a1()) -} - -/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a -/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as -/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. -pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { - matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) -} - -/// Whether GridCraft's `actual` agrees with the producer's `expected`. -pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { - match (expected, actual) { - (Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0), - (Value::Text(a), Value::Text(b)) => a == b, - (Value::Bool(a), Value::Bool(b)) => a == b, - (Value::Error(a), Value::Error(b)) => a == b, - _ => false, - } -} - -fn type_name(v: &Value) -> &'static str { - match v { - Value::Empty => "blank", - Value::Number(_) => "number", - Value::Text(_) => "text", - Value::Bool(_) => "bool", - Value::Error(e) => e.as_str(), - Value::Array(_) => "array", - } -} - -fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str { - match (expected, actual) { - (_, Value::Empty) => "blank", - (Value::Number(_), Value::Number(_)) => "numeric", - (Value::Text(_), Value::Text(_)) => "text", - (Value::Bool(_), Value::Bool(_)) => "bool", - (Value::Error(_), _) | (_, Value::Error(_)) => "error", - _ => "type", - } -} - -/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries. -struct MismatchIndex { - cols: HashMap<(usize, u32), BTreeSet>, - /// Columns with mismatches per sheet (for very wide areas). - sheet_cols: HashMap>, -} - -impl MismatchIndex { - fn new(keys: &[Key]) -> MismatchIndex { - let mut cols: HashMap<(usize, u32), BTreeSet> = HashMap::new(); - let mut sheet_cols: HashMap> = HashMap::new(); - for &(s, c) in keys { - cols.entry((s, c.col)).or_default().insert(c.row); - sheet_cols.entry(s).or_default().insert(c.col); - } - MismatchIndex { cols, sheet_cols } - } - /// Whether a mismatched cell other than `own` lies in the area. - fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool { - let Some(cols) = self.sheet_cols.get(&sheet) else { return false }; - cols.range(c0..=c1) - .any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own))) - } -} - -/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated -/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]). -pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport { - let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() }; - let vnames = volatile_names(cached); - let mut volatile: HashSet = HashSet::new(); - for (si, sh) in cached.sheets.iter().enumerate() { - for (c, cell) in sh.cells.iter() { - if let Some(f) = &cell.formula { - r.formula_cells += 1; - if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) { - volatile.insert((si, c)); - } - } - } - } - // Everything downstream of a volatile formula is volatile too; each key is pushed once. - let mut stack: Vec = volatile.iter().copied().collect(); - let mut deps = Vec::new(); - while let Some((s, c)) = stack.pop() { - deps.clear(); - calc.graph.dependents(s, c, &mut deps); - if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) { - calc.graph.dependents_of_range(s, *range, &mut deps); - } - for k in deps.drain(..) { - if volatile.insert(k) { - stack.push(k); - } - } - } - - let mut bad: Vec<(Key, Vec)> = Vec::new(); - for (si, sh) in cached.sheets.iter().enumerate() { - for (c, cell) in sh.cells.iter() { - let Some(f) = &cell.formula else { continue }; - if volatile.contains(&(si, c)) { - r.volatile += 1; - continue; - } - if cell.value.is_empty() || !trust_cache { - r.no_oracle += 1; - continue; - } - r.checked += 1; - let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default(); - let funcs = match f.expr_arc() { - Some(e) => { - let names = functions_of(&e); - if names.is_empty() { vec!["(no function)".to_string()] } else { names } - } - None => vec!["(unparsed)".to_string()], - }; - let bool_as_number = bool_cached_as_number(&cell.value, &actual); - let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); - if bool_as_number { - r.bool_as_number += 1; - } - for n in &funcs { - let st = r.functions.entry(n.clone()).or_default(); - st.checked += 1; - if !ok { - st.mismatched += 1; - } - } - if ok { - r.matched += 1; - } else { - r.mismatched += 1; - *r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1; - bad.push(((si, c), funcs)); - } - } - } - - // A mismatch is primary when none of its precedent cells mismatch. - let keys: Vec = bad.iter().map(|(k, _)| *k).collect(); - let index = MismatchIndex::new(&keys); - let mut listed: Vec<(bool, Key, Vec)> = Vec::with_capacity(bad.len()); - for (k, funcs) in bad { - let upstream = calc - .graph - .precedents_of(k) - .iter() - .any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k)); - if !upstream { - for n in &funcs { - r.functions.entry(n.clone()).or_default().primary += 1; - } - } - listed.push((!upstream, k, funcs)); - } - // Primary mismatches first, each group in sheet then row-major order (already sorted). - listed.sort_by_key(|(primary, _, _)| !*primary); - for (primary, k, functions) in listed.into_iter().take(opts.max_report) { - let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1)); - let expected = cell.map(|c| c.value.clone()).unwrap_or_default(); - let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default(); - r.mismatches.push(Mismatch { - cell: cell_name(cached, k), - formula: cell.map(formula_text).unwrap_or_default(), - kind: mismatch_kind(&expected, &actual), - expected, - actual, - functions, - primary, - }); - } - r -} - -// ---------------------------------------------------------------- round trip - -/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against -/// `source` (the file `wb` was read from), reads it back and compares. -pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { - let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { - Ok(b) => b, - Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, - }; - let mut package = Diffs::new("package", opts.max_report); - match validate_package(&bytes) { - Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), - Err(e) => package.push(|| e), - } - if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { - for (key, kind) in &before { - if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { - package.push(|| format!("lost relationship {kind}")); - } - } - } - let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { - Ok((b, _)) => b, - Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, - }; - let mut categories = diff_workbooks(wb, &back, opts); - categories.push(package.done()); - RoundTrip { error: None, categories } -} - -fn value_show(v: &Value) -> String { - match v { - Value::Empty => "(blank)".to_string(), - Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(), - Value::Number(n) => number_text(*n), - Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), - Value::Error(e) => e.as_str().to_string(), - Value::Array(_) => v.scalar().display(), - } -} - -fn opt_formula(c: Option<&Cell>) -> String { - c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default() -} - -/// Per-sheet comparison of two lists, reporting what one side has and the other lacks. -fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet) { - for x in a.difference(b) { - d.push(|| format!("{sheet}: lost {x}")); - } - for x in b.difference(a) { - d.push(|| format!("{sheet}: gained {x}")); - } -} - -/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number -/// itself is left out, so renumbering without changing the order isn't a difference. -fn cond_format_list(s: &Sheet) -> Vec { - let mut cfs: Vec<_> = s.cond_formats.iter().collect(); - cfs.sort_by_key(|c| c.priority); - cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() -} - -fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { - if a != b { - d.push(|| format!("{sheet}: {a} → {b}")); - } -} - -/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an -/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses. -fn as_cached(v: Value) -> Value { - match v { - Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), - Value::Error(CellError::Circ) => Value::Number(0.0), - v => v, - } -} - -fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str { - wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str() -} - -/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets -/// are paired by position. -pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec { - let m = opts.max_report; - let mut sheets = Diffs::new("sheets", m); - let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect()); - if an != bn { - sheets.push(|| format!("{an:?} → {bn:?}")); - } - let mut values = Diffs::new("values", m); - let mut formulas = Diffs::new("formulas", m); - let mut num_fmts = Diffs::new("numFmts", m); - let mut merges = Diffs::new("merges", m); - let mut tables = Diffs::new("tables", m); - let mut cond_formats = Diffs::new("condFormats", m); - let mut validations = Diffs::new("validations", m); - let mut charts = Diffs::new("charts", m); - let mut comments = Diffs::new("comments", m); - let mut hyperlinks = Diffs::new("hyperlinks", m); - let mut pivots = Diffs::new("pivots", m); - for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) { - let name = quote_sheet(&sa.name); - let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| { - let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default()); - let (va, vb) = (cached(ca), cached(cb)); - if va != vb { - values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb))); - } - let (fa, fb) = (opt_formula(ca), opt_formula(cb)); - if fa != fb { - formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1())); - } - let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb)); - if na != nb { - num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1())); - } - }; - for (c, ca) in sa.cells.iter() { - cell(c, Some(ca), sb.cell(c)); - } - for (c, cb) in sb.cells.iter() { - if sa.cell(c).is_none() { - cell(c, None, Some(cb)); - } - } - let set = |f: &dyn Fn(&Sheet) -> Vec, s: &Sheet| f(s).into_iter().collect::>(); - let merge_list = |s: &Sheet| -> Vec { s.merges.iter().map(|r| r.a1()).collect() }; - diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb)); - let table_list = |s: &Sheet| -> Vec { - s.tables - .iter() - .map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::>().join(", "))) - .collect() - }; - diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb)); - let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb)); - if cfa.len() != cfb.len() { - cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len())); - } else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) { - cond_formats.push(|| format!("{name}: {x} → {y}")); - } - diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len()); - let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::>(); - let (ca, cb) = (chart_list(sa), chart_list(sb)); - if ca != cb { - charts.push(|| format!("{name}: {ca:?} → {cb:?}")); - } - diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len()); - diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len()); - diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len()); - } - let mut names = Diffs::new("names", m); - let name_list = |wb: &Workbook| -> BTreeSet { - wb.names - .iter() - .map(|n| { - let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default(); - format!("{scope}{}={}", n.name, n.formula) - }) - .collect() - }; - diff_sets(&mut names, "workbook", &name_list(a), &name_list(b)); - vec![ - sheets.done(), - values.done(), - formulas.done(), - num_fmts.done(), - merges.done(), - names.done(), - tables.done(), - cond_formats.done(), - validations.done(), - charts.done(), - comments.done(), - hyperlinks.done(), - pivots.done(), - ] -} - -// ---------------------------------------------------------------- package validation - -fn local_name(name: &[u8]) -> &[u8] { - match name.iter().rposition(|&b| b == b':') { - Some(i) => name.get(i + 1..).unwrap_or(name), - None => name, - } -} - -/// `(element local name, attributes)` for every start/empty element of an XML part. -fn xml_elements(xml: &[u8]) -> Result)>, String> { - use quick_xml::events::Event; - let mut reader = quick_xml::Reader::from_reader(xml); - let mut buf = Vec::new(); - let mut out = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { - let attrs = e - .attributes() - .with_checks(false) - .flatten() - .map(|a| { - let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { - Ok(v) => v.into_owned(), - Err(_) => String::from_utf8_lossy(&a.value).into_owned(), - }; - (String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v) - }) - .collect(); - out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs)); - } - Ok(Event::Eof) => break, - Ok(_) => {} - Err(e) => return Err(format!("invalid XML: {e}")), - } - buf.clear(); - } - Ok(out) -} - -fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> { - attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) -} - -/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively. -fn part_key(name: &str) -> String { - name.trim_start_matches('/').to_ascii_lowercase() -} - -/// `%XX` escapes in a relationship target. -fn percent_decode(s: &str) -> String { - let b = s.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while let Some(&c) = b.get(i) { - let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16)); - match (c, hex(i + 1), hex(i + 2)) { - (b'%', Some(h), Some(l)) => { - out.push((h * 16 + l) as u8); - i += 3; - } - _ => { - out.push(c); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -/// Resolves a relationship target against the source part's directory. -fn resolve_target(source_dir: &str, target: &str) -> String { - let target = percent_decode(target.split('#').next().unwrap_or(target)); - let joined = match target.strip_prefix('/') { - Some(abs) => abs.to_string(), - None if source_dir.is_empty() => target, - None => format!("{source_dir}/{target}"), - }; - let mut parts: Vec<&str> = Vec::new(); - for seg in joined.split('/') { - match seg { - "" | "." => {} - ".." => { - parts.pop(); - } - s => parts.push(s), - } - } - parts.join("/") -} - -type Zip<'a> = zip::ZipArchive>; - -fn zip_names(zip: &mut Zip<'_>) -> Vec { - (0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect() -} - -fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { - let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?; - let mut buf = Vec::new(); - f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?; - Ok(buf) -} - -/// Whether the producer flagged its cached results as stale with ``: -/// libraries that don't calculate write it, and store 0 or nothing as every formula's value. -fn full_calc_on_load(bytes: &[u8]) -> bool { - let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; - let names = zip_names(&mut zip); - let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned(); - let mut workbook = "xl/workbook.xml".to_string(); - if let Some(rels) = find("_rels/.rels") - && let Ok(xml) = read_zip_part(&mut zip, &rels) - && let Ok(els) = xml_elements(&xml) - && let Some(target) = els - .iter() - .find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument"))) - .and_then(|(_, a)| attr(a, "Target")) - { - workbook = resolve_target("", target); - } - let Some(name) = find(&part_key(&workbook)) else { return false }; - let Ok(xml) = read_zip_part(&mut zip, &name) else { return false }; - xml_elements(&xml) - .ok() - .into_iter() - .flatten() - .any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))) -} - -/// Problems Excel would repair in a written package: parts without a content type, content-type -/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids -/// and internal relationships pointing at missing parts. `Err` when the package can't be read. -pub fn validate_package(bytes: &[u8]) -> Result, String> { - let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; - let names = zip_names(&mut zip); - let parts: HashSet = names.iter().map(|n| part_key(n)).collect(); - let mut read_part = |name: &str| read_zip_part(&mut zip, name); - let mut issues = Vec::new(); - let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned(); - let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new()); - match ct_name { - None => issues.push("no [Content_Types].xml".to_string()), - Some(n) => { - for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? { - match el.as_str() { - "Default" => { - if let Some(x) = attr(&attrs, "Extension") { - defaults.insert(x.to_ascii_lowercase()); - } - } - "Override" => { - if let Some(p) = attr(&attrs, "PartName") { - overrides.insert(part_key(p)); - } - } - _ => {} - } - } - } - } - let mut sorted: Vec<&String> = names.iter().collect(); - sorted.sort(); - for n in &sorted { - let key = part_key(n); - if key == "[content_types].xml" { - continue; - } - let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string()); - if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) { - issues.push(format!("part {n} has no content type")); - } - } - let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect(); - missing_overrides.sort(); - for p in missing_overrides { - issues.push(format!("content type override for missing part /{p}")); - } - for n in sorted { - let key = part_key(n); - let Some(rels_file) = key.strip_suffix(".rels") else { continue }; - // `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself. - let (dir, file) = match rels_file.rsplit_once('/') { - Some((d, f)) => (d, f), - None => continue, - }; - let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue }; - let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") }; - if !source.is_empty() && !parts.contains(&source) { - issues.push(format!("{n} describes missing part {source}")); - } - let mut ids = HashSet::new(); - for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? { - if el != "Relationship" { - continue; - } - let id = attr(&attrs, "Id").unwrap_or(""); - if !ids.insert(id.to_string()) { - issues.push(format!("{n}: duplicate relationship id {id}")); - } - if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) { - continue; - } - let target = attr(&attrs, "Target").unwrap_or(""); - let resolved = resolve_target(src_dir, target); - if !parts.contains(&resolved.to_ascii_lowercase()) { - let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?"); - issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}")); - } - } - } - Ok(issues) -} - -/// Every relationship kind (last segment of the type, the same in the transitional and strict -/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry -/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook -/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. -fn relationship_kinds(bytes: &[u8]) -> Result, String> { - let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; - let names = zip_names(&mut zip); - let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); - // Several relationships name the same part; each fresh check decompressed it again, so - // repeated references to one highly compressible part multiplied the work. The verdict is - // remembered per (part, named content) and the decompression the package can cause is - // budgeted as a whole (finding: repeated decompression). - let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); - let mut budget: u64 = 512 * 1024 * 1024; - let mut kinds = BTreeMap::new(); - for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { - let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; - let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; - for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { - let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; - let target = attr(attrs, "Target").unwrap_or(""); - if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { - continue; - } - let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); - if !external - && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) - && !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget) - { - continue; - } - kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); - } - } - Ok(kinds) -} - -/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the -/// empty string for the package's `_rels/.rels`. -fn rels_source_dir(key: &str) -> Option { - let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; - Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string()) -} - -/// Whether a part has content: an element under its root, or for comments (whose authors list -/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. -/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this -/// package's remaining checks may cause, and once it is spent the part counts as having content. -fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool { - let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() { - "comments" => Some(b"comment"), - "vmldrawing" => Some(b"shape"), - _ => None, - }; - let marker = match wanted { - Some(b"comment") => "comment", - Some(_) => "shape", - None => "any", - }; - if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) { - return *v; - } - let v = holds_something_uncached(zip, name, marker, budget); - verdicts.insert((name.to_ascii_lowercase(), marker), v); - v -} - -fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool { - use quick_xml::events::Event; - let Ok(f) = zip.by_name(name) else { return true }; - if *budget == 0 { - return true; - } - let take = f.size().min(MAX_XML_PART).min(*budget); - *budget -= take; - let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take))); - let wanted: Option<&'static [u8]> = match marker { - "comment" => Some(b"comment"), - "shape" => Some(b"shape"), - _ => None, - }; - let mut buf = Vec::new(); - let mut in_root = false; - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { - let found = match wanted { - Some(w) => local_name(e.name().as_ref()) == w, - None => in_root, - }; - if found { - return true; - } - in_root = true; - } - Ok(Event::Eof) => return false, - Ok(_) => {} - Err(_) => return true, - } - buf.clear(); - } -} - -// ---------------------------------------------------------------- output - -#[derive(Default)] -struct Totals { - files: usize, - opened: usize, - failed: usize, - formula_cells: usize, - checked: usize, - matched: usize, - mismatched: usize, - no_oracle: usize, - volatile: usize, - bool_as_number: usize, - round_trip_failed: usize, - round_trip_differing: usize, - categories: BTreeMap<&'static str, usize>, - functions: BTreeMap, - signatures: BTreeMap, -} - -fn totals(reports: &[FileReport]) -> Totals { - let mut t = Totals { files: reports.len(), ..Default::default() }; - for r in reports { - if r.error.is_some() { - t.failed += 1; - continue; - } - t.opened += 1; - t.formula_cells += r.calc.formula_cells; - t.checked += r.calc.checked; - t.matched += r.calc.matched; - t.mismatched += r.calc.mismatched; - t.no_oracle += r.calc.no_oracle; - t.volatile += r.calc.volatile; - t.bool_as_number += r.calc.bool_as_number; - if r.round_trip.error.is_some() { - t.round_trip_failed += 1; - } - if !r.round_trip.is_clean() { - t.round_trip_differing += 1; - } - for c in r.round_trip.differing() { - *t.categories.entry(c).or_default() += 1; - } - for (n, s) in &r.calc.functions { - t.functions.entry(n.clone()).or_default().add(s); - } - for (sig, n) in &r.calc.signatures { - *t.signatures.entry(sig.clone()).or_default() += n; - } - } - t -} - -/// Functions with mismatches, worst first: most primary mismatches, then most mismatches. -fn ranked(functions: &BTreeMap) -> Vec<(&String, &FnStat)> { - let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect(); - v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); - v -} - -fn functions_json(functions: &BTreeMap) -> Json { - let mut v: Vec<(&String, &FnStat)> = functions.iter().collect(); - v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); - Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect()) -} - -pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { - let files: Vec = reports - .iter() - .map(|r| { - let c = &r.calc; - let mismatches: Vec = c - .mismatches - .iter() - .map(|m| { - json!({ - "cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual), - "kind": m.kind, "functions": m.functions, "primary": m.primary, - }) - }) - .collect(); - let categories: serde_json::Map = r - .round_trip - .categories - .iter() - .map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples}))) - .collect(); - json!({ - "path": r.path, - "opened": r.error.is_none(), - "error": r.error, - "warnings": r.warnings, - "calc": { - "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, - "mismatches": mismatches, "functions": functions_json(&c.functions), - "signatures": c.signatures, - }, - "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, - }) - }) - .collect(); - let t = totals(reports); - json!({ - "schema": SCHEMA, - "tolerance": opts.tolerance, - "files": files, - "totals": { - "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, - "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, - }, - "functions": functions_json(&t.functions), - "signatures": t.signatures, - }) -} - -/// One report line: line breaks and tabs shown escaped so that they stay visible as differences. -fn one_line(s: &str) -> String { - s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t") -} - -/// `…tail` of a long label. -fn fit(s: &str, w: usize) -> String { - let n = s.chars().count(); - if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::()) } -} - -fn print_human(reports: &[FileReport], opts: &Options) { - for r in reports { - outln!("== {}", r.path); - if let Some(e) = &r.error { - outln!(" can't open: {e}"); - continue; - } - let c = &r.calc; - outln!( - " formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile", - c.formula_cells, - c.checked, - c.matched, - c.mismatched, - c.no_oracle, - c.volatile - ); - if c.stale_cache { - outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); - } - if c.bool_as_number > 0 { - outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); - } - for m in &c.mismatches { - let tag = if m.primary { "mismatch" } else { " (downstream)" }; - outln!( - " {tag} {} {} expected {} got {} [{}]", - m.cell, - one_line(&m.formula), - value_show(&m.expected), - value_show(&m.actual), - m.functions.join(" ") - ); - } - if c.mismatched > c.mismatches.len() { - outln!(" … {} more mismatches", c.mismatched - c.mismatches.len()); - } - let rt = &r.round_trip; - if let Some(e) = &rt.error { - outln!(" round trip: {e}"); - } - let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect(); - if differing.is_empty() && rt.error.is_none() { - outln!(" round trip: all equal"); - } - for cat in differing { - outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs); - for e in &cat.examples { - outln!(" {}", one_line(e)); - } - if cat.diffs > cat.examples.len() { - outln!(" … {} more", cat.diffs - cat.examples.len()); - } - } - } - outln!(""); - let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50); - outln!( - "{:8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip", - "file", - "formulas", - "checked", - "matched", - "mismatch", - "no-oracle", - "volatile" - ); - for r in reports { - let label = fit(&r.path, w); - if let Some(e) = &r.error { - outln!("{label: "FAILED".to_string(), - (None, d) if d.is_empty() => "ok".to_string(), - (None, d) => d.join(","), - }; - outln!( - "{label:8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}", - c.formula_cells, - c.checked, - c.matched, - c.mismatched, - c.no_oracle, - c.volatile - ); - } - let t = totals(reports); - let top = ranked(&t.functions); - if !top.is_empty() { - outln!(""); - outln!("top mismatching functions (primary = no mismatching precedent):"); - outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked"); - for (n, s) in top.iter().take(opts.max_report.max(1)) { - outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked); - } - } - let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect(); - sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); - if !sigs.is_empty() { - outln!(""); - outln!("mismatch signatures (expected → GridCraft):"); - for (sig, n) in sigs.iter().take(opts.max_report.max(1)) { - outln!(" {n:>8} {sig}"); - } - } - outln!(""); - outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed); - outln!( - "formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)", - t.formula_cells, - t.checked, - t.matched, - t.mismatched, - t.no_oracle, - t.volatile - ); - if t.bool_as_number > 0 { - outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); - } - let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); - outln!( - "round trip: {} of {} files differ{}{}", - t.round_trip_differing, - t.opened, - if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() }, - if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) } - ); -} - #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used)] - use std::io::Write as _; - use std::sync::Arc; - - use gridcraft_engine::model::Formula; - - use super::*; - - #[test] - fn discovery_reports_a_directory_read_error() { - let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); - std::fs::write(&path, "not a directory").unwrap(); - let mut discovery = Discovery::default(); - let mut seen = std::collections::HashSet::new(); - assert!(walk(&path, 0, &mut discovery, &mut seen)); - assert!(discovery.files.is_empty()); - assert_eq!(discovery.failures.len(), 1); - assert_eq!(discovery.failures[0].path, path.to_string_lossy()); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn discovery_reports_omissions_only_after_the_file_limit() { - let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; - assert!(discovery.add_file(PathBuf::from("last.xlsx"))); - assert!(discovery.failures.is_empty()); - assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); - assert_eq!(discovery.files.len(), MAX_FILES); - assert_eq!(discovery.failures[0].path, "omitted.xlsx"); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); - } - - fn at(a1: &str) -> CellRef { - CellRef::parse(a1).unwrap() - } - - /// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a - /// formula with that cached value, anything else a constant. - fn book(cells: &[(&str, &str, Value)]) -> Workbook { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - for (a1, input, cached) in cells { - let cell = if input.starts_with('=') { - Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } - } else { - Cell::value(cached.clone()) - }; - sh.set_cell(at(a1), cell); - } - wb - } - - fn check(wb: &Workbook, opts: &Options) -> FileReport { - let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); - check_bytes("t.xlsx", &bytes, opts) - } - - fn n(x: f64) -> Value { - Value::number(x) - } - - #[test] - fn matching_cached_values_pass() { - let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); - let r = check(&wb, &Options::default()); - assert_eq!(r.error, None); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); - assert!(r.is_clean(), "{r:?}"); - } - - #[test] - fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { - // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with - // Excel's 7, so it mismatches too, but only as a downstream effect. - let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); - let first = &r.calc.mismatches[0]; - assert_eq!(first.cell, "Sheet1!B1"); - assert_eq!(first.formula, "=SUM(A1:A2)"); - assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); - assert!(!r.calc.mismatches[1].primary); - assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); - assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); - assert!(!r.is_clean()); - } - - #[test] - fn errors_compare_by_kind() { - let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); - assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); - assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); - assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); - assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); - assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); - } - - #[test] - fn numbers_compare_within_tolerance() { - assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); - assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); - // Absolute near zero. - assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); - assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); - assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); - assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); - assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); - assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); - assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); - let wb = book(&[("A1", "=1+2", n(3.0000000001))]); - assert_eq!(check(&wb, &Options::default()).calc.matched, 1); - assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); - } - - /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: - /// the cache holds the right value under a number type, so it matches and is counted apart. - #[test] - fn booleans_cached_as_numbers_match_and_are_counted_apart() { - let wb = book(&[ - ("A1", "=TRUE()", n(1.0)), - ("B1", "=FALSE()", n(0.0)), - ("C1", "=TRUE()", Value::Bool(true)), - ("D1", "=1=1", n(0.0)), - ("E1", "=TRUE()+0", n(1.0)), - ]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); - // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. - assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); - assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); - assert_eq!(r.calc.bool_as_number, 2); - let v = report_json(std::slice::from_ref(&r), &Options::default()); - assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); - assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); - assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); - } - - #[test] - fn missing_cached_values_and_volatile_formulas_are_skipped() { - let wb = - book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); - } - - #[test] - fn full_calc_on_load_means_no_oracle() { - // A formula without a cached value makes GridCraft's writer flag the file - // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" - // value must not count as a mismatch then. - let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); - let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - assert!(full_calc_on_load(&bytes)); - let r = check_bytes("t.xlsx", &bytes, &Options::default()); - assert!(r.calc.stale_cache); - assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); - let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); - assert!(!full_calc_on_load(&fresh)); - assert!(!full_calc_on_load(b"not a zip")); - } - - #[test] - fn volatile_names_propagate() { - let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); - wb.names.push(gridcraft_engine::model::DefinedName { - name: "Stamp".into(), - scope: None, - formula: "TODAY()".into(), - comment: String::new(), - hidden: false, - }); - assert!(volatile_names(&wb).contains("STAMP")); - assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); - } - - #[test] - fn round_trip_of_a_simple_book_is_clean() { - let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); - wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - let rt = round_trip(&wb, &source, &Options::default()); - assert_eq!(rt.error, None); - assert!(rt.is_clean(), "{rt:?}"); - assert!(rt.categories.iter().any(|c| c.name == "package")); - } - - #[test] - fn diff_detects_lost_features() { - let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); - a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let mut b = a.clone(); - let sh = b.sheet_mut(0).unwrap(); - sh.merges.clear(); - sh.set_cell(at("A1"), Cell::value(n(3.0))); - sh.set_cell(at("B1"), Cell::value(n(6.0))); - b.sheets.push(Arc::new(Sheet::new("Extra"))); - let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); - let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); - assert_eq!(get("values").diffs, 1); - assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); - assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); - assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); - assert_eq!(get("sheets").diffs, 1); - assert_eq!(get("tables").diffs, 0); - } - - #[test] - fn diff_detects_changed_cond_format_rule() { - use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; - let bar = |gradient: bool, priority: u32| CondFormat { - ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], - rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, - priority, - stop_if_true: false, - }; - let with = |cf: CondFormat| { - let mut wb = book(&[("A1", "2", n(2.0))]); - wb.sheet_mut(0).unwrap().cond_formats.push(cf); - wb - }; - let diffs = - |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; - assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); - } - - fn zip_of(parts: &[(&str, &str)]) -> Vec { - let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); - for (name, body) in parts { - z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); - z.write_all(body.as_bytes()).unwrap(); - } - z.finish().unwrap().into_inner() - } - - #[test] - fn package_validation_finds_what_excel_would_repair() { - let bytes = zip_of(&[ - ( - "[Content_Types].xml", - r#""#, - ), - ("_rels/.rels", r#""#), - ("xl/workbook.xml", ""), - ("xl/styles.xml", ""), - ( - "xl/_rels/workbook.xml.rels", - r#""#, - ), - ]); - let issues = validate_package(&bytes).unwrap(); - assert_eq!( - issues, - [ - "part xl/styles.xml has no content type", - "content type override for missing part /xl/gone.xml", - "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", - "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", - ] - ); - assert!(validate_package(b"not a zip").is_err()); - let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); - assert_eq!(validate_package(&own).unwrap(), Vec::::new()); - } - - const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; - const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; - const MADE_UP: &str = r#""#; - /// Not empty: an empty part isn't a loss. - const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); - - /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. - fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { - let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - let mut pending: Vec<(&str, &str)> = rels.to_vec(); - for i in 0..src.len() { - let mut f = src.by_index(i).unwrap(); - let name = f.name().to_string(); - let mut body = String::new(); - f.read_to_string(&mut body).unwrap(); - if let Some(at) = pending.iter().position(|(r, _)| *r == name) { - body = body.replace("", &format!("{}", pending.remove(at).1)); - } - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - let created = pending.iter().map(|(r, extra)| { - (*r, format!(r#"{extra}"#)) - }); - for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - out.finish().unwrap().into_inner() - } - - /// The `package` examples of checking `source`. - fn package_of(source: &[u8]) -> Vec { - let r = check_bytes("t.xlsx", source, &Options::default()); - r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() - } - - fn plain() -> Vec { - gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() - } - - #[test] - fn package_reports_relationship_kinds_the_output_lost() { - // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written - // (Excel rebuilds it), so its absence isn't a loss. - let calc_chain = r#""#; - let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); - } - - // In the three tests below the made-up kind stays lost, so the comparison is known to run. - - #[test] - fn an_internal_link_written_as_a_relationship_is_not_a_loss() { - // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. - let link = r##""##; - let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn comments_without_a_comment_are_not_a_loss() { - let rels = r#""#; - let comments = r#"A"#; - let vml = r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], - &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn empty_drawings_and_person_lists_are_not_a_loss() { - let drawing = r#""#; - let person = - r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], - &[ - MADE_UP_PART, - ("xl/drawings/drawing1.xml", r#""#), - ("xl/persons/person.xml", r#""#), - ], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn json_lists_warning_messages() { - let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; - let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; - let v = report_json(&[with, without], &Options::default()); - assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); - assert_eq!(v["files"][1]["warnings"], json!([])); - } - - #[test] - fn unopenable_files_are_reported() { - let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); - assert!(r.error.is_some()); - assert!(!r.is_clean()); - } - - #[test] - fn json_schema_keys() { - let wb = book(&[("A1", "=1+1", n(3.0))]); - let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; - let v = report_json(&reports, &Options::default()); - let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); - let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); - assert_eq!(v["schema"], SCHEMA); - let f = &v["files"][0]; - assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); - assert_eq!( - keys(&f["calc"]), - set(&[ - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "staleCache", - "volatile", - "boolAsNumber", - "mismatches", - "functions", - "signatures" - ]) - ); - assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); - assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); - assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); - assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); - assert_eq!( - keys(&f["roundTrip"]["categories"]), - set(&[ - "sheets", - "values", - "formulas", - "numFmts", - "merges", - "names", - "tables", - "condFormats", - "validations", - "charts", - "comments", - "hyperlinks", - "pivots", - "package" - ]) - ); - assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); - assert_eq!(v["files"][1]["opened"], json!(false)); - assert_eq!( - keys(&v["totals"]), - set(&[ - "files", - "opened", - "failedToOpen", - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "volatile", - "boolAsNumber", - "roundTripFailed", - "roundTripDiffering", - "categoriesDiffering" - ]) - ); - assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); - } -} +mod tests; diff --git a/apps/gridcraft-cli/src/check/calc.rs b/apps/gridcraft-cli/src/check/calc.rs new file mode 100644 index 000000000..a3f33d296 --- /dev/null +++ b/apps/gridcraft-cli/src/check/calc.rs @@ -0,0 +1,237 @@ +use super::*; + +/// Functions whose result depends on the clock, a random generator or the environment. +const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"]; + +// ---------------------------------------------------------------- calc oracle + +fn functions_of(e: &Expr) -> Vec { + let mut set = BTreeSet::new(); + e.walk(&mut |x| { + if let Expr::Call(n, _) = x { + set.insert(n.clone()); + } + }); + set.into_iter().collect() +} + +fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { + let mut v = false; + e.walk(&mut |x| match x { + Expr::Call(n, args) => { + let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename")); + if filename || VOLATILE.contains(&n.as_str()) { + v = true; + } + } + Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true, + _ => {} + }); + v +} + +/// Upper-cased defined names whose formula is volatile, directly or through other names. +pub(super) fn volatile_names(wb: &Workbook) -> HashSet { + let parsed: Vec<(String, Option)> = + wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); + let mut set = HashSet::new(); + // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. + for _ in 0..=parsed.len() { + let before = set.len(); + for (name, e) in &parsed { + if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { + set.insert(name.clone()); + } + } + if set.len() == before { + break; + } + } + set +} + +/// The formula as the formula bar shows it. +pub(super) fn formula_text(cell: &Cell) -> String { + cell.bar_text() +} + +fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { + let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?"); + format!("{}!{}", quote_sheet(name), c.a1()) +} + +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + +/// Whether GridCraft's `actual` agrees with the producer's `expected`. +pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { + match (expected, actual) { + (Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0), + (Value::Text(a), Value::Text(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Error(a), Value::Error(b)) => a == b, + _ => false, + } +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Empty => "blank", + Value::Number(_) => "number", + Value::Text(_) => "text", + Value::Bool(_) => "bool", + Value::Error(e) => e.as_str(), + Value::Array(_) => "array", + } +} + +fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str { + match (expected, actual) { + (_, Value::Empty) => "blank", + (Value::Number(_), Value::Number(_)) => "numeric", + (Value::Text(_), Value::Text(_)) => "text", + (Value::Bool(_), Value::Bool(_)) => "bool", + (Value::Error(_), _) | (_, Value::Error(_)) => "error", + _ => "type", + } +} + +/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries. +struct MismatchIndex { + cols: HashMap<(usize, u32), BTreeSet>, + /// Columns with mismatches per sheet (for very wide areas). + sheet_cols: HashMap>, +} + +impl MismatchIndex { + fn new(keys: &[Key]) -> MismatchIndex { + let mut cols: HashMap<(usize, u32), BTreeSet> = HashMap::new(); + let mut sheet_cols: HashMap> = HashMap::new(); + for &(s, c) in keys { + cols.entry((s, c.col)).or_default().insert(c.row); + sheet_cols.entry(s).or_default().insert(c.col); + } + MismatchIndex { cols, sheet_cols } + } + /// Whether a mismatched cell other than `own` lies in the area. + fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool { + let Some(cols) = self.sheet_cols.get(&sheet) else { return false }; + cols.range(c0..=c1) + .any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own))) + } +} + +/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated +/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]). +pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport { + let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() }; + let vnames = volatile_names(cached); + let mut volatile: HashSet = HashSet::new(); + for (si, sh) in cached.sheets.iter().enumerate() { + for (c, cell) in sh.cells.iter() { + if let Some(f) = &cell.formula { + r.formula_cells += 1; + if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) { + volatile.insert((si, c)); + } + } + } + } + // Everything downstream of a volatile formula is volatile too; each key is pushed once. + let mut stack: Vec = volatile.iter().copied().collect(); + let mut deps = Vec::new(); + while let Some((s, c)) = stack.pop() { + deps.clear(); + calc.graph.dependents(s, c, &mut deps); + if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) { + calc.graph.dependents_of_range(s, *range, &mut deps); + } + for k in deps.drain(..) { + if volatile.insert(k) { + stack.push(k); + } + } + } + + let mut bad: Vec<(Key, Vec)> = Vec::new(); + for (si, sh) in cached.sheets.iter().enumerate() { + for (c, cell) in sh.cells.iter() { + let Some(f) = &cell.formula else { continue }; + if volatile.contains(&(si, c)) { + r.volatile += 1; + continue; + } + if cell.value.is_empty() || !trust_cache { + r.no_oracle += 1; + continue; + } + r.checked += 1; + let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default(); + let funcs = match f.expr_arc() { + Some(e) => { + let names = functions_of(&e); + if names.is_empty() { vec!["(no function)".to_string()] } else { names } + } + None => vec!["(unparsed)".to_string()], + }; + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } + for n in &funcs { + let st = r.functions.entry(n.clone()).or_default(); + st.checked += 1; + if !ok { + st.mismatched += 1; + } + } + if ok { + r.matched += 1; + } else { + r.mismatched += 1; + *r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1; + bad.push(((si, c), funcs)); + } + } + } + + // A mismatch is primary when none of its precedent cells mismatch. + let keys: Vec = bad.iter().map(|(k, _)| *k).collect(); + let index = MismatchIndex::new(&keys); + let mut listed: Vec<(bool, Key, Vec)> = Vec::with_capacity(bad.len()); + for (k, funcs) in bad { + let upstream = calc + .graph + .precedents_of(k) + .iter() + .any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k)); + if !upstream { + for n in &funcs { + r.functions.entry(n.clone()).or_default().primary += 1; + } + } + listed.push((!upstream, k, funcs)); + } + // Primary mismatches first, each group in sheet then row-major order (already sorted). + listed.sort_by_key(|(primary, _, _)| !*primary); + for (primary, k, functions) in listed.into_iter().take(opts.max_report) { + let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1)); + let expected = cell.map(|c| c.value.clone()).unwrap_or_default(); + let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default(); + r.mismatches.push(Mismatch { + cell: cell_name(cached, k), + formula: cell.map(formula_text).unwrap_or_default(), + kind: mismatch_kind(&expected, &actual), + expected, + actual, + functions, + primary, + }); + } + r +} diff --git a/apps/gridcraft-cli/src/check/fidelity.rs b/apps/gridcraft-cli/src/check/fidelity.rs new file mode 100644 index 000000000..d8def2747 --- /dev/null +++ b/apps/gridcraft-cli/src/check/fidelity.rs @@ -0,0 +1,185 @@ +use super::calc::formula_text; +use super::package::{REBUILT_KINDS, relationship_kinds, validate_package}; +use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet}; + +// ---------------------------------------------------------------- round trip + +/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against +/// `source` (the file `wb` was read from), reads it back and compares. +pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { + let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { + Ok(b) => b, + Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, + }; + let mut package = Diffs::new("package", opts.max_report); + match validate_package(&bytes) { + Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), + Err(e) => package.push(|| e), + } + if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { + for (key, kind) in &before { + if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { + package.push(|| format!("lost relationship {kind}")); + } + } + } + let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { + Ok((b, _)) => b, + Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, + }; + let mut categories = diff_workbooks(wb, &back, opts); + categories.push(package.done()); + RoundTrip { error: None, categories } +} + +pub(super) fn value_show(v: &Value) -> String { + match v { + Value::Empty => "(blank)".to_string(), + Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(), + Value::Number(n) => number_text(*n), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + Value::Error(e) => e.as_str().to_string(), + Value::Array(_) => v.scalar().display(), + } +} + +fn opt_formula(c: Option<&Cell>) -> String { + c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default() +} + +/// Per-sheet comparison of two lists, reporting what one side has and the other lacks. +fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet) { + for x in a.difference(b) { + d.push(|| format!("{sheet}: lost {x}")); + } + for x in b.difference(a) { + d.push(|| format!("{sheet}: gained {x}")); + } +} + +/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number +/// itself is left out, so renumbering without changing the order isn't a difference. +fn cond_format_list(s: &Sheet) -> Vec { + let mut cfs: Vec<_> = s.cond_formats.iter().collect(); + cfs.sort_by_key(|c| c.priority); + cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() +} + +fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { + if a != b { + d.push(|| format!("{sheet}: {a} → {b}")); + } +} + +/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an +/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses. +fn as_cached(v: Value) -> Value { + match v { + Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), + Value::Error(CellError::Circ) => Value::Number(0.0), + v => v, + } +} + +fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str { + wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str() +} + +/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets +/// are paired by position. +pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec { + let m = opts.max_report; + let mut sheets = Diffs::new("sheets", m); + let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect()); + if an != bn { + sheets.push(|| format!("{an:?} → {bn:?}")); + } + let mut values = Diffs::new("values", m); + let mut formulas = Diffs::new("formulas", m); + let mut num_fmts = Diffs::new("numFmts", m); + let mut merges = Diffs::new("merges", m); + let mut tables = Diffs::new("tables", m); + let mut cond_formats = Diffs::new("condFormats", m); + let mut validations = Diffs::new("validations", m); + let mut charts = Diffs::new("charts", m); + let mut comments = Diffs::new("comments", m); + let mut hyperlinks = Diffs::new("hyperlinks", m); + let mut pivots = Diffs::new("pivots", m); + for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) { + let name = quote_sheet(&sa.name); + let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| { + let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default()); + let (va, vb) = (cached(ca), cached(cb)); + if va != vb { + values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb))); + } + let (fa, fb) = (opt_formula(ca), opt_formula(cb)); + if fa != fb { + formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1())); + } + let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb)); + if na != nb { + num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1())); + } + }; + for (c, ca) in sa.cells.iter() { + cell(c, Some(ca), sb.cell(c)); + } + for (c, cb) in sb.cells.iter() { + if sa.cell(c).is_none() { + cell(c, None, Some(cb)); + } + } + let set = |f: &dyn Fn(&Sheet) -> Vec, s: &Sheet| f(s).into_iter().collect::>(); + let merge_list = |s: &Sheet| -> Vec { s.merges.iter().map(|r| r.a1()).collect() }; + diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb)); + let table_list = |s: &Sheet| -> Vec { + s.tables + .iter() + .map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::>().join(", "))) + .collect() + }; + diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb)); + let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb)); + if cfa.len() != cfb.len() { + cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len())); + } else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) { + cond_formats.push(|| format!("{name}: {x} → {y}")); + } + diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len()); + let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::>(); + let (ca, cb) = (chart_list(sa), chart_list(sb)); + if ca != cb { + charts.push(|| format!("{name}: {ca:?} → {cb:?}")); + } + diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len()); + diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len()); + diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len()); + } + let mut names = Diffs::new("names", m); + let name_list = |wb: &Workbook| -> BTreeSet { + wb.names + .iter() + .map(|n| { + let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default(); + format!("{scope}{}={}", n.name, n.formula) + }) + .collect() + }; + diff_sets(&mut names, "workbook", &name_list(a), &name_list(b)); + vec![ + sheets.done(), + values.done(), + formulas.done(), + num_fmts.done(), + merges.done(), + names.done(), + tables.done(), + cond_formats.done(), + validations.done(), + charts.done(), + comments.done(), + hyperlinks.done(), + pivots.done(), + ] +} diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs new file mode 100644 index 000000000..6b97fa495 --- /dev/null +++ b/apps/gridcraft-cli/src/check/package.rs @@ -0,0 +1,331 @@ +use super::*; + +/// Largest package part read while validating a written package. +const MAX_XML_PART: u64 = 64 * 1024 * 1024; + +/// Most zip entries looked at while validating a written package. +const MAX_ENTRIES: usize = 200_000; + +/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is +/// a cache Excel rebuilds, and GridCraft never writes it. +pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"]; + +// ---------------------------------------------------------------- package validation + +fn local_name(name: &[u8]) -> &[u8] { + match name.iter().rposition(|&b| b == b':') { + Some(i) => name.get(i + 1..).unwrap_or(name), + None => name, + } +} + +/// `(element local name, attributes)` for every start/empty element of an XML part. +type XmlElement = (String, Vec<(String, String)>); + +fn xml_elements(xml: &[u8]) -> Result, String> { + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_reader(xml); + let mut buf = Vec::new(); + let mut out = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let attrs = e + .attributes() + .with_checks(false) + .flatten() + .map(|a| { + let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { + Ok(v) => v.into_owned(), + Err(_) => String::from_utf8_lossy(&a.value).into_owned(), + }; + (String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v) + }) + .collect(); + out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs)); + } + Ok(Event::Eof) => break, + Ok(_) => {} + Err(e) => return Err(format!("invalid XML: {e}")), + } + buf.clear(); + } + Ok(out) +} + +fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> { + attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) +} + +/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively. +fn part_key(name: &str) -> String { + name.trim_start_matches('/').to_ascii_lowercase() +} + +/// `%XX` escapes in a relationship target. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16)); + match (c, hex(i + 1), hex(i + 2)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Resolves a relationship target against the source part's directory. +fn resolve_target(source_dir: &str, target: &str) -> String { + let target = percent_decode(target.split('#').next().unwrap_or(target)); + let joined = match target.strip_prefix('/') { + Some(abs) => abs.to_string(), + None if source_dir.is_empty() => target, + None => format!("{source_dir}/{target}"), + }; + let mut parts: Vec<&str> = Vec::new(); + for seg in joined.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +type Zip<'a> = zip::ZipArchive>; + +fn zip_names(zip: &mut Zip<'_>) -> Vec { + (0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect() +} + +fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { + let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?; + let mut buf = Vec::new(); + f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?; + Ok(buf) +} + +/// Whether the producer flagged its cached results as stale with ``: +/// libraries that don't calculate write it, and store 0 or nothing as every formula's value. +pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool { + let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; + let names = zip_names(&mut zip); + let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned(); + let mut workbook = "xl/workbook.xml".to_string(); + if let Some(rels) = find("_rels/.rels") + && let Ok(xml) = read_zip_part(&mut zip, &rels) + && let Ok(els) = xml_elements(&xml) + && let Some(target) = els + .iter() + .find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument"))) + .and_then(|(_, a)| attr(a, "Target")) + { + workbook = resolve_target("", target); + } + let Some(name) = find(&part_key(&workbook)) else { return false }; + let Ok(xml) = read_zip_part(&mut zip, &name) else { return false }; + xml_elements(&xml) + .ok() + .into_iter() + .flatten() + .any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))) +} + +/// Problems Excel would repair in a written package: parts without a content type, content-type +/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids +/// and internal relationships pointing at missing parts. `Err` when the package can't be read. +pub fn validate_package(bytes: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let names = zip_names(&mut zip); + let parts: HashSet = names.iter().map(|n| part_key(n)).collect(); + let mut read_part = |name: &str| read_zip_part(&mut zip, name); + let mut issues = Vec::new(); + let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned(); + let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new()); + match ct_name { + None => issues.push("no [Content_Types].xml".to_string()), + Some(n) => { + for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? { + match el.as_str() { + "Default" => { + if let Some(x) = attr(&attrs, "Extension") { + defaults.insert(x.to_ascii_lowercase()); + } + } + "Override" => { + if let Some(p) = attr(&attrs, "PartName") { + overrides.insert(part_key(p)); + } + } + _ => {} + } + } + } + } + let mut sorted: Vec<&String> = names.iter().collect(); + sorted.sort(); + for n in &sorted { + let key = part_key(n); + if key == "[content_types].xml" { + continue; + } + let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string()); + if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) { + issues.push(format!("part {n} has no content type")); + } + } + let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect(); + missing_overrides.sort(); + for p in missing_overrides { + issues.push(format!("content type override for missing part /{p}")); + } + for n in sorted { + let key = part_key(n); + let Some(rels_file) = key.strip_suffix(".rels") else { continue }; + // `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself. + let (dir, file) = match rels_file.rsplit_once('/') { + Some((d, f)) => (d, f), + None => continue, + }; + let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue }; + let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") }; + if !source.is_empty() && !parts.contains(&source) { + issues.push(format!("{n} describes missing part {source}")); + } + let mut ids = HashSet::new(); + for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? { + if el != "Relationship" { + continue; + } + let id = attr(&attrs, "Id").unwrap_or(""); + if !ids.insert(id.to_string()) { + issues.push(format!("{n}: duplicate relationship id {id}")); + } + if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) { + continue; + } + let target = attr(&attrs, "Target").unwrap_or(""); + let resolved = resolve_target(src_dir, target); + if !parts.contains(&resolved.to_ascii_lowercase()) { + let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?"); + issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}")); + } + } + } + Ok(issues) +} + +/// Every relationship kind (last segment of the type, the same in the transitional and strict +/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry +/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook +/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. +pub(super) fn relationship_kinds(bytes: &[u8]) -> Result, String> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?; + let names = zip_names(&mut zip); + let by_key: HashMap = names.iter().map(|n| (part_key(n), n)).collect(); + // Several relationships name the same part; each fresh check decompressed it again, so + // repeated references to one highly compressible part multiplied the work. The verdict is + // remembered per (part, named content) and the decompression the package can cause is + // budgeted as a whole (finding: repeated decompression). + let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new(); + let mut budget: u64 = 512 * 1024 * 1024; + let mut kinds = BTreeMap::new(); + for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) { + let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue }; + let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { + let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; + let target = attr(attrs, "Target").unwrap_or(""); + if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { + continue; + } + let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + if !external + && let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase()) + && !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget) + { + continue; + } + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + } + } + Ok(kinds) +} + +/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the +/// empty string for the package's `_rels/.rels`. +fn rels_source_dir(key: &str) -> Option { + let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; + Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string()) +} + +/// Whether a part has content: an element under its root, or for comments (whose authors list +/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts. +/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this +/// package's remaining checks may cause, and once it is spent the part counts as having content. +fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool { + let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() { + "comments" => Some(b"comment"), + "vmldrawing" => Some(b"shape"), + _ => None, + }; + let marker = match wanted { + Some(b"comment") => "comment", + Some(_) => "shape", + None => "any", + }; + if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) { + return *v; + } + let v = holds_something_uncached(zip, name, marker, budget); + verdicts.insert((name.to_ascii_lowercase(), marker), v); + v +} + +fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool { + use quick_xml::events::Event; + let Ok(f) = zip.by_name(name) else { return true }; + if *budget == 0 { + return true; + } + let take = f.size().min(MAX_XML_PART).min(*budget); + *budget -= take; + let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take))); + let wanted: Option<&'static [u8]> = match marker { + "comment" => Some(b"comment"), + "shape" => Some(b"shape"), + _ => None, + }; + let mut buf = Vec::new(); + let mut in_root = false; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let found = match wanted { + Some(w) => local_name(e.name().as_ref()) == w, + None => in_root, + }; + if found { + return true; + } + in_root = true; + } + Ok(Event::Eof) => return false, + Ok(_) => {} + Err(_) => return true, + } + buf.clear(); + } +} diff --git a/apps/gridcraft-cli/src/check/report.rs b/apps/gridcraft-cli/src/check/report.rs new file mode 100644 index 000000000..6c0d6b36d --- /dev/null +++ b/apps/gridcraft-cli/src/check/report.rs @@ -0,0 +1,280 @@ +use super::fidelity::value_show; +use super::*; + +// ---------------------------------------------------------------- output + +#[derive(Default)] +struct Totals { + files: usize, + opened: usize, + failed: usize, + formula_cells: usize, + checked: usize, + matched: usize, + mismatched: usize, + no_oracle: usize, + volatile: usize, + bool_as_number: usize, + round_trip_failed: usize, + round_trip_differing: usize, + categories: BTreeMap<&'static str, usize>, + functions: BTreeMap, + signatures: BTreeMap, +} + +fn totals(reports: &[FileReport]) -> Totals { + let mut t = Totals { files: reports.len(), ..Default::default() }; + for r in reports { + if r.error.is_some() { + t.failed += 1; + continue; + } + t.opened += 1; + t.formula_cells += r.calc.formula_cells; + t.checked += r.calc.checked; + t.matched += r.calc.matched; + t.mismatched += r.calc.mismatched; + t.no_oracle += r.calc.no_oracle; + t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; + if r.round_trip.error.is_some() { + t.round_trip_failed += 1; + } + if !r.round_trip.is_clean() { + t.round_trip_differing += 1; + } + for c in r.round_trip.differing() { + *t.categories.entry(c).or_default() += 1; + } + for (n, s) in &r.calc.functions { + t.functions.entry(n.clone()).or_default().add(s); + } + for (sig, n) in &r.calc.signatures { + *t.signatures.entry(sig.clone()).or_default() += n; + } + } + t +} + +/// Functions with mismatches, worst first: most primary mismatches, then most mismatches. +fn ranked(functions: &BTreeMap) -> Vec<(&String, &FnStat)> { + let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect(); + v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); + v +} + +fn functions_json(functions: &BTreeMap) -> Json { + let mut v: Vec<(&String, &FnStat)> = functions.iter().collect(); + v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); + Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect()) +} + +pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { + let files: Vec = reports + .iter() + .map(|r| { + let c = &r.calc; + let mismatches: Vec = c + .mismatches + .iter() + .map(|m| { + json!({ + "cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual), + "kind": m.kind, "functions": m.functions, "primary": m.primary, + }) + }) + .collect(); + let categories: serde_json::Map = r + .round_trip + .categories + .iter() + .map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples}))) + .collect(); + json!({ + "path": r.path, + "opened": r.error.is_none(), + "error": r.error, + "warnings": r.warnings, + "calc": { + "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), + "signatures": c.signatures, + }, + "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, + }) + }) + .collect(); + let t = totals(reports); + json!({ + "schema": SCHEMA, + "tolerance": opts.tolerance, + "files": files, + "totals": { + "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, + "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, + }, + "functions": functions_json(&t.functions), + "signatures": t.signatures, + }) +} + +/// One report line: line breaks and tabs shown escaped so that they stay visible as differences. +fn one_line(s: &str) -> String { + s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t") +} + +/// `…tail` of a long label. +fn fit(s: &str, w: usize) -> String { + let n = s.chars().count(); + if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::()) } +} + +fn print_file_reports(reports: &[FileReport]) { + for r in reports { + outln!("== {}", r.path); + if let Some(e) = &r.error { + outln!(" can't open: {e}"); + continue; + } + let c = &r.calc; + outln!( + " formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile", + c.formula_cells, + c.checked, + c.matched, + c.mismatched, + c.no_oracle, + c.volatile + ); + if c.stale_cache { + outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); + } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } + for m in &c.mismatches { + let tag = if m.primary { "mismatch" } else { " (downstream)" }; + outln!( + " {tag} {} {} expected {} got {} [{}]", + m.cell, + one_line(&m.formula), + value_show(&m.expected), + value_show(&m.actual), + m.functions.join(" ") + ); + } + if c.mismatched > c.mismatches.len() { + outln!(" … {} more mismatches", c.mismatched - c.mismatches.len()); + } + let rt = &r.round_trip; + if let Some(e) = &rt.error { + outln!(" round trip: {e}"); + } + let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect(); + if differing.is_empty() && rt.error.is_none() { + outln!(" round trip: all equal"); + } + for cat in differing { + outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs); + for e in &cat.examples { + outln!(" {}", one_line(e)); + } + if cat.diffs > cat.examples.len() { + outln!(" … {} more", cat.diffs - cat.examples.len()); + } + } + } +} + +fn print_file_table(reports: &[FileReport]) { + let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50); + outln!( + "{:8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip", + "file", + "formulas", + "checked", + "matched", + "mismatch", + "no-oracle", + "volatile" + ); + for r in reports { + let label = fit(&r.path, w); + if let Some(e) = &r.error { + outln!("{label: "FAILED".to_string(), + (None, d) if d.is_empty() => "ok".to_string(), + (None, d) => d.join(","), + }; + outln!( + "{label:8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}", + c.formula_cells, + c.checked, + c.matched, + c.mismatched, + c.no_oracle, + c.volatile + ); + } +} + +fn print_rankings(t: &Totals, opts: &Options) { + let top = ranked(&t.functions); + if !top.is_empty() { + outln!(""); + outln!("top mismatching functions (primary = no mismatching precedent):"); + outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked"); + for (n, s) in top.iter().take(opts.max_report.max(1)) { + outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked); + } + } + let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect(); + sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + if !sigs.is_empty() { + outln!(""); + outln!("mismatch signatures (expected → GridCraft):"); + for (sig, n) in sigs.iter().take(opts.max_report.max(1)) { + outln!(" {n:>8} {sig}"); + } + } +} + +fn print_totals(t: &Totals) { + outln!(""); + outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed); + outln!( + "formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)", + t.formula_cells, + t.checked, + t.matched, + t.mismatched, + t.no_oracle, + t.volatile + ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } + let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); + outln!( + "round trip: {} of {} files differ{}{}", + t.round_trip_differing, + t.opened, + if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() }, + if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) } + ); +} + +pub(super) fn print_human(reports: &[FileReport], opts: &Options) { + print_file_reports(reports); + outln!(""); + print_file_table(reports); + let t = totals(reports); + print_rankings(&t, opts); + print_totals(&t); +} diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs new file mode 100644 index 000000000..68c6f8fb5 --- /dev/null +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -0,0 +1,450 @@ +#![allow(clippy::unwrap_used)] +use std::io::Write as _; +use std::sync::Arc; + +use gridcraft_engine::model::Formula; + +use super::*; + +#[test] +fn discovery_reports_a_directory_read_error() { + let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); + std::fs::write(&path, "not a directory").unwrap(); + let mut discovery = Discovery::default(); + let mut seen = std::collections::HashSet::new(); + assert!(walk(&path, 0, &mut discovery, &mut seen)); + assert!(discovery.files.is_empty()); + assert_eq!(discovery.failures.len(), 1); + assert_eq!(discovery.failures[0].path, path.to_string_lossy()); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn discovery_reports_omissions_only_after_the_file_limit() { + let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; + assert!(discovery.add_file(PathBuf::from("last.xlsx"))); + assert!(discovery.failures.is_empty()); + assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); + assert_eq!(discovery.files.len(), MAX_FILES); + assert_eq!(discovery.failures[0].path, "omitted.xlsx"); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); +} + +fn at(a1: &str) -> CellRef { + CellRef::parse(a1).unwrap() +} + +/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a +/// formula with that cached value, anything else a constant. +fn book(cells: &[(&str, &str, Value)]) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for (a1, input, cached) in cells { + let cell = if input.starts_with('=') { + Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } + } else { + Cell::value(cached.clone()) + }; + sh.set_cell(at(a1), cell); + } + wb +} + +fn check(wb: &Workbook, opts: &Options) -> FileReport { + let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); + check_bytes("t.xlsx", &bytes, opts) +} + +fn n(x: f64) -> Value { + Value::number(x) +} + +#[test] +fn matching_cached_values_pass() { + let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); + let r = check(&wb, &Options::default()); + assert_eq!(r.error, None); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); + assert!(r.is_clean(), "{r:?}"); +} + +#[test] +fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { + // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with + // Excel's 7, so it mismatches too, but only as a downstream effect. + let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); + let first = &r.calc.mismatches[0]; + assert_eq!(first.cell, "Sheet1!B1"); + assert_eq!(first.formula, "=SUM(A1:A2)"); + assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); + assert!(!r.calc.mismatches[1].primary); + assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); + assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); + assert!(!r.is_clean()); +} + +#[test] +fn errors_compare_by_kind() { + let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); + assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); + assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); + assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); + assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); + assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); +} + +#[test] +fn numbers_compare_within_tolerance() { + assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); + assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); + // Absolute near zero. + assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); + assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); + assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); + assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); + assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); + assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); + assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); + let wb = book(&[("A1", "=1+2", n(3.0000000001))]); + assert_eq!(check(&wb, &Options::default()).calc.matched, 1); + assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); +} + +/// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: +/// the cache holds the right value under a number type, so it matches and is counted apart. +#[test] +fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); +} + +#[test] +fn missing_cached_values_and_volatile_formulas_are_skipped() { + let wb = + book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); +} + +#[test] +fn full_calc_on_load_means_no_oracle() { + // A formula without a cached value makes GridCraft's writer flag the file + // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" + // value must not count as a mismatch then. + let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); + let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + assert!(full_calc_on_load(&bytes)); + let r = check_bytes("t.xlsx", &bytes, &Options::default()); + assert!(r.calc.stale_cache); + assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); + let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); + assert!(!full_calc_on_load(&fresh)); + assert!(!full_calc_on_load(b"not a zip")); +} + +#[test] +fn volatile_names_propagate() { + let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); + wb.names.push(gridcraft_engine::model::DefinedName { + name: "Stamp".into(), + scope: None, + formula: "TODAY()".into(), + comment: String::new(), + hidden: false, + }); + assert!(volatile_names(&wb).contains("STAMP")); + assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); +} + +#[test] +fn round_trip_of_a_simple_book_is_clean() { + let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); + wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); + assert_eq!(rt.error, None); + assert!(rt.is_clean(), "{rt:?}"); + assert!(rt.categories.iter().any(|c| c.name == "package")); +} + +#[test] +fn diff_detects_lost_features() { + let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); + a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let mut b = a.clone(); + let sh = b.sheet_mut(0).unwrap(); + sh.merges.clear(); + sh.set_cell(at("A1"), Cell::value(n(3.0))); + sh.set_cell(at("B1"), Cell::value(n(6.0))); + b.sheets.push(Arc::new(Sheet::new("Extra"))); + let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); + let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); + assert_eq!(get("values").diffs, 1); + assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); + assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); + assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); + assert_eq!(get("sheets").diffs, 1); + assert_eq!(get("tables").diffs, 0); +} + +#[test] +fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); +} + +fn zip_of(parts: &[(&str, &str)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in parts { + z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn package_validation_finds_what_excel_would_repair() { + let bytes = zip_of(&[ + ( + "[Content_Types].xml", + r#""#, + ), + ("_rels/.rels", r#""#), + ("xl/workbook.xml", ""), + ("xl/styles.xml", ""), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ]); + let issues = validate_package(&bytes).unwrap(); + assert_eq!( + issues, + [ + "part xl/styles.xml has no content type", + "content type override for missing part /xl/gone.xml", + "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", + "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", + ] + ); + assert!(validate_package(b"not a zip").is_err()); + let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); + assert_eq!(validate_package(&own).unwrap(), Vec::::new()); +} + +const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; +const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; +const MADE_UP: &str = r#""#; +/// Not empty: an empty part isn't a loss. +const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + +/// `bytes` with relationships added to rels parts (created when missing) and `parts` added. +fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// The `package` examples of checking `source`. +fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() +} + +fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() +} + +#[test] +fn package_reports_relationship_kinds_the_output_lost() { + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let calc_chain = + r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); +} + +// In the three tests below the made-up kind stays lost, so the comparison is known to run. + +#[test] +fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn json_lists_warning_messages() { + let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; + let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; + let v = report_json(&[with, without], &Options::default()); + assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); + assert_eq!(v["files"][1]["warnings"], json!([])); +} + +#[test] +fn unopenable_files_are_reported() { + let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); + assert!(r.error.is_some()); + assert!(!r.is_clean()); +} + +#[test] +fn json_schema_keys() { + let wb = book(&[("A1", "=1+1", n(3.0))]); + let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; + let v = report_json(&reports, &Options::default()); + let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); + let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); + assert_eq!(v["schema"], SCHEMA); + let f = &v["files"][0]; + assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); + assert_eq!( + keys(&f["calc"]), + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) + ); + assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); + assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); + assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); + assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); + assert_eq!( + keys(&f["roundTrip"]["categories"]), + set(&[ + "sheets", + "values", + "formulas", + "numFmts", + "merges", + "names", + "tables", + "condFormats", + "validations", + "charts", + "comments", + "hyperlinks", + "pivots", + "package" + ]) + ); + assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); + assert_eq!(v["files"][1]["opened"], json!(false)); + assert_eq!( + keys(&v["totals"]), + set(&[ + "files", + "opened", + "failedToOpen", + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "volatile", + "boolAsNumber", + "roundTripFailed", + "roundTripDiffering", + "categoriesDiffering" + ]) + ); + assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); +} diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs index 17f8db87f..5906b3967 100644 --- a/apps/gridcraft-cli/tests/it/main.rs +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -1,5 +1,3 @@ //! The crate's integration tests, one module per file, built as one test binary. - -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod cli; mod text_to_columns_quotes; diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 43923bde1..10e17dc44 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url) { // With `noopener` the new browsing context can't hold a reference back to the - // editor window and navigate it while the user reads the linked page. - let _ = w.open_with_url_and_target(&url, "_blank,noopener"); + // editor window and navigate it while the user reads the linked page. It must be a + // window *feature*: inside the target string it would only name the new window. + let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer"); } })), inbox: Some(inbox), diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 8429070b7..e5ad73da4 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index cc5a80c10..7cba6db2c 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -12,16 +12,47 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; use gridcraft_mcp::control_auth; use gridcraft_ui_egui::ControlRequest; use serde_json::{Value as Json, json}; const MAX_LINE: usize = 64 * 1024 * 1024; -/// How long a new connection may take to send its token. +/// How long a new connection may take to send its token, in total. const AUTH_TIMEOUT: Duration = Duration::from_secs(10); +/// How many connections may be waiting to authenticate at once. Anyone on the machine can +/// connect without the token, so past this a new connection is closed before anything is read: +/// each waiting one holds a thread and a socket. +const MAX_PENDING_AUTH: usize = 16; + +/// How the server treats connections that haven't authenticated yet. +#[derive(Clone, Copy)] +struct AuthLimits { + timeout: Duration, + max_pending: usize, +} + +const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH }; + +/// A connection that is still waiting to authenticate; dropping it frees its place. +struct Pending(Arc); + +impl Pending { + /// A place among the `max` waiting connections, or `None` when they are all taken. + fn claim(waiting: &Arc, max: usize) -> Option { + waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?; + Some(Pending(Arc::clone(waiting))) + } +} + +impl Drop for Pending { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} /// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`), /// else 256 random bits from the operating system, written to a file only the user can read. @@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std /// Accept connections on `listener`; requests arrive on the returned receiver. fn serve_listener(listener: TcpListener, token: Arc, ctx: egui::Context) -> std::io::Result> { + serve_listener_with(listener, token, ctx, AUTH_LIMITS) +} + +fn serve_listener_with(listener: TcpListener, token: Arc, ctx: egui::Context, limits: AuthLimits) -> std::io::Result> { let (tx, rx) = channel(); + let waiting = Arc::new(AtomicUsize::new(0)); std::thread::Builder::new().name("control".into()).spawn(move || { - for stream in listener.incoming().flatten() { + for stream in listener.incoming() { + let Ok(stream) = stream else { + // Out of sockets or handles: wait for some to free instead of spinning on the error. + std::thread::sleep(Duration::from_millis(50)); + continue; + }; + // Dropping the stream closes it before anything is read. + let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue }; let tx = tx.clone(); let ctx = ctx.clone(); let token = Arc::clone(&token); - let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx)); + let _ = + std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place))); } })?; Ok(rx) } /// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within -/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on. -fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str) -> bool { - let _ = out.set_read_timeout(Some(AUTH_TIMEOUT)); - let mut first = String::new(); - let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first); - let verdict = match read { - Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token), +/// `timeout` in total) and check the token. `true` when the peer may go on. +#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it +fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str, timeout: Duration) -> bool { + let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now); + let mut first = Vec::new(); + let read = read_auth_line(reader, &mut first, deadline); + let verdict = match (read, std::str::from_utf8(&first)) { + (Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token), _ => Err(json!({"ok": false, "error": "authentication required"})), }; let allowed = verdict.is_ok(); let reply = verdict.unwrap_or_else(|e| e); let _ = writeln!(out, "{reply}"); - let _ = out.set_read_timeout(None); + let _ = reader.get_ref().set_read_timeout(None); allowed } +/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`. +/// +/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps +/// its own, so one set on the writing half never fired (a silent peer held its thread forever). +/// It is shortened before every read, so a peer sending one byte at a time can't stretch it. +#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it +fn read_auth_line(reader: &mut BufReader, line: &mut Vec, deadline: Instant) -> std::io::Result<()> { + let max = control_auth::MAX_AUTH_LINE; + while line.len() < max && line.last() != Some(&b'\n') { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::ErrorKind::TimedOut.into()); + } + reader.get_ref().set_read_timeout(Some(left))?; + let buf = reader.fill_buf()?; + if buf.is_empty() { + break; + } + let room = max.saturating_sub(line.len()); + let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1); + line.extend(buf.iter().take(take)); + reader.consume(take); + } + Ok(()) +} + /// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). /// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { @@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool { || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) } -fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { +fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_timeout: Duration, place: Option) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; let mut reader = BufReader::new(read); - if !authenticate(&mut reader, &mut out, token) { + let allowed = authenticate(&mut reader, &mut out, token, auth_timeout); + // Authenticated or refused, the connection no longer counts as waiting. + drop(place); + if !allowed { return; } let mut buf = Vec::new(); @@ -294,7 +368,7 @@ mod tests { std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); - std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default())); + std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None)); } }); addr @@ -346,6 +420,81 @@ mod tests { assert_eq!(got[1]["ok"], json!(false)); } + /// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within + /// `within`, and how long it took. + #[allow(clippy::disallowed_methods)] // a native-only test of the desktop app + fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) { + let start = Instant::now(); + c.set_read_timeout(Some(within)).unwrap(); + let mut r = BufReader::new(c); + let mut line = String::new(); + loop { + line.clear(); + match r.read_line(&mut line) { + Ok(0) => return (true, start.elapsed()), + Ok(_) => continue, + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()), + Err(_) => return (true, start.elapsed()), + } + } + } + + /// The token deadline is for the whole first line: neither a silent peer nor one sending a + /// byte at a time keeps the connection (and its thread) past it. + #[test] + fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH }; + let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap(); + + let silent = TcpStream::connect(addr).unwrap(); + let mut slow = TcpStream::connect(addr).unwrap(); + let mut drip = slow.try_clone().unwrap(); + std::thread::spawn(move || { + for b in control_auth::auth_line(1, TOKEN).bytes() { + if drip.write_all(&[b]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + }); + let (closed, took) = closed_within(silent, Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}"); + let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}"); + let _ = slow.flush(); + } + + /// Peers without the token can't pile up threads: past the cap a new connection is closed at + /// once, and a waiting one that authenticates frees its place. + #[test] + fn connections_waiting_to_authenticate_are_capped() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 }; + let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap(); + + let mut first = TcpStream::connect(addr).unwrap(); + let _second = TcpStream::connect(addr).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5)); + assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept"); + + first.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap(); + let mut reply = String::new(); + BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap()["ok"], json!(true), "{reply}"); + std::thread::sleep(Duration::from_millis(200)); + let mut next = TcpStream::connect(addr).unwrap(); + next.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap(); + let mut reply = String::new(); + BufReader::new(next).read_line(&mut reply).unwrap(); + assert_eq!(serde_json::from_str::(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}"); + } + #[test] fn a_chosen_token_must_be_long_enough() { assert!(make_token(1, Some("short".into())).is_err()); diff --git a/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/logging.rs b/apps/gridcraft/src/logging.rs index a0f62d164..ca7d05717 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -268,9 +268,10 @@ impl log::Log for AppLogger { } // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. let thread = std::thread::current(); - #[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it - let line = - format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + let logged_at = gridcraft_engine::core::date::unix_now_ms() + .and_then(|milliseconds| SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(milliseconds.max(0.0) as u64))) + .unwrap_or(SystemTime::UNIX_EPOCH); + let line = format_line(×tamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); if self.stderr { // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. let _ = std::io::stderr().write_all(line.as_bytes()); @@ -295,303 +296,4 @@ pub fn install() -> Option<&'static AppLogger> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { - use log::Log; - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - } - - #[test] - fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); - assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); - assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "gridcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,gridcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "gridcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - } - assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than - // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built - // with a checked add and only asserted where it exists. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), - "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); - assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); - record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); - record(&logger, log::Level::Debug, "gridcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("gridcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - assert!(logger.attach_dir(&dir).is_err()); - record(&logger, log::Level::Error, "x", "still fine"); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - record(&logger, log::Level::Warn, "gridcraft", "after the poison"); - assert!(read(&path).contains("gridcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/gridcraft/src/logging/tests.rs b/apps/gridcraft/src/logging/tests.rs new file mode 100644 index 000000000..bfc0f81a1 --- /dev/null +++ b/apps/gridcraft/src/logging/tests.rs @@ -0,0 +1,298 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); +} + +#[test] +fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); + assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); + assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "gridcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,gridcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "gridcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), + "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); + assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); + record(&logger, log::Level::Debug, "gridcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("gridcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "gridcraft", "after the poison"); + assert!(read(&path).contains("gridcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 988bf738b..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")] @@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> { })); let args: Vec = std::env::args().skip(1).collect(); // The restart helper a self-update spawns: it finishes the swap and relaunches, then exits. - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("GridCraft update helper failed: {e}"); std::process::exit(1); @@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> { { log::warn!("sample {s}: {e}"); } - if session.documents().is_empty() { - session.new_workbook(); - } let mut app = SheetApp::new(session, services()); app.watch_desktop_theme(); app.start_update_check(); - app.after_engine(); // Show warnings from the sample. let control_port = control_port; load_prefs(&mut app); + // Stamp the startup workbook's author from the persisted user name. + if app.session.documents().is_empty() { + app.session.new_workbook(); + } + app.after_engine(); // Show warnings from the sample. // Files named on the command line (a double-click on Windows and Linux) open as File › Open // opens them: a password-protected one asks for its password, and errors are shown, not // only logged. Each replaces the untouched blank Book1, as a file opened from Finder does. @@ -340,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/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 522f4fd68..04139297d 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -390,11 +390,12 @@ impl NativeMenu { #[cfg(test)] mod tests { - use super::*; + // No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test + // harness, so the #[test] functions are left out and a module-level import would be unused. #[test] fn file_menu_has_export_as_pdf_dispatching_the_command() { - let tree = tree(); + let tree = super::tree(); let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); assert_eq!(item.1, "file.exportPdf"); @@ -403,23 +404,23 @@ mod tests { #[test] fn help_menu_checks_for_updates_through_the_registered_command() { - let tree = tree(); + let tree = super::tree(); let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu"); let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu"); assert_eq!(item.1, "app.checkForUpdates"); - let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default()); assert!(app.run(item.1, serde_json::json!({})).is_ok()); } #[test] fn theme_choices_use_the_shared_preference_command() { - let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default()); for mode in ["dark", "system", "light"] { - assert!(select_theme(&mut app, &format!("theme:{mode}"))); + assert!(super::select_theme(&mut app, &format!("theme:{mode}"))); assert_eq!(app.ui.theme_mode(), mode); assert!(app.message.is_none()); } - assert!(!select_theme(&mut app, "view.formulaBar")); + assert!(!super::select_theme(&mut app, "view.formulaBar")); assert_eq!(app.ui.theme_mode(), "light"); } } diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs index e713f6699..ca1eef509 100644 --- a/apps/gridcraft/tests/mac_menu_appkit.rs +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -7,7 +7,6 @@ // unit tests compile here too without a harness to run them). #[cfg(target_os = "macos")] #[path = "../src/native_menu.rs"] -#[allow(unused_imports, dead_code)] mod native_menu; #[cfg(target_os = "macos")] @@ -20,12 +19,23 @@ fn main() { nsapp.finishLaunching(); let ctx = egui::Context::default(); - let _menu = native_menu::NativeMenu::install(&ctx); + let mut menu = native_menu::NativeMenu::install(&ctx); let bar = nsapp.mainMenu().expect("a main menu"); let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]); - assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu"); + assert_eq!(windows_menu.title().to_string(), "Window"); + + // A poll lists the open workbook windows at the end of the Window menu (#716). + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default()); + menu.poll(&mut app, &ctx, egui::ViewportId::ROOT); + let open = app.open_windows(); + assert_eq!(open.len(), 1, "one workbook window"); + let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item"); + assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook"); println!("mac_menu_appkit: ok"); } diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index fdf0b0e09..349dcfd5a 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,7 +1,7 @@ # GridCraft app icon (PLACEHOLDER) **This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the -product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet. +product owner makes; GridCraft's hasn't been made yet. Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number column, a selected cell with its fill handle and a double rule under a total, on a full-bleed GridCraft-green tile. It is original geometry written by hand for this repository, deliberately diff --git a/assets/app-icon/gridcraft.svg b/assets/app-icon/gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/gridcraft.svg +++ b/assets/app-icon/gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg index fd53f9e1b..38e3e802a 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.gridcraft.svg @@ -1,7 +1,7 @@ diff --git a/clippy.toml b/clippy.toml index 8b8bc5a3a..c6fbddafd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -10,3 +10,8 @@ disallowed-methods = [ { path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" }, { path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" }, ] + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 5ce80fe59..0e5b14ca4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -103,3 +100,9 @@ real_name = "Helen Kwok" [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true + +[people.Plutonium-fream] +sync_github_name = true diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4bdb3a30d..4972623fc 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -7,10 +7,22 @@ use std::sync::Arc; use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare}; -use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp}; use gridcraft_functions::{Arg, Ctx}; use gridcraft_model::Workbook; +mod array; +mod call; +mod format; +mod lambda; +mod reference_fns; +mod special; + +use array::map_unary; +pub use array::{binary_values, zip2}; +pub use format::r1c1_to_a1; +pub use special::{SPECIAL_FUNCTIONS, is_known_function, precedents, resized_sum_range}; + /// Largest array a reference may expand to. pub const MAX_CELLS: u64 = 16_000_000; /// References up to this many cells (one whole row) are always read at their full size. @@ -615,1021 +627,6 @@ impl<'h> Evaluator<'h> { } } } - - fn call(&mut self, name: &str, args: &[Expr]) -> Ev { - match name { - "IF" => { - if args.is_empty() || args.len() > 3 { - return err(CellError::Value); - } - let c = self.arg_val(args, 0).unwrap_or_default(); - if let Value::Array(arr) = &c { - // Array condition: element-wise. - let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); - let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; - return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { - Ok(true) => t.clone(), - Ok(false) => f.clone(), - Err(e) => Value::Error(e), - })); - } - match c.to_bool() { - Ok(true) => match args.get(1) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(true)), - }, - Ok(false) => match args.get(2) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(false)), - }, - Err(e) => err(e), - } - } - "IFS" => { - if args.len() < 2 || !args.len().is_multiple_of(2) { - return err(CellError::Value); - } - for pair in args.chunks(2) { - let [c, v] = pair else { break }; - let cv = self.value(c); - match cv.scalar().to_bool() { - Ok(true) => return self.eval(v), - Ok(false) => {} - Err(e) => return err(e), - } - } - err(CellError::NA) - } - "IFERROR" | "IFNA" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); - let na_only = name == "IFNA"; - let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; - match &v { - Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), - Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - Ev::R(_) => { - let val = self.deref(v.clone()); - match val { - Value::Error(e) if catch(e) => self.eval(&args[1]), - Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - _ => v, - } - } - _ => v, - } - } - "CHOOSE" => { - if args.len() < 2 { - return err(CellError::Value); - } - let index = self.value(&args[0]); - if let Value::Array(_) = index { - // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` - // is a two-column array. The library CHOOSE lifts over its arguments. - let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; - let mut evaluated = vec![Arg { value: index, from_ref: false }]; - for a in &args[1..] { - let value = self.value(a); - evaluated.push(Arg { value, from_ref: false }); - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); - } - let i = match index.to_number() { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if i < 1 || i as usize >= args.len() { - return err(CellError::Value); - } - self.eval(&args[i as usize]) - } - "SWITCH" => { - if args.len() < 3 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default().scalar(); - if let Value::Error(e) = v { - return err(e); - } - let rest = &args[1..]; - let mut i = 0; - while i + 1 < rest.len() { - let cand = self.value(&rest[i]).scalar(); - if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { - return self.eval(&rest[i + 1]); - } - i += 2; - } - if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } - } - "ROW" | "COLUMN" => { - let is_row = name == "ROW"; - if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { - return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - let Some(a) = a.first() else { return err(CellError::Ref) }; - let r = self.trim_lines(a); - if is_row { - let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); - if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } - } else { - let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); - if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } - } - } - Ev::V(Value::Error(e)) => err(e), - _ => err(CellError::Value), - } - } - "ROWS" | "COLUMNS" => { - if args.len() != 1 { - return err(CellError::Value); - } - let is_rows = name == "ROWS"; - match self.eval(&args[0]) { - Ev::R(a) => match a.as_slice() { - [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), - _ => err(CellError::Ref), - }, - Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), - Ev::V(Value::Error(e)) => err(e), - Ev::V(_) => Ev::V(Value::Number(1.0)), - Ev::L(_) => err(CellError::Value), - } - } - "AREAS" => match args.first().map(|a| self.eval(a)) { - Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), - Some(Ev::V(Value::Error(e))) => err(e), - _ => err(CellError::Value), - }, - "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), - "ISFORMULA" | "FORMULATEXT" => { - let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; - let Some(a) = a.first() else { return err(CellError::NA) }; - let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); - if name == "ISFORMULA" { - Ev::V(Value::Bool(f.is_some())) - } else { - match f { - Some(f) => Ev::V(Value::text(format!("={}", f.text))), - None => err(CellError::NA), - } - } - } - "SHEET" | "SHEETS" => { - let wb = self.host.workbook(); - if args.is_empty() { - return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - if name == "SHEET" { - a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) - } else { - let mut s: Vec = a.iter().map(|x| x.sheet).collect(); - s.dedup(); - Ev::V(Value::number(s.len() as f64)) - } - } - Ev::V(Value::Text(t)) if name == "SHEET" => { - let wb = self.host.workbook(); - wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) - } - _ => err(CellError::NA), - } - } - "OFFSET" => self.offset(args), - "INDIRECT" => self.indirect(args), - "INDEX" => self.index(args), - "XLOOKUP" => self.xlookup(args), - "CELL" => self.cell_info(args), - "TRIMRANGE" => self.trimrange(args), - "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), - "TEXT" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default(); - let f = self.arg_val(args, 1).unwrap_or_default(); - let sys = self.host.workbook().date_system; - let one = |v: &Value, f: &Value| -> Value { - if let Value::Error(e) = v { - return Value::Error(*e); - } - let code = match f.to_text() { - Ok(c) => c, - Err(e) => return Value::Error(e), - }; - match gridcraft_numfmt::text_function(v, &code, sys) { - Ok(s) => Value::text(s), - Err(e) => Value::Error(e), - } - }; - Ev::V(zip2(&v, &f, one)) - } - "LET" => self.let_(args), - "LAMBDA" => { - if args.is_empty() { - return err(CellError::Value); - } - let mut params = Vec::new(); - for p in &args[..args.len() - 1] { - match p { - Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), - _ => return err(CellError::Value), - } - } - let Some(body) = args.last() else { return err(CellError::Value) }; - Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) - } - "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); - Ev::V(Value::Bool(r)) - } - "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), - "GROUPBY" | "PIVOTBY" => { - // The aggregation may be a function given by name (`SUM`): pass it as its name. - let i = if name == "GROUPBY" { 2 } else { 3 }; - match args.get(i).and_then(|a| self.function_by_name(a)) { - Some(f) => { - let mut args = args.to_vec(); - if let Some(a) = args.get_mut(i) { - *a = Expr::Text(f.into()); - } - self.builtin(name, &args) - } - None => self.builtin(name, args), - } - } - "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { - self.host.note_volatile(); - self.builtin(name, args) - } - _ => self.builtin(name, args), - } - } - - fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { - let Some(spec) = gridcraft_functions::lookup(name) else { - // A defined name holding a LAMBDA, or a LET-bound lambda. - return match self.name(name) { - Ev::L(l) => self.apply_lambda(&l, args), - Ev::V(Value::Error(x)) => err(x), - _ => err(CellError::Name), - }; - }; - if args.len() < spec.min_args || args.len() > spec.max_args { - return err(CellError::Value); - } - if let Some(kind) = Agg::of(spec.name) { - return self.aggregate(kind, args); - } - let mut evaluated = Vec::with_capacity(args.len()); - // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. - let mut criteria_shape = None; - // Database functions: the criteria range (argument 2), whose formulas are computed criteria. - let mut criteria = None; - // GROUPBY/PIVOTBY: the LAMBDA given as their function. - let mut lambda = None; - let lambda_at = match name { - "GROUPBY" => Some(2), - "PIVOTBY" => Some(3), - _ => None, - }; - for (i, a) in args.iter().enumerate() { - let mut ev = self.eval(a); - if lambda_at == Some(i) - && let Ev::L(l) = &ev - { - lambda = Some(l.clone()); - ev = Ev::V(Value::Empty); - } - if spec.category == gridcraft_functions::Category::Database - && i == 2 - && let Ev::R(areas) = &ev - && let [area] = areas.as_slice() - { - criteria = Some(*area); - } - if resizes_sum_range(name, args.len()) { - if i == 0 { - criteria_shape = ev_shape(&ev); - } else if i == 2 - && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) - && let [area] = areas.as_slice() - { - ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); - } - } - let from_ref = matches!(ev, Ev::R(_)); - let value = match ev { - Ev::R(areas) if areas.len() > 1 => { - // Multi-area references (unions): flatten into one row for aggregates. - let mut vals = Vec::new(); - for ar in &areas { - match self.area_value(ar) { - Value::Array(a) => vals.extend(a.data.iter().cloned()), - v => vals.push(v), - } - } - Value::from(Array::row(vals)) - } - ev => self.deref(ev), - }; - evaluated.push(Arg { value, from_ref }); - } - let mut ctx = FnCtx { ev: self, criteria, lambda }; - gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) - } - - /// Performs aggregation operations without copying its ranges: blocks are folded over the stored - /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one - /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. - /// Only `SUM`, `COUNT` and `AVG` are supported currently. - fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { - let mut acc = Acc::default(); - let mut first_error = None; - for a in args { - let ev = self.eval(a); - let steps = match ev { - Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), - ev => { - let value = self.deref(ev); - vec![self.aggregate_value(kind, value, false, &mut acc)] - } - }; - if first_error.is_none() { - first_error = steps.into_iter().find_map(Result::err); - } - } - first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) - } - - /// Adds one area to `acc`; `Err` is the result the function ends with. - fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { - if !a.range.is_single() { - let mut error = None; - let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { - Ok(()) => true, - Err(e) => { - error = Some(e); - false - } - }); - if walked { - return error.map_or(Ok(()), |e| Err(err(e))); - } - } - let value = self.area_value(a); - self.aggregate_value(kind, value, true, acc) - } - - /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules - /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). - fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { - let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { - return Err(err(CellError::Name)); - }; - let args = [Arg { value, from_ref }]; - if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.sum += n, - other => return Err(Ev::V(other)), - } - } - if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), - other => return Err(Ev::V(other)), - } - } - Ok(()) - } - - pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { - // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in - // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` - // parses to no arguments, which for a one-parameter lambda can also be read as one empty - // position, so that call still binds the parameter as omitted. - let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); - if !positions_ok { - return err(CellError::Value); - } - let mut bound = Vec::with_capacity(l.params.len()); - for (i, p) in l.params.iter().enumerate() { - let v = match args.get(i) { - Some(Expr::Missing) | None => Ev::V(Value::Empty), - Some(e) => self.eval(e), - }; - bound.push((p.clone(), v)); - } - self.call_lambda_values(l, bound) - } - - fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { - if self.calls >= MAX_LAMBDA_CALLS { - return err(CellError::Num); - } - // #2382: one charge per invocation, so a cell's total work stays bounded however the - // lambdas nest. Foregrounding the charge here means every path into a body (direct - // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. - self.fuel = match self.fuel.checked_sub(1) { - Some(f) => f, - None => return err(CellError::Calc), - }; - self.calls += 1; - let saved = std::mem::replace(&mut self.env, l.env.clone()); - self.env.extend(bound); - let r = self.eval(&l.body); - self.env = saved; - self.calls -= 1; - r - } - - fn let_(&mut self, args: &[Expr]) -> Ev { - if args.len() < 3 || args.len().is_multiple_of(2) { - return err(CellError::Value); - } - let base = self.env.len(); - for pair in args[..args.len() - 1].chunks(2) { - let [Expr::Name(n), v] = pair else { - self.env.truncate(base); - return err(CellError::Value); - }; - let val = self.eval(v); - self.env.push((n.clone(), val)); - } - let r = match args.last() { - Some(body) => self.eval(body), - None => err(CellError::Value), - }; - self.env.truncate(base); - r - } - - /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, - /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has - /// that name. - fn function_by_name(&self, e: &Expr) -> Option { - let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { - return None; - } - let u = n.to_ascii_uppercase(); - gridcraft_functions::lookup(&u).map(|_| u) - } - - /// The function argument of a LAMBDA helper; a function given by name takes `arity` - /// arguments. - fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { - if let Some(f) = e.and_then(|x| self.function_by_name(x)) { - let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); - let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); - return Some(Arc::new(Lambda { params, body, env: Vec::new() })); - } - match e.map(|x| self.eval(x)) { - Some(Ev::L(l)) => Some(l), - _ => None, - } - } - - fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { - let as_array = |v: Value| -> Arc { - match v { - Value::Array(a) => a, - other => Arc::new(Array::scalar(other)), - } - }; - match name { - "MAP" => { - if args.len() < 2 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; - let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); - let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); - let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); - let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; - // The LAMBDA takes one parameter per array. - if l.params.len() != arrays.len() { - return err(CellError::Value); - } - let mut out = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - "REDUCE" | "SCAN" => { - if args.len() != 3 { - return err(CellError::Value); - } - let mut acc = self.value(&args[0]); - let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let mut scan = Vec::with_capacity(arr.data.len()); - for v in arr.data.iter() { - let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; - let r = self.call_lambda_values(&l, bound); - acc = self.deref(r); - if name == "SCAN" { - let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; - scan.push(one); - } - } - if name == "SCAN" { - Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } else { - Ev::V(acc) - } - } - "BYROW" | "BYCOL" => { - if args.len() != 2 { - return err(CellError::Value); - } - let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; - let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; - if l.params.len() != 1 { - return err(CellError::Value); - } - let by_row = name == "BYROW"; - let n = if by_row { arr.rows } else { arr.cols }; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let slice: Vec = if by_row { - (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() - } else { - (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() - }; - let a = if by_row { Array::row(slice) } else { Array::column(slice) }; - let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); - let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; - out.push(one); - } - let a = if by_row { Array::column(out) } else { Array::row(out) }; - Ev::V(a.into()) - } - "MAKEARRAY" => { - if args.len() != 3 { - return err(CellError::Value); - } - let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { - (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let (rows, cols) = (rows as usize, cols as usize); - let mut out = Vec::with_capacity(rows * cols); - for r in 0..rows { - for c in 0..cols { - let bound = vec![ - (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), - (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), - ]; - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - _ => err(CellError::Name), - } - } - - /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. - fn trim_lines(&mut self, a: &Area) -> RangeRef { - if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } - } - - fn offset(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.len() < 3 || args.len() > 5 { - return err(CellError::Value); - } - let base = match self.eval(&args[0]) { - Ev::R(a) if a.len() == 1 => a[0], - Ev::V(Value::Error(e)) => return err(e), - _ => return err(CellError::Value), - }; - let rows = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let cols = match self.arg_num(args, 2, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let h = match self.arg_num(args, 3, base.range.height() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let w = match self.arg_num(args, 4, base.range.width() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if h == 0 || w == 0 { - return err(CellError::Ref); - } - let r0 = (base.range.start.row as i64).saturating_add(rows); - let c0 = (base.range.start.col as i64).saturating_add(cols); - let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; - let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; - if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { - return err(CellError::Ref); - } - Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) - } - - fn indirect(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.is_empty() || args.len() > 2 { - return err(CellError::Value); - } - let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t, - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let a1 = match args.get(1) { - None | Some(Expr::Missing) => true, - Some(e) => { - let v = self.value(e); - v.scalar().to_bool().unwrap_or(true) - } - }; - // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. - let (text, a1_ok) = if a1 { - (text, true) - } else { - match r1c1_to_a1(&text, self.at) { - Ok(Some(a1_text)) => (a1_text, true), - Ok(None) => (text, false), - Err(e) => return err(e), - } - }; - match gridcraft_formula::parse(&text) { - Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), - Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { - r @ Ev::R(_) => r, - _ => err(CellError::Ref), - }, - _ => err(CellError::Ref), - } - } - - fn index(&mut self, args: &[Expr]) -> Ev { - if args.is_empty() || args.len() > 4 { - return err(CellError::Value); - } - let src = self.eval(&args[0]); - let mut arg = |i: usize| match args.get(i) { - None | Some(Expr::Missing) => None, - Some(e) => Some(self.value(e)), - }; - let (rows, cols) = (arg(1), arg(2)); - let area_n = match self.arg_num(args, 3, 1.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let two = args.len() == 2; - let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); - // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. - if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { - let as_array = |v: &Option| match v { - Some(Value::Array(a)) => Arc::clone(a), - Some(v) => Arc::new(Array::scalar(v.clone())), - None => Arc::new(Array::scalar(Value::Number(0.0))), - }; - let (ra, ca) = (as_array(&rows), as_array(&cols)); - let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); - if (h as u64).saturating_mul(w as u64) > MAX_CELLS { - return err(CellError::Num); - } - let mut out = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); - let v = match pos { - (Ok(row), Ok(col)) => { - let one = self.index_one(src.clone(), row, col, area_n, two); - match self.deref(one) { - Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), - v => v.scalar(), - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - out.push(v); - } - } - return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); - } - let (row, col) = match (num(&rows), num(&cols)) { - (Ok(r), Ok(c)) => (r, c), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - self.index_one(src, row, col, area_n, two) - } - - /// INDEX for one row and column position (0 = the whole column or row). - fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { - if row < 0 || col < 0 { - return err(CellError::Value); - } - match src { - Ev::R(areas) => { - let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); - let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; - let r = a.range; - // A single row or column with one index picks along it. An area with several rows - // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third - // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: - // `INDEX({1,2;3,4},2)` is the row. - if two_args && r.height() > 1 && r.width() > 1 { - return err(CellError::Ref); - } - let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; - if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { - return err(CellError::Ref); - } - let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; - let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; - Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) - } - Ev::V(Value::Array(arr)) => { - let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; - if row as usize > arr.rows || col as usize > arr.cols { - return err(CellError::Ref); - } - match (row, col) { - (0, 0) => Ev::V(Value::Array(arr)), - (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), - (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), - (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), - } - } - Ev::V(Value::Error(e)) => err(e), - Ev::V(v) => { - if row <= 1 && col <= 1 { - Ev::V(v) - } else { - err(CellError::Ref) - } - } - Ev::L(_) => err(CellError::Value), - } - } - - /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can - /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of - /// lookup values, array arguments, errors) goes to the library function. - fn xlookup(&mut self, args: &[Expr]) -> Ev { - if (3..=6).contains(&args.len()) - && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) - && let ([l], [r]) = (look.as_slice(), ret.as_slice()) - && let Some(spec) = gridcraft_functions::lookup("XMATCH") - { - let key = self.value(&args[0]); - let (l, r) = (*l, *r); - let vertical = l.range.width() == 1 && l.range.height() > 1; - let horizontal = l.range.height() == 1 && l.range.width() > 1; - let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; - if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { - let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; - // An empty match_mode before a given search_mode is the default (0), not the end. - let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); - let last = if given(5) { - 5 - } else if given(4) { - 4 - } else { - 3 - }; - for i in 4..=last { - match args.get(i) { - Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), - Some(e) => { - let value = self.value(e); - xargs.push(Arg { value, from_ref: false }); - } - } - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return match gridcraft_functions::call(spec, &xargs, &mut ctx) { - Value::Number(p) if p >= 1.0 => { - let k = p as u32 - 1; - let range = if vertical { - RangeRef::new( - CellRef::new(r.range.start.row + k, r.range.start.col), - CellRef::new(r.range.start.row + k, r.range.end.col), - ) - } else { - RangeRef::new( - CellRef::new(r.range.start.row, r.range.start.col + k), - CellRef::new(r.range.end.row, r.range.start.col + k), - ) - }; - Ev::R(vec![Area { sheet: r.sheet, range }]) - } - Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), - Value::Error(e) => err(e), - _ => err(CellError::Value), - }; - } - } - self.builtin("XLOOKUP", args) - } - - fn cell_info(&mut self, args: &[Expr]) -> Ev { - let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t.to_ascii_lowercase(), - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let area = match args.get(1).map(|a| self.eval(a)) { - Some(Ev::R(a)) => a.first().copied(), - None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), - _ => return err(CellError::Value), - }; - let Some(a) = area else { return err(CellError::Ref) }; - let c = a.range.start; - let wb = self.host.workbook(); - let sheet = wb.sheet(a.sheet); - match kind.as_str() { - "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), - "row" => Ev::V(Value::number(c.row as f64 + 1.0)), - "col" => Ev::V(Value::number(c.col as f64 + 1.0)), - "contents" => Ev::V(self.host.cell_value(a.sheet, c)), - "type" => { - let v = self.host.cell_value(a.sheet, c); - Ev::V(Value::text(match v { - Value::Empty => "b", - Value::Text(_) => "l", - _ => "v", - })) - } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), - // Excel's `/[]`, which the classic sheet-name formula splits on - // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). - "filename" => Ev::V(Value::text(match &wb.source_path { - Some(file) => { - let name = file.file_name().unwrap_or_default().to_string_lossy(); - let on = sheet.map_or("", |s| s.name.as_str()); - file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() - } - None => String::new(), - })), - "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), - "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), - "format" => { - let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); - Ev::V(Value::text(cell_format_code(&code))) - } - "color" | "parentheses" => Ev::V(Value::Number(0.0)), - "prefix" => Ev::V(Value::text("")), - _ => err(CellError::Value), - } - } - - fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { - // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. - let min_args = if name == "AGGREGATE" { 3 } else { 2 }; - if args.len() < min_args { - return err(CellError::Value); - } - let code = match self.arg_num(args, 0, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - let (func, options, rest) = if name == "AGGREGATE" { - let opt = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - (code, opt, &args[2..]) - } else { - let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; - (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) - }; - if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { - return err(CellError::Value); - } - let skip_hidden = matches!(options, 1 | 3 | 5 | 7); - // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's - // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. - let skip_filtered = name == "SUBTOTAL"; - let skip_errors = matches!(options, 2 | 3 | 6 | 7); - // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. - let skip_nested = name == "SUBTOTAL" || options <= 3; - let mut values = Vec::new(); - let mut k = None; - let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; - if name == "AGGREGATE" && func >= 14 { - k = rest.get(1).map(|e| self.value(e)); - } - // Every cell visited (even one skipped as hidden, nested or an error) and every value - // counts against one budget for the whole call. All arguments are evaluated (references - // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes - // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). - enum Input { - Cells(usize, RangeRef), - Values(Value), - } - let mut inputs = Vec::new(); - let mut budget = MAX_CELLS; - for e in refs { - match self.eval(e) { - Ev::R(areas) => { - for a in areas { - let range = self.trim(&a); - budget = match budget.checked_sub(range.count()) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Cells(a.sheet, range)); - } - } - Ev::V(v) => { - let n = match &v { - Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), - _ => 1, - }; - budget = match budget.checked_sub(n) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Values(v)); - } - Ev::L(_) => return err(CellError::Value), - } - } - for input in inputs { - match input { - Input::Cells(sheet, range) => { - for c in range.iter() { - let (hidden, nested) = { - let wb = self.host.workbook(); - let s = wb.sheet(sheet); - let hidden = - s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); - let nested = skip_nested - && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { - let u = f.text.to_ascii_uppercase(); - u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") - }); - (hidden, nested) - }; - if hidden || nested { - continue; - } - let v = self.host.cell_value(sheet, c); - if skip_errors && v.is_error() { - continue; - } - values.push(v); - } - } - Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), - Input::Values(v) => values.push(v), - } - } - if let Some(k) = k { - return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); - } - Ev::V(gridcraft_functions::aggregate_values(func, &values)) - } } trait Pipe: Sized { @@ -1639,424 +636,6 @@ trait Pipe: Sized { } impl Pipe for T {} -/// CELL("format") code for a number format. -fn cell_format_code(code: &str) -> String { - let c = code.to_ascii_lowercase(); - if c == "general" { - return "G".into(); - } - if c.contains('%') { - return format!("P{}", decimals(&c)); - } - if c.contains('e') && c.contains('0') { - return format!("S{}", decimals(&c)); - } - if c.contains('$') { - return format!("C{}", decimals(&c)); - } - if c.contains('y') || c.contains('d') { - return "D1".into(); - } - if c.contains('h') { - return "D9".into(); - } - if c.contains(',') { - return format!(",{}", decimals(&c)); - } - format!("F{}", decimals(&c)) -} - -fn decimals(c: &str) -> usize { - c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) -} - -fn same_kind(a: &Value, b: &Value) -> bool { - std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) -} - -/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is -/// not R1C1; `Err` means it is R1C1 but points past the sheet. -pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { - let (sheet, body) = match s.rsplit_once('!') { - Some((sh, b)) => (format!("{sh}!"), b), - None => (String::new(), s), - }; - let conv = |part: &str| -> Result, CellError> { - let u = part.to_ascii_uppercase(); - let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; - // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. - let num = |p: &str, base: u32, max: u32| -> Option> { - let (v, absolute) = if p.is_empty() { - (Some(i64::from(base)), false) - } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { - (i64::from(base).checked_add(inner.parse().ok()?), false) - } else { - (p.parse::().ok()?.checked_sub(1).map(i64::from), true) - }; - Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) - }; - let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; - let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; - Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) - }; - let out = match body.split_once(':') { - Some((a, b)) => match (conv(a)?, conv(b)?) { - (Some(a), Some(b)) => format!("{a}:{b}"), - _ => return Ok(None), - }, - None => match conv(body)? { - Some(x) => x, - None => return Ok(None), - }, - }; - Ok(Some(format!("{sheet}{out}"))) -} - -/// The aggregates that can fold a reference without building an array. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Agg { - Sum, - Count, - Average, -} - -impl Agg { - fn of(name: &str) -> Option { - match name { - "SUM" => Some(Agg::Sum), - "COUNT" => Some(Agg::Count), - "AVERAGE" => Some(Agg::Average), - _ => None, - } - } -} - -/// What a folded aggregate carries from one value to the next. -#[derive(Default)] -struct Acc { - sum: f64, - count: u64, -} - -impl Acc { - /// Takes a value found inside a reference: only numbers count. `Err` is an error value that - /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. - fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { - match v { - Value::Number(n) => { - self.sum += n; - self.count = self.count.saturating_add(1); - Ok(()) - } - Value::Error(e) if kind != Agg::Count => Err(*e), - _ => Ok(()), - } - } - - fn finish(&self, kind: Agg) -> Value { - match kind { - Agg::Sum => Value::number(self.sum), - Agg::Count => Value::number(self.count as f64), - Agg::Average if self.count == 0 => Value::Error(CellError::Div0), - Agg::Average => Value::number(self.sum / self.count as f64), - } - } -} - -// ---------------------------------------------------------------- operators - -fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { - match v { - Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), - Value::Error(e) => Value::Error(*e), - Value::Text(_) if plus => v.clone(), - other => match other.to_number_in(sys) { - Ok(n) => f(n), - Err(e) => Value::Error(e), - }, - } -} - -/// Element-wise combination with Excel's broadcasting. -pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { - match (a, b) { - (Value::Array(x), Value::Array(y)) => { - let rows = broadcast_dim(x.rows, y.rows); - let cols = broadcast_dim(x.cols, y.cols); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); - } - } - Value::from(Array { rows, cols, data }) - } - (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), - (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), - (x, y) => f(x, y), - } -} - -/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. -/// Each operand passes its own size cap, but their broadcast product can be far larger -/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. -fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) -} - -fn broadcast_dim(a: usize, b: usize) -> usize { - if a == 1 { - b - } else if b == 1 { - a - } else { - a.max(b) - } -} - -fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { - let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; - let (tr, tc) = dims(t); - let (fr, fc) = dims(f); - let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); - let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for col in 0..cols { - data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); - } - } - Value::from(Array { rows, cols, data }) -} - -/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays -/// can't nest, so the whole helper gives `#CALC!`, as in Excel. -fn single(v: Value) -> Option { - match v { - Value::Array(a) if a.rows * a.cols > 1 => None, - v => Some(v.scalar()), - } -} - -pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { - zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) -} - -fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { - if let Value::Error(e) = a { - return Value::Error(*e); - } - if let Value::Error(e) = b { - return Value::Error(*e); - } - let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { - (Ok(x), Ok(y)) => f(x, y), - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - use std::cmp::Ordering::*; - match op { - BinOp::Add => num(|x, y| Value::number(x + y)), - BinOp::Sub => num(|x, y| Value::number(x - y)), - BinOp::Mul => num(|x, y| Value::number(x * y)), - BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), - BinOp::Pow => num(|x, y| { - if x == 0.0 && y == 0.0 { - Value::Error(CellError::Num) - } else if x == 0.0 && y < 0.0 { - Value::Error(CellError::Div0) - } else { - Value::number(x.powf(y)) - } - }), - BinOp::Concat => match (a.to_text(), b.to_text()) { - (Ok(x), Ok(y)) => { - if x.len() + y.len() > 32767 * 4 { - Value::Error(CellError::Value) - } else { - Value::text(x + &y) - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }, - BinOp::Eq => Value::Bool(compare(a, b) == Equal), - BinOp::Ne => Value::Bool(compare(a, b) != Equal), - BinOp::Lt => Value::Bool(compare(a, b) == Less), - BinOp::Gt => Value::Bool(compare(a, b) == Greater), - BinOp::Le => Value::Bool(compare(a, b) != Greater), - BinOp::Ge => Value::Bool(compare(a, b) != Less), - BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), - } -} - -/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. -fn resizes_sum_range(name: &str, args: usize) -> bool { - args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") -} - -/// Height and width of a single-area reference or an array argument. -fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { - match ev { - Ev::R(areas) => match areas.as_slice() { - [a] => Some((a.range.height(), a.range.width())), - _ => None, - }, - Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), - Ev::V(_) => Some((1, 1)), - Ev::L(_) => None, - } -} - -/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns -/// as the criteria range (`height` × `width`), cut off at the sheet's edge. -pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { - let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); - let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); - RangeRef::new(sum.start, CellRef::new(row, col)) -} - -/// Collects the static references of an expression with their sheets resolved; `dynamic` is -/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. -pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { - let mut out = Vec::new(); - let mut dynamic = false; - let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } - } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); - } - } - } - }; - e.walk(&mut |x| match x { - Expr::Ref(r) => push(&mut out, r, r.range()), - // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when - // the criteria range's size isn't known statically, recalculate the formula every time. - Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { - (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { - let (crit, sum) = (c.range(), s.range()); - push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); - } - (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} - _ => dynamic = true, - }, - Expr::Call(n, _) - if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => - { - dynamic = true - } - Expr::Name(_) | Expr::Struct(_) => dynamic = true, - Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, - // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between - // the references inside it, not only those references. - Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { - let mut inner = precedents(wb, sheet, a).0; - inner.extend(precedents(wb, sheet, b).0); - let mut boxes: Vec = Vec::new(); - for area in inner { - match boxes.iter_mut().find(|x| x.sheet == area.sheet) { - Some(x) => x.range = x.range.union(&area.range), - None => boxes.push(area), - } - } - out.extend(boxes); - } - _ => {} - }); - (out, dynamic) -} - -fn is_special(n: &str) -> bool { - matches!( - n, - "IF" | "IFS" - | "IFERROR" - | "IFNA" - | "CHOOSE" - | "SWITCH" - | "ROW" - | "COLUMN" - | "ROWS" - | "COLUMNS" - | "AREAS" - | "ISREF" - | "ISFORMULA" - | "FORMULATEXT" - | "SHEET" - | "SHEETS" - | "OFFSET" - | "INDIRECT" - | "INDEX" - | "CELL" - | "TRIMRANGE" - | "SUBTOTAL" - | "AGGREGATE" - | "TEXT" - | "LET" - | "LAMBDA" - | "ISOMITTED" - | "MAP" - | "REDUCE" - | "SCAN" - | "BYROW" - | "BYCOL" - | "MAKEARRAY" - ) -} - -/// Names of functions the evaluator implements itself (merged into the function list for UI). -pub const SPECIAL_FUNCTIONS: &[&str] = &[ - "IF", - "IFS", - "IFERROR", - "IFNA", - "CHOOSE", - "SWITCH", - "ROW", - "COLUMN", - "ROWS", - "COLUMNS", - "AREAS", - "ISREF", - "ISFORMULA", - "FORMULATEXT", - "SHEET", - "SHEETS", - "OFFSET", - "INDIRECT", - "INDEX", - "CELL", - "TRIMRANGE", - "SUBTOTAL", - "AGGREGATE", - "TEXT", - "LET", - "LAMBDA", - "ISOMITTED", - "MAP", - "REDUCE", - "SCAN", - "BYROW", - "BYCOL", - "MAKEARRAY", -]; - -pub fn is_known_function(n: &str) -> bool { - is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() -} - -#[allow(dead_code)] -fn unused(_: RefKind) {} - #[cfg(test)] mod fuel_tests { use super::*; diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs new file mode 100644 index 000000000..ab6ac1b06 --- /dev/null +++ b/crates/calc/src/eval/array.rs @@ -0,0 +1,180 @@ +use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare}; + +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + pub(super) fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +pub(super) struct Acc { + pub(super) sum: f64, + pub(super) count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + pub(super) fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + +// ---------------------------------------------------------------- operators + +pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { + match v { + Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), + Value::Error(e) => Value::Error(*e), + Value::Text(_) if plus => v.clone(), + other => match other.to_number_in(sys) { + Ok(n) => f(n), + Err(e) => Value::Error(e), + }, + } +} + +/// Element-wise combination with Excel's broadcasting. +pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { + match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), + (x, y) => f(x, y), + } +} + +/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. +/// Each operand passes its own size cap, but their broadcast product can be far larger +/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { + rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) +} + +fn broadcast_dim(a: usize, b: usize) -> usize { + if a == 1 { + b + } else if b == 1 { + a + } else { + a.max(b) + } +} + +pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { + let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; + let (tr, tc) = dims(t); + let (fr, fc) = dims(f); + let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); + let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for col in 0..cols { + data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); + } + } + Value::from(Array { rows, cols, data }) +} + +/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays +/// 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, + v => Some(v.scalar()), + } +} + +pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) +} + +fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { + if let Value::Error(e) = a { + return Value::Error(*e); + } + if let Value::Error(e) = b { + return Value::Error(*e); + } + let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { + (Ok(x), Ok(y)) => f(x, y), + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + use std::cmp::Ordering::*; + match op { + BinOp::Add => num(|x, y| Value::number(x + y)), + BinOp::Sub => num(|x, y| Value::number(x - y)), + BinOp::Mul => num(|x, y| Value::number(x * y)), + BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), + BinOp::Pow => num(|x, y| { + if x == 0.0 && y == 0.0 { + Value::Error(CellError::Num) + } else if x == 0.0 && y < 0.0 { + Value::Error(CellError::Div0) + } else { + Value::number(x.powf(y)) + } + }), + BinOp::Concat => match (a.to_text(), b.to_text()) { + (Ok(x), Ok(y)) => { + if x.len() + y.len() > 32767 * 4 { + Value::Error(CellError::Value) + } else { + Value::text(x + &y) + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }, + BinOp::Eq => Value::Bool(compare(a, b) == Equal), + BinOp::Ne => Value::Bool(compare(a, b) != Equal), + BinOp::Lt => Value::Bool(compare(a, b) == Less), + BinOp::Gt => Value::Bool(compare(a, b) == Greater), + BinOp::Le => Value::Bool(compare(a, b) != Greater), + BinOp::Ge => Value::Bool(compare(a, b) != Less), + BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), + } +} diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs new file mode 100644 index 000000000..08d563d43 --- /dev/null +++ b/crates/calc/src/eval/call.rs @@ -0,0 +1,435 @@ +use super::array::{Acc, Agg, zip2, zip3}; +use super::format::same_kind; +use super::special::{ev_shape, resized_sum_range, resizes_sum_range}; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err}; +use std::sync::Arc; + +impl<'h> Evaluator<'h> { + pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev { + match name { + "IF" => self.if_call(args), + "IFS" => { + if args.len() < 2 || !args.len().is_multiple_of(2) { + return err(CellError::Value); + } + for pair in args.chunks(2) { + let [c, v] = pair else { break }; + let cv = self.value(c); + match cv.scalar().to_bool() { + Ok(true) => return self.eval(v), + Ok(false) => {} + Err(e) => return err(e), + } + } + err(CellError::NA) + } + "IFERROR" | "IFNA" => self.if_error_call(name, args), + "CHOOSE" => self.choose_call(args), + "SWITCH" => self.switch_call(args), + "ROW" | "COLUMN" => self.row_column_call(name, args), + "ROWS" | "COLUMNS" => { + if args.len() != 1 { + return err(CellError::Value); + } + let is_rows = name == "ROWS"; + match self.eval(&args[0]) { + Ev::R(a) => match a.as_slice() { + [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), + _ => err(CellError::Ref), + }, + Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), + Ev::V(Value::Error(e)) => err(e), + Ev::V(_) => Ev::V(Value::Number(1.0)), + Ev::L(_) => err(CellError::Value), + } + } + "AREAS" => match args.first().map(|a| self.eval(a)) { + Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), + Some(Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + }, + "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), + "ISFORMULA" | "FORMULATEXT" => { + let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; + let Some(a) = a.first() else { return err(CellError::NA) }; + let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); + if name == "ISFORMULA" { + Ev::V(Value::Bool(f.is_some())) + } else { + match f { + Some(f) => Ev::V(Value::text(format!("={}", f.text))), + None => err(CellError::NA), + } + } + } + "SHEET" | "SHEETS" => self.sheet_call(name, args), + "OFFSET" => self.offset(args), + "INDIRECT" => self.indirect(args), + "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), + "CELL" => self.cell_info(args), + "TRIMRANGE" => self.trimrange(args), + "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), + "TEXT" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default(); + let f = self.arg_val(args, 1).unwrap_or_default(); + let sys = self.host.workbook().date_system; + let one = |v: &Value, f: &Value| -> Value { + if let Value::Error(e) = v { + return Value::Error(*e); + } + let code = match f.to_text() { + Ok(c) => c, + Err(e) => return Value::Error(e), + }; + match gridcraft_numfmt::text_function(v, &code, sys) { + Ok(s) => Value::text(s), + Err(e) => Value::Error(e), + } + }; + Ev::V(zip2(&v, &f, one)) + } + "LET" => self.let_(args), + "LAMBDA" => { + if args.is_empty() { + return err(CellError::Value); + } + let mut params = Vec::new(); + for p in &args[..args.len() - 1] { + match p { + Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), + _ => return err(CellError::Value), + } + } + let Some(body) = args.last() else { return err(CellError::Value) }; + Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) + } + "ISOMITTED" => { + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + Ev::V(Value::Bool(r)) + } + "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } + "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { + self.host.note_volatile(); + self.builtin(name, args) + } + _ => self.builtin(name, args), + } + } + + fn if_call(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let condition = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(array) = &condition { + let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() { + Ok(true) => when_true.clone(), + Ok(false) => when_false.clone(), + Err(error) => Value::Error(error), + })); + } + match condition.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(false)), + }, + Err(error) => err(error), + } + } + + fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev { + if args.len() != 2 { + return err(CellError::Value); + } + let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catches = |error: CellError| !na_only || error == CellError::NA; + match &value { + Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]), + Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => self.if_error_reference(value.clone(), args, catches), + _ => value, + } + } + + fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev { + match self.deref(value.clone()) { + Value::Error(error) if catches(error) => self.eval(&args[1]), + Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => value, + } + } + + fn choose_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 2 { + return err(CellError::Value); + } + let index = self.value(&args[0]); + if let Value::Array(_) = index { + let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for argument in &args[1..] { + evaluated.push(Arg { value: self.value(argument), from_ref: false }); + } + let mut context = FnCtx { ev: self, criteria: None, lambda: None }; + return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V); + } + let index = match index.to_number() { + Ok(number) => number.trunc() as i64, + Err(error) => return err(error), + }; + if index < 1 || index as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[index as usize]) + } + + fn switch_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 { + return err(CellError::Value); + } + let value = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(error) = value { + return err(error); + } + let choices = &args[1..]; + for pair in choices.as_chunks::<2>().0 { + let (candidate, result) = (&pair[0], &pair[1]); + let candidate = self.value(candidate).scalar(); + if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) { + return self.eval(result); + } + } + if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) { + self.eval(default) + } else { + err(CellError::NA) + } + } + + fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev { + let is_row = name == "ROW"; + if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { + return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); + } + let areas = match self.eval(&args[0]) { + Ev::R(areas) => areas, + Ev::V(Value::Error(error)) => return err(error), + _ => return err(CellError::Value), + }; + let Some(area) = areas.first() else { return err(CellError::Ref) }; + let range = self.trim_lines(area); + if is_row { + let values: Vec = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) } + } else { + let values: Vec = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) } + } + } + + fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev { + let is_sheet = name == "SHEET"; + if args.is_empty() { + let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() }; + return Ev::V(Value::number(count as f64)); + } + match self.eval(&args[0]) { + Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))), + Ev::R(areas) => { + let mut sheets: Vec = areas.iter().map(|area| area.sheet).collect(); + sheets.dedup(); + Ev::V(Value::number(sheets.len() as f64)) + } + Ev::V(Value::Text(name)) if is_sheet => { + self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { + let Some(spec) = gridcraft_functions::lookup(name) else { + // A defined name holding a LAMBDA, or a LET-bound lambda. + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; + }; + if args.len() < spec.min_args || args.len() > spec.max_args { + return err(CellError::Value); + } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } + let mut evaluated = Vec::with_capacity(args.len()); + // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. + let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; + // GROUPBY/PIVOTBY: the LAMBDA given as their function. + let mut lambda = None; + let lambda_at = match name { + "GROUPBY" => Some(2), + "PIVOTBY" => Some(3), + _ => None, + }; + for (i, a) in args.iter().enumerate() { + let mut ev = self.eval(a); + if lambda_at == Some(i) + && let Ev::L(l) = &ev + { + lambda = Some(l.clone()); + ev = Ev::V(Value::Empty); + } + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } + if resizes_sum_range(name, args.len()) { + if i == 0 { + criteria_shape = ev_shape(&ev); + } else if i == 2 + && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) + && let [area] = areas.as_slice() + { + ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); + } + } + let from_ref = matches!(ev, Ev::R(_)); + let value = match ev { + Ev::R(areas) if areas.len() > 1 => { + // Multi-area references (unions): flatten into one row for aggregates. + let mut vals = Vec::new(); + for ar in &areas { + match self.area_value(ar) { + Value::Array(a) => vals.extend(a.data.iter().cloned()), + v => vals.push(v), + } + } + Value::from(Array::row(vals)) + } + ev => self.deref(ev), + }; + evaluated.push(Arg { value, from_ref }); + } + let mut ctx = FnCtx { ev: self, criteria, lambda }; + gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) + } + + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.sum += n, + other => return Err(Ev::V(other)), + } + } + if kind != Agg::Sum { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + pub(super) fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } +} diff --git a/crates/calc/src/eval/format.rs b/crates/calc/src/eval/format.rs new file mode 100644 index 000000000..a288b410e --- /dev/null +++ b/crates/calc/src/eval/format.rs @@ -0,0 +1,75 @@ +use super::{CellError, Value}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; + +/// CELL("format") code for a number format. +pub(super) fn cell_format_code(code: &str) -> String { + let c = code.to_ascii_lowercase(); + if c == "general" { + return "G".into(); + } + if c.contains('%') { + return format!("P{}", decimals(&c)); + } + if c.contains('e') && c.contains('0') { + return format!("S{}", decimals(&c)); + } + if c.contains('$') { + return format!("C{}", decimals(&c)); + } + if c.contains('y') || c.contains('d') { + return "D1".into(); + } + if c.contains('h') { + return "D9".into(); + } + if c.contains(',') { + return format!(",{}", decimals(&c)); + } + format!("F{}", decimals(&c)) +} + +fn decimals(c: &str) -> usize { + c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) +} + +pub(super) fn same_kind(a: &Value, b: &Value) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) +} + +/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is +/// not R1C1; `Err` means it is R1C1 but points past the sheet. +pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { + let (sheet, body) = match s.rsplit_once('!') { + Some((sh, b)) => (format!("{sh}!"), b), + None => (String::new(), s), + }; + let conv = |part: &str| -> Result, CellError> { + let u = part.to_ascii_uppercase(); + let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; + // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. + let num = |p: &str, base: u32, max: u32| -> Option> { + let (v, absolute) = if p.is_empty() { + (Some(i64::from(base)), false) + } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + (i64::from(base).checked_add(inner.parse().ok()?), false) + } else { + (p.parse::().ok()?.checked_sub(1).map(i64::from), true) + }; + Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) + }; + let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; + let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; + Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) + }; + let out = match body.split_once(':') { + Some((a, b)) => match (conv(a)?, conv(b)?) { + (Some(a), Some(b)) => format!("{a}:{b}"), + _ => return Ok(None), + }, + None => match conv(body)? { + Some(x) => x, + None => return Ok(None), + }, + }; + Ok(Some(format!("{sheet}{out}"))) +} diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs new file mode 100644 index 000000000..dfb30029a --- /dev/null +++ b/crates/calc/src/eval/lambda.rs @@ -0,0 +1,200 @@ +use super::MAX_LAMBDA_CALLS; +use super::array::{broadcast_len, single}; +use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err}; +use std::sync::Arc; + +impl<'h> Evaluator<'h> { + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { + // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in + // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` + // parses to no arguments, which for a one-parameter lambda can also be read as one empty + // position, so that call still binds the parameter as omitted. + let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); + if !positions_ok { + return err(CellError::Value); + } + let mut bound = Vec::with_capacity(l.params.len()); + for (i, p) in l.params.iter().enumerate() { + let v = match args.get(i) { + Some(Expr::Missing) | None => Ev::V(Value::Empty), + Some(e) => self.eval(e), + }; + bound.push((p.clone(), v)); + } + self.call_lambda_values(l, bound) + } + + pub(super) fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } + // #2382: one charge per invocation, so a cell's total work stays bounded however the + // lambdas nest. Foregrounding the charge here means every path into a body (direct + // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. + self.fuel = match self.fuel.checked_sub(1) { + Some(f) => f, + None => return err(CellError::Calc), + }; + self.calls += 1; + let saved = std::mem::replace(&mut self.env, l.env.clone()); + self.env.extend(bound); + let r = self.eval(&l.body); + self.env = saved; + self.calls -= 1; + r + } + + pub(super) fn let_(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 || args.len().is_multiple_of(2) { + return err(CellError::Value); + } + let base = self.env.len(); + for pair in args[..args.len() - 1].chunks(2) { + let [Expr::Name(n), v] = pair else { + self.env.truncate(base); + return err(CellError::Value); + }; + let val = self.eval(v); + self.env.push((n.clone(), val)); + } + let r = match args.last() { + Some(body) => self.eval(body), + None => err(CellError::Value), + }; + self.env.truncate(base); + r + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } + match e.map(|x| self.eval(x)) { + Some(Ev::L(l)) => Some(l), + _ => None, + } + } + + pub(super) fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { + let as_array = |v: Value| -> Arc { + match v { + Value::Array(a) => a, + other => Arc::new(Array::scalar(other)), + } + }; + match name { + "MAP" => { + if args.len() < 2 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; + let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); + let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); + let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); + let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; + // The LAMBDA takes one parameter per array. + if l.params.len() != arrays.len() { + return err(CellError::Value); + } + let mut out = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + "REDUCE" | "SCAN" => { + if args.len() != 3 { + return err(CellError::Value); + } + let mut acc = self.value(&args[0]); + let arr = as_array(self.value(&args[1])); + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let mut scan = Vec::with_capacity(arr.data.len()); + for v in arr.data.iter() { + let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; + let r = self.call_lambda_values(&l, bound); + acc = self.deref(r); + if name == "SCAN" { + let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; + scan.push(one); + } + } + if name == "SCAN" { + Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } else { + Ev::V(acc) + } + } + "BYROW" | "BYCOL" => { + if args.len() != 2 { + return err(CellError::Value); + } + let arr = as_array(self.value(&args[0])); + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; + let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + if l.params.len() != 1 { + return err(CellError::Value); + } + let by_row = name == "BYROW"; + let n = if by_row { arr.rows } else { arr.cols }; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let slice: Vec = if by_row { + (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() + } else { + (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() + }; + let a = if by_row { Array::row(slice) } else { Array::column(slice) }; + let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); + let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; + out.push(one); + } + let a = if by_row { Array::column(out) } else { Array::row(out) }; + Ev::V(a.into()) + } + "MAKEARRAY" => { + if args.len() != 3 { + return err(CellError::Value); + } + let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { + (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let (rows, cols) = (rows as usize, cols as usize); + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let bound = vec![ + (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), + (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), + ]; + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + _ => err(CellError::Name), + } + } +} diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs new file mode 100644 index 000000000..4f5154c2d --- /dev/null +++ b/crates/calc/src/eval/reference_fns.rs @@ -0,0 +1,409 @@ +use super::format::{cell_format_code, r1c1_to_a1}; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use std::sync::Arc; + +impl<'h> Evaluator<'h> { + /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. + pub(super) fn trim_lines(&mut self, a: &Area) -> RangeRef { + if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } + } + + pub(super) fn offset(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.len() < 3 || args.len() > 5 { + return err(CellError::Value); + } + let base = match self.eval(&args[0]) { + Ev::R(a) if a.len() == 1 => a[0], + Ev::V(Value::Error(e)) => return err(e), + _ => return err(CellError::Value), + }; + let rows = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let cols = match self.arg_num(args, 2, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let h = match self.arg_num(args, 3, base.range.height() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let w = match self.arg_num(args, 4, base.range.width() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if h == 0 || w == 0 { + return err(CellError::Ref); + } + let r0 = (base.range.start.row as i64).saturating_add(rows); + let c0 = (base.range.start.col as i64).saturating_add(cols); + let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; + let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; + if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { + return err(CellError::Ref); + } + Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) + } + + pub(super) fn indirect(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.is_empty() || args.len() > 2 { + return err(CellError::Value); + } + let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t, + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let a1 = match args.get(1) { + None | Some(Expr::Missing) => true, + Some(e) => { + let v = self.value(e); + v.scalar().to_bool().unwrap_or(true) + } + }; + // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. + let (text, a1_ok) = if a1 { + (text, true) + } else { + match r1c1_to_a1(&text, self.at) { + Ok(Some(a1_text)) => (a1_text, true), + Ok(None) => (text, false), + Err(e) => return err(e), + } + }; + match gridcraft_formula::parse(&text) { + Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), + Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { + r @ Ev::R(_) => r, + _ => err(CellError::Ref), + }, + _ => err(CellError::Ref), + } + } + + pub(super) fn index(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 4 { + return err(CellError::Value); + } + let src = self.eval(&args[0]); + let mut arg = |i: usize| match args.get(i) { + None | Some(Expr::Missing) => None, + Some(e) => Some(self.value(e)), + }; + let (rows, cols) = (arg(1), arg(2)); + let area_n = match self.arg_num(args, 3, 1.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let two = args.len() == 2; + let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); + // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. + if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { + let as_array = |v: &Option| match v { + Some(Value::Array(a)) => Arc::clone(a), + Some(v) => Arc::new(Array::scalar(v.clone())), + None => Arc::new(Array::scalar(Value::Number(0.0))), + }; + let (ra, ca) = (as_array(&rows), as_array(&cols)); + let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); + if (h as u64).saturating_mul(w as u64) > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); + let v = match pos { + (Ok(row), Ok(col)) => { + let one = self.index_one(src.clone(), row, col, area_n, two); + match self.deref(one) { + Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), + v => v.scalar(), + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + out.push(v); + } + } + return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); + } + let (row, col) = match (num(&rows), num(&cols)) { + (Ok(r), Ok(c)) => (r, c), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + self.index_one(src, row, col, area_n, two) + } + + /// INDEX for one row and column position (0 = the whole column or row). + fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { + if row < 0 || col < 0 { + return err(CellError::Value); + } + match src { + Ev::R(areas) => { + let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); + let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; + let r = a.range; + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } + let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; + if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { + return err(CellError::Ref); + } + let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; + let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; + Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) + } + Ev::V(Value::Array(arr)) => { + let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; + if row as usize > arr.rows || col as usize > arr.cols { + return err(CellError::Ref); + } + match (row, col) { + (0, 0) => Ev::V(Value::Array(arr)), + (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), + (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), + (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), + } + } + Ev::V(Value::Error(e)) => err(e), + Ev::V(v) => { + if row <= 1 && col <= 1 { + Ev::V(v) + } else { + err(CellError::Ref) + } + } + Ev::L(_) => err(CellError::Value), + } + } + + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + pub(super) fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + let horizontal = l.range.height() == 1 && l.range.width() > 1; + let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; + if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { + let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + // An empty match_mode before a given search_mode is the default (0), not the end. + let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); + let last = if given(5) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { + match args.get(i) { + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + + pub(super) fn cell_info(&mut self, args: &[Expr]) -> Ev { + let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t.to_ascii_lowercase(), + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let area = match args.get(1).map(|a| self.eval(a)) { + Some(Ev::R(a)) => a.first().copied(), + None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), + _ => return err(CellError::Value), + }; + let Some(a) = area else { return err(CellError::Ref) }; + let c = a.range.start; + let wb = self.host.workbook(); + let sheet = wb.sheet(a.sheet); + match kind.as_str() { + "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), + "row" => Ev::V(Value::number(c.row as f64 + 1.0)), + "col" => Ev::V(Value::number(c.col as f64 + 1.0)), + "contents" => Ev::V(self.host.cell_value(a.sheet, c)), + "type" => { + let v = self.host.cell_value(a.sheet, c); + Ev::V(Value::text(match v { + Value::Empty => "b", + Value::Text(_) => "l", + _ => "v", + })) + } + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), + // Excel's `/[]`, which the classic sheet-name formula splits on + // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). + "filename" => Ev::V(Value::text(match &wb.source_path { + Some(file) => { + let name = file.file_name().unwrap_or_default().to_string_lossy(); + let on = sheet.map_or("", |s| s.name.as_str()); + file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() + } + None => String::new(), + })), + "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), + "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), + "format" => { + let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); + Ev::V(Value::text(cell_format_code(&code))) + } + "color" | "parentheses" => Ev::V(Value::Number(0.0)), + "prefix" => Ev::V(Value::text("")), + _ => err(CellError::Value), + } + } + + pub(super) fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { + // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. + let min_args = if name == "AGGREGATE" { 3 } else { 2 }; + if args.len() < min_args { + return err(CellError::Value); + } + let code = match self.arg_num(args, 0, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + let (func, options, rest) = if name == "AGGREGATE" { + let opt = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + (code, opt, &args[2..]) + } else { + let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; + (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) + }; + if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { + return err(CellError::Value); + } + let skip_hidden = matches!(options, 1 | 3 | 5 | 7); + // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's + // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. + let skip_filtered = name == "SUBTOTAL"; + let skip_errors = matches!(options, 2 | 3 | 6 | 7); + // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. + let skip_nested = name == "SUBTOTAL" || options <= 3; + let mut values = Vec::new(); + let mut k = None; + let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; + if name == "AGGREGATE" && func >= 14 { + k = rest.get(1).map(|e| self.value(e)); + } + // Every cell visited (even one skipped as hidden, nested or an error) and every value + // counts against one budget for the whole call. All arguments are evaluated (references + // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes + // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). + enum Input { + Cells(usize, RangeRef), + Values(Value), + } + let mut inputs = Vec::new(); + let mut budget = MAX_CELLS; + for e in refs { + match self.eval(e) { + Ev::R(areas) => { + for a in areas { + let range = self.trim(&a); + budget = match budget.checked_sub(range.count()) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Cells(a.sheet, range)); + } + } + Ev::V(v) => { + let n = match &v { + Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), + _ => 1, + }; + budget = match budget.checked_sub(n) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Values(v)); + } + Ev::L(_) => return err(CellError::Value), + } + } + for input in inputs { + match input { + Input::Cells(sheet, range) => { + for c in range.iter() { + let (hidden, nested) = { + let wb = self.host.workbook(); + let s = wb.sheet(sheet); + let hidden = + s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); + let nested = skip_nested + && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { + let u = f.text.to_ascii_uppercase(); + u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") + }); + (hidden, nested) + }; + if hidden || nested { + continue; + } + let v = self.host.cell_value(sheet, c); + if skip_errors && v.is_error() { + continue; + } + values.push(v); + } + } + Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), + Input::Values(v) => values.push(v), + } + } + if let Some(k) = k { + return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); + } + Ev::V(gridcraft_functions::aggregate_values(func, &values)) + } +} diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs new file mode 100644 index 000000000..0151ea2f9 --- /dev/null +++ b/crates/calc/src/eval/special.rs @@ -0,0 +1,165 @@ +use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; + +/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. +pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool { + args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") +} + +/// Height and width of a single-area reference or an array argument. +pub(super) fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { + match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => Some((a.range.height(), a.range.width())), + _ => None, + }, + Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), + Ev::V(_) => Some((1, 1)), + Ev::L(_) => None, + } +} + +/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns +/// as the criteria range (`height` × `width`), cut off at the sheet's edge. +pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { + let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); + let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); + RangeRef::new(sum.start, CellRef::new(row, col)) +} + +/// Collects the static references of an expression with their sheets resolved; `dynamic` is +/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. +pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { + let mut out = Vec::new(); + let mut dynamic = false; + let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); + } + } + } + }; + e.walk(&mut |x| match x { + Expr::Ref(r) => push(&mut out, r, r.range()), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => dynamic = true, + }, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + dynamic = true + } + Expr::Name(_) | Expr::Struct(_) => dynamic = true, + Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, + // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between + // the references inside it, not only those references. + Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { + let mut inner = precedents(wb, sheet, a).0; + inner.extend(precedents(wb, sheet, b).0); + let mut boxes: Vec = Vec::new(); + for area in inner { + match boxes.iter_mut().find(|x| x.sheet == area.sheet) { + Some(x) => x.range = x.range.union(&area.range), + None => boxes.push(area), + } + } + out.extend(boxes); + } + _ => {} + }); + (out, dynamic) +} + +fn is_special(n: &str) -> bool { + matches!( + n, + "IF" | "IFS" + | "IFERROR" + | "IFNA" + | "CHOOSE" + | "SWITCH" + | "ROW" + | "COLUMN" + | "ROWS" + | "COLUMNS" + | "AREAS" + | "ISREF" + | "ISFORMULA" + | "FORMULATEXT" + | "SHEET" + | "SHEETS" + | "OFFSET" + | "INDIRECT" + | "INDEX" + | "CELL" + | "TRIMRANGE" + | "SUBTOTAL" + | "AGGREGATE" + | "TEXT" + | "LET" + | "LAMBDA" + | "ISOMITTED" + | "MAP" + | "REDUCE" + | "SCAN" + | "BYROW" + | "BYCOL" + | "MAKEARRAY" + ) +} + +/// Names of functions the evaluator implements itself (merged into the function list for UI). +pub const SPECIAL_FUNCTIONS: &[&str] = &[ + "IF", + "IFS", + "IFERROR", + "IFNA", + "CHOOSE", + "SWITCH", + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "AREAS", + "ISREF", + "ISFORMULA", + "FORMULATEXT", + "SHEET", + "SHEETS", + "OFFSET", + "INDIRECT", + "INDEX", + "CELL", + "TRIMRANGE", + "SUBTOTAL", + "AGGREGATE", + "TEXT", + "LET", + "LAMBDA", + "ISOMITTED", + "MAP", + "REDUCE", + "SCAN", + "BYROW", + "BYCOL", + "MAKEARRAY", +]; + +pub fn is_known_function(n: &str) -> bool { + is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() +} diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index e1b1e89cf..1ee6df243 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -10,362 +10,22 @@ //! and the formulas that read one are evaluated again until the loop settles //! (`PassHost::iterate_loops`). -use std::collections::{BTreeSet, VecDeque}; -use std::hash::{BuildHasherDefault, Hasher}; - -/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). -#[derive(Default, Clone, Copy)] -pub struct FxHasher(u64); - -impl Hasher for FxHasher { - fn finish(&self) -> u64 { - self.0 - } - fn write(&mut self, bytes: &[u8]) { - for b in bytes { - self.write_u64(*b as u64); - } - } - fn write_u64(&mut self, i: u64) { - self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); - } - fn write_u32(&mut self, i: u32) { - self.write_u64(i as u64); - } - fn write_usize(&mut self, i: usize) { - self.write_u64(i as u64); - } -} - -type HashMap = std::collections::HashMap>; -type HashSet = std::collections::HashSet>; +use std::collections::VecDeque; use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; +use crate::eval::{Evaluator, Host, precedents, stack_position}; -pub type Key = (usize, CellRef); +mod graph; +mod pass_host; -#[derive(Clone, Debug, Default)] -struct Node { - areas: Vec, - dynamic: bool, -} - -/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. -const WIDE: u32 = 64; - -/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. -const PENDING: usize = 32; - -/// Formulas a formula may calculate on demand one inside the other (each needing one another's -/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and -/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate -/// whatever order they come in, with bounded stack. -const MAX_NESTED: usize = 64; - -/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas -/// evaluated again), however high Maximum Iterations is. -const MAX_ITERATION_WORK: usize = 10_000_000; - -/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows -/// `a..=b`?" in O(log n + k). -/// -/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query -/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one -/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops -/// dead entries once either grows, so mutations cost O(log n) amortised. -#[derive(Clone, Debug)] -struct Intervals { - /// Sorted, without duplicates. - items: Vec<(u32, u32, T)>, - dead: Vec, - dead_count: usize, - /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at - /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). - max_end: Vec, - /// Inserted since the last merge. - pending: BTreeSet<(u32, u32, T)>, -} - -impl Default for Intervals { - fn default() -> Self { - Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } - } -} - -impl Intervals { - fn is_empty(&self) -> bool { - self.items.len() == self.dead_count && self.pending.is_empty() - } - - fn insert(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && *d - { - *d = false; - self.dead_count -= 1; - } - return; - } - self.pending.insert(it); - } - - fn remove(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && !*d - { - *d = true; - self.dead_count += 1; - } - return; - } - self.pending.remove(&it); - } - - /// Merges pending insertions and drops dead entries when there are enough of them. - fn settle(&mut self) { - if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { - return; - } - let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); - all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); - all.extend(std::mem::take(&mut self.pending)); - // Two sorted runs: the stable sort merges them in linear time. - all.sort(); - all.dedup(); - let size = all.len().next_power_of_two(); - let mut max_end = vec![0; 2 * size]; - if let Some(leaves) = max_end.get_mut(size..) { - for (slot, it) in leaves.iter_mut().zip(&all) { - *slot = it.1; - } - } - for i in (1..size).rev() { - let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); - if let Some(slot) = max_end.get_mut(i) { - *slot = m; - } - } - self.dead = vec![false; all.len()]; - self.dead_count = 0; - self.items = all; - self.max_end = max_end; - } - - /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the - /// number of tree nodes visited. - fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { - for it in &self.pending { - if it.0 <= b && it.1 >= a { - f(&it.2); - } - } - // Entries before `p` start at or before row `b`. - let p = self.items.partition_point(|it| it.0 <= b); - if p == 0 { - return 0; - } - self.walk(1, 0, self.max_end.len() / 2, p, a, f) - } - - fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { - if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { - return 1; - } - if hi - lo <= 1 { - if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) - && it.1 >= a - { - f(&it.2); - } - return 1; - } - let mid = lo + (hi - lo) / 2; - 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) - } -} - -#[derive(Clone, Debug, Default)] -pub struct Graph { - nodes: HashMap, - /// single cell → dependents - cell_deps: HashMap>, - /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column - col_deps: HashMap<(usize, u32), Intervals>, - /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) - wide_deps: HashMap>, - dynamic: HashSet, - /// Range indexes changed since the last [`Graph::settle`]. - unsettled_cols: HashSet<(usize, u32)>, - unsettled_wide: HashSet, -} - -impl Graph { - pub fn len(&self) -> usize { - self.nodes.len() - } - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - /// Index entries of a formula's precedents, without duplicates: single cells, column - /// intervals and wide ranges. - fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { - let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); - for a in areas { - if a.range.is_single() { - cells.push((a.sheet, a.range.start)); - } else if a.range.width() > WIDE { - wide.push((a.sheet, a.range)); - } else { - for c in a.range.start.col..=a.range.end.col { - cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); - } - } - } - cells.sort_unstable(); - cells.dedup(); - cols.sort_unstable(); - cols.dedup(); - wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); - wide.dedup(); - (cells, cols, wide) - } - - fn insert(&mut self, k: Key, node: Node) { - self.remove(k); - let (cells, cols, wide) = Self::entries(&node.areas); - for c in cells { - self.cell_deps.entry(c).or_default().insert(k); - } - for (s, c, r0, r1) in cols { - self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - for (s, r) in wide { - self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - if node.dynamic { - self.dynamic.insert(k); - } - self.nodes.insert(k, node); - } - - fn remove(&mut self, k: Key) { - let Some(old) = self.nodes.remove(&k) else { return }; - let (cells, cols, wide) = Self::entries(&old.areas); - for c in cells { - if let Some(v) = self.cell_deps.get_mut(&c) { - v.remove(&k); - if v.is_empty() { - self.cell_deps.remove(&c); - } - } - } - for (s, c, r0, r1) in cols { - if let Some(v) = self.col_deps.get_mut(&(s, c)) { - v.remove(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - } - for (s, r) in wide { - if let Some(v) = self.wide_deps.get_mut(&s) { - v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - } - self.dynamic.remove(&k); - } - - /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, - /// just slower). - fn settle(&mut self) { - for key in std::mem::take(&mut self.unsettled_cols) { - if let Some(v) = self.col_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.col_deps.remove(&key); - } - } - } - for key in std::mem::take(&mut self.unsettled_wide) { - if let Some(v) = self.wide_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.wide_deps.remove(&key); - } - } - } - } - - /// Formulas that read cell `c` of `sheet`. - pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - if let Some(v) = self.col_deps.get(&(sheet, c.col)) { - v.query(c.row, c.row, &mut |k| out.push(*k)); - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(c.row, c.row, &mut |(c0, c1, k)| { - if *c0 <= c.col && c.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Formulas that read any cell of `range` (a formula can be listed more than once). - pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { - if range.count() <= 4096 { - for c in range.iter() { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - } - } else { - for ((s, c), v) in &self.cell_deps { - if *s == sheet && range.contains(*c) { - out.extend(v.iter().copied()); - } - } - } - let (r0, r1) = (range.start.row, range.end.row); - if (range.width() as usize) <= self.col_deps.len() { - for col in range.start.col..=range.end.col { - if let Some(v) = self.col_deps.get(&(sheet, col)) { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } else { - for ((s, col), v) in &self.col_deps { - if *s == sheet && range.start.col <= *col && *col <= range.end.col { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(r0, r1, &mut |(c0, c1, k)| { - if *c0 <= range.end.col && range.start.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Precedent areas of a formula cell (for Trace Precedents). - pub fn precedents_of(&self, k: Key) -> Vec { - self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() - } -} +#[cfg(test)] +use graph::Intervals; +pub use graph::{FxHasher, Graph, Key}; +use graph::{HashMap, HashSet, Node}; +use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. #[derive(Clone, Debug)] @@ -385,595 +45,68 @@ impl Default for Calc { } } -struct PassHost<'a> { - wb: &'a Workbook, - exprs: &'a HashMap>, - results: HashMap, - pending: HashSet, - in_progress: HashSet, - /// New spills found during the pass: anchor → array. - spills: HashMap>, - /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks - /// every row, so once per sheet rather than once per range read. - used: HashMap>, - rng: &'a mut u64, - now: f64, - cycle: bool, - cycles: Vec, - depth: usize, - /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned - /// and it is calculated first. - suspend: Option, - /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. - /// They all need it, so reading one of them while it is calculated is a circular reference, - /// as reading a formula in progress is. - waiting: HashSet, - /// The formulas abandoned since the last suspension. - abandoned: Vec, - /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They - /// are calculated along with the suspended one, rather than one per restart (a wide range - /// read at the limit would restart the abandoned evaluations once per cell in it). - also: Vec, - /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). - #[cfg(test)] - suspensions: usize, - /// The formulas in `cycles`. - cycle_seen: HashSet, - /// Iterative calculation: a circular reference reads the cell's previous value instead of - /// giving `#CIRC!`. - iterate: bool, - /// Values from the previous iteration; cells not in it read their stored value. - prev: HashMap, - /// Formulas being evaluated, innermost last (kept only when iterating). - stack: Vec, - /// Formulas found on a circular reference (when iterating). - members: HashSet, - /// Results that read a previous value, directly or through other formulas. - tainted: HashSet, - /// Whether the formula being evaluated has read a previous value. - taint: bool, - /// `stack_position()` where the outermost formula being calculated started. - stack_top: usize, - /// Whether an evaluation ran past the stack budget since the innermost formula started. - out_of_stack: bool, - /// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its - /// result is laid out (an array spills from the cell, as a formula's does). Each one is in - /// `pending` until it is laid out, so a formula that reads it first gets its new value. - python: HashMap, -} - -impl<'a> PassHost<'a> { - fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { - PassHost { - wb, - exprs, - results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), - pending: exprs.keys().copied().collect(), - in_progress: HashSet::default(), - spills: HashMap::default(), - used: HashMap::default(), - rng, - now, - cycle: false, - cycles: vec![], - depth: 0, - suspend: None, - waiting: HashSet::default(), - abandoned: Vec::new(), - also: Vec::new(), - #[cfg(test)] - suspensions: 0, - cycle_seen: HashSet::default(), - iterate: false, - prev: HashMap::default(), - stack: Vec::new(), - members: HashSet::default(), - tainted: HashSet::default(), - taint: false, - stack_top: 0, - out_of_stack: false, - python: HashMap::default(), +/// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The +/// component is everything up to and including `root`. +fn pop_component(stack: &mut Vec, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) { + while let Some(w) = stack.pop() { + on_stack[w] = false; + comp[w] = comp_index; + if w == root { + break; } } - - /// Adds the dirty Python cells of the pass and their kept results: like the formulas, they - /// are pending until laid out, so every read path calculates them before reading them. - fn with_python(mut self, python: HashMap) -> Self { - self.pending.extend(python.keys().copied()); - self.python = python; - self - } } -/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, -/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is -/// recalculated in this pass, and so may spill elsewhere now). -fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { - let Some(sh) = host.wb.sheet(sheet) else { return false }; - let end = - CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); - let range = RangeRef::new(anchor, end); - let own = sh.spill_ranges.get(&anchor); - let spilled_over = |c: CellRef| { - !own.is_some_and(|o| o.contains(c)) - && sh.spill_ranges.iter().any(|(o, r)| { - // A formula or Python cell recalculated in this pass lays its array out again. - *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o)) - }) - }; - end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.merges.iter().any(|m| m.intersects(&range)) - || sh.tables.iter().any(|t| t.range.contains(anchor)) - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) - || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) -} - -/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over -/// `range`: the same area, nothing new in the way, and the same values. -fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { - let end = - CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); - RangeRef::new(anchor, end) == range - && !sheet.merges.iter().any(|m| m.intersects(&range)) - && !sheet.tables.iter().any(|t| t.range.contains(anchor)) - && range - .iter() - .enumerate() - .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) -} - -impl PassHost<'_> { - /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would - /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is - /// calculated from here, then the suspended ones again (reading the results kept so far). - fn calculate(&mut self, k: Key) { - // Formulas to calculate, innermost last, each with the ones abandoned to calculate the - // next. - let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; - while let Some(&(top, _)) = work.last() { - self.compute(top); - match self.suspend.take() { - Some(next) => { - #[cfg(test)] - { - self.suspensions += 1; - } - let held = std::mem::take(&mut self.abandoned); - self.waiting.extend(held.iter().copied()); - if let Some(entry) = work.last_mut() { - entry.1 = held; - } - // The other formulas read at the limit are calculated after `next`, before the - // held evaluations restart. Each has nothing held of its own, so the held ones - // are released only when the last of them is done. - let mut seen: HashSet = HashSet::default(); - seen.insert(next); - for s in std::mem::take(&mut self.also) { - if seen.insert(s) { - work.push((s, Vec::new())); - } - } - work.push((next, Vec::new())); - } - None => { - work.pop(); - if let Some(entry) = work.last_mut() { - for a in entry.1.drain(..) { - self.waiting.remove(&a); - } - } +/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each +/// node's component index, and the number of nodes in each component. +fn components(edges: &[Vec]) -> (Vec, Vec) { + let n = edges.len(); + let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]); + let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]); + let (mut next, mut comps) = (0usize, 0usize); + for root in 0..n { + if num[root] != usize::MAX { + continue; + } + let mut call: Vec<(usize, usize)> = vec![(root, 0)]; + num[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + while let Some(top) = call.last_mut() { + let v = top.0; + if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) { + top.1 += 1; + if num[w] == usize::MAX { + num[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + call.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(num[w]); } + continue; + } + call.pop(); + if let Some(&(u, _)) = call.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == num[v] { + pop_component(&mut stack, &mut on_stack, &mut comp, comps, v); + comps += 1; } } } - - fn compute(&mut self, k: Key) -> Value { - if let Some(v) = self.results.get(&k) { - let v = v.clone(); - if self.iterate && self.tainted.contains(&k) { - self.taint = true; - } - return v; - } - if let Some(result) = self.python.get(&k).cloned() { - return self.python_result(k, result); - } - let Some(expr) = self.exprs.get(&k) else { - return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); - }; - if self.suspend.is_some() { - // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is - // calculated with the suspended one, so the evaluations are not restarted for each. - if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { - self.also.push(k); - } - return Value::Error(CellError::Calc); - } - if self.in_progress.contains(&k) || self.waiting.contains(&k) { - self.cycle = true; - if self.iterate { - return self.circular_read(k); - } - // Once each, though a suspended evaluation meets it again. - if self.cycle_seen.insert(k) { - self.cycles.push(k); - } - return Value::Error(CellError::Circ); - } - if self.depth >= MAX_NESTED { - self.suspend = Some(k); - return Value::Error(CellError::Calc); - } - if self.depth == 0 { - self.stack_top = stack_position(); - } - self.in_progress.insert(k); - self.depth += 1; - let outer_taint = std::mem::replace(&mut self.taint, false); - if self.iterate { - self.stack.push(k); - } - let expr = std::sync::Arc::clone(expr); - let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); - let v = { - let mut ev = Evaluator::new(self, k.0, k.1); - ev.value(&expr) - }; - let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); - if out_of_stack && self.depth > 1 && self.suspend.is_none() { - // It ran out of the stack left below the formulas that read it: it is calculated - // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. - self.suspend = Some(k); - self.depth -= 1; - self.in_progress.remove(&k); - if self.iterate { - self.stack.pop(); - } - self.taint = outer_taint; - return Value::Error(CellError::Calc); - } - if self.suspend.is_some() { - // Abandoned: `calculate` evaluates this formula again once the one it needs is done. - self.depth -= 1; - self.in_progress.remove(&k); - self.abandoned.push(k); - if self.iterate { - self.stack.pop(); - } - self.taint = outer_taint; - return Value::Error(CellError::Calc); - } - // A legacy array formula fills the range it was entered in. - let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); - let v = match legacy { - Some(range) => fit_to_range(v, range), - None => v, - }; - self.depth -= 1; - self.in_progress.remove(&k); - self.pending.remove(&k); - if self.iterate { - self.stack.pop(); - if self.taint { - self.tainted.insert(k); - } else { - self.tainted.remove(&k); - } - } - self.taint |= outer_taint; - // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, - // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells - // of their own, which stay blank. - let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); - let v = match v { - Value::Array(a) => { - let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { - a - } else { - let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); - Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) - }; - // A dynamic-array formula anchors a spill range even while its result is one - // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` - // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that - // returns a scalar never reaches this arm, so it does not become an anchor. - self.spills.insert(k, a.clone()); - a.data.first().cloned().unwrap_or_default() - } - Value::Empty => Value::Number(0.0), - v => v, - }; - // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. - let v = match self.spills.get(&k) { - Some(a) if spill_blocked(self, k, a) => { - self.spills.remove(&k); - Value::Error(CellError::Spill) - } - _ => v, - }; - self.results.insert(k, v.clone()); - v - } - - /// A Python cell's kept result as its value: an array spills from the cell unless it is - /// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a - /// crafted file holds one), which shows `#SPILL!`. - fn python_result(&mut self, k: Key, result: Value) -> Value { - self.pending.remove(&k); - let v = match result { - Value::Array(a) => { - let sane = a.rows >= 1 - && a.cols >= 1 - && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS); - if sane { - self.spills.insert(k, a.clone()); - a.data.first().cloned().unwrap_or_default() - } else { - Value::Error(CellError::Spill) - } - } - v => v, - }; - let v = match self.spills.get(&k) { - Some(a) if spill_blocked(self, k, a) => { - self.spills.remove(&k); - Value::Error(CellError::Spill) - } - _ => v, - }; - self.results.insert(k, v.clone()); - v - } - - /// A formula's previous value for iterative calculation: from the last iteration, else the - /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. - fn previous(&self, k: Key) -> Value { - let v = match self.prev.get(&k) { - Some(v) => v.clone(), - None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), - }; - if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } - } - - /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. - /// The formulas around the loop are the ones whose changes decide when iteration stops. - fn circular_read(&mut self, k: Key) -> Value { - self.taint = true; - match self.stack.iter().rposition(|s| *s == k) { - Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), - // Too deep to follow: the formula itself is watched. - None => { - self.members.insert(k); - } - } - self.previous(k) - } - - /// Iterative calculation, after a first pass over `order` that met circular references. The - /// formulas that read a previous value (the loops and what depends on them) are evaluated - /// again in the same order, each pass starting from the last one's values and reading what it - /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or - /// more, or after `max_iterations` passes in all. Returns the number of passes. - fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { - let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); - let set: HashSet = again.iter().copied().collect(); - // Bounds the work whatever Maximum Iterations says. - let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); - let mut passes = 1u32; - while passes < max_iterations.min(max_passes) && !self.converged(max_change) { - self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); - self.pending = set.clone(); - self.spills.retain(|a, _| !set.contains(a)); - for k in &again { - self.calculate(*k); - } - passes += 1; - } - passes - } - - /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, - /// logical and error values: not at all). - fn converged(&self, max_change: f64) -> bool { - self.members.iter().all(|k| { - let Some(new) = self.results.get(k) else { return true }; - match (new, &self.previous(*k)) { - (Value::Number(a), Value::Number(b)) => { - let d = (a - b).abs(); - d < max_change || d == 0.0 - } - (new, old) => new == old, - } - }) - } -} - -/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as -/// Excel does: a single row or column repeats across the range, a single value fills it, cells -/// past the result show `#N/A`, and a result larger than the range is cut off. -fn fit_to_range(v: Value, range: RangeRef) -> Value { - if range.count() > crate::eval::MAX_CELLS { - return Value::Error(CellError::Num); - } - let (h, w) = (range.height() as usize, range.width() as usize); - let Value::Array(a) = v else { - return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; - }; - if h * w == 1 { - return a.data.first().cloned().unwrap_or_default(); - } - let mut data = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); - data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + let mut size = vec![0usize; comps]; + for &c in &comp { + if let Some(s) = size.get_mut(c) { + *s += 1; } } - Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) -} - -impl Host for PassHost<'_> { - fn workbook(&self) -> &Workbook { - self.wb - } - fn stack_top(&self) -> Option { - (self.depth > 1).then_some(self.stack_top) - } - fn stack_exhausted(&mut self) { - self.out_of_stack = true; - } - fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { - let k = (sheet, anchor); - if !(self.pending.contains(&k) || self.results.contains_key(&k)) { - return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); - } - // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). - self.compute(k); - let arr = self.spills.get(&k)?; - let end = CellRef::new( - anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), - anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - let range = RangeRef::new(anchor, end); - let sh = self.wb.sheet(sheet)?; - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) - || sh.merges.iter().any(|m| m.intersects(&range)); - (!blocked).then_some(range) - } - fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { - let k = (sheet, c); - if self.pending.contains(&k) || self.results.contains_key(&k) { - return self.compute(k); - } - // A cell covered by a spill computed in this pass. - for (anchor, arr) in &self.spills { - if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { - let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); - if dr < arr.rows && dc < arr.cols { - if self.iterate && self.tainted.contains(anchor) { - self.taint = true; - } - return arr.get(dr, dc).cloned().unwrap_or_default(); - } - } - } - self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() - } - fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { - let (h, w) = (range.height() as usize, range.width() as usize); - let mut out = vec![Value::Empty; h * w]; - let Some(sh) = self.wb.sheet(sheet) else { return out }; - // Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go - // through `compute`, constants are copied straight from the store. - let mut formulas: Vec<(usize, CellRef)> = Vec::new(); - for (c, cell) in sh.cells.iter_range(range) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if cell.formula.is_some() || cell.py.is_some() { - formulas.push((i, c)); - } else if let Some(slot) = out.get_mut(i) { - *slot = cell.value.clone(); - } - } - for (c, v) in sh.spill.range(range.start..=range.end) { - if range.contains(*c) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if let Some(slot) = out.get_mut(i) - && slot.is_empty() - { - *slot = v.clone(); - } - } - } - for (i, c) in formulas { - let v = self.cell_value(sheet, c); - if let Some(slot) = out.get_mut(i) { - *slot = v; - } - } - if !self.spills.is_empty() { - let new: Vec<(usize, Value)> = self - .spills - .iter() - .filter(|(a, _)| a.0 == sheet) - .flat_map(|(a, arr)| { - let mut v = Vec::new(); - for r in 0..arr.rows { - for cc in 0..arr.cols { - let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); - if c != a.1 && range.contains(c) { - v.push(( - (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, - arr.get(r, cc).cloned().unwrap_or_default(), - )); - } - } - } - v - }) - .collect(); - // Iterating: an array spilled by a formula that read a previous value. - if self.iterate - && self.spills.iter().any(|(a, arr)| { - let end = CellRef::new( - a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), - a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) - }) - { - self.taint = true; - } - for (i, v) in new { - if let Some(slot) = out.get_mut(i) { - *slot = v; - } - } - } - out - } - fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { - let wb = self.wb; - let Some(sh) = wb.sheet(sheet) else { return false }; - // Formula and Python cells first, in row-major order like `range_values`: reading one - // calculates it (a Python cell lays out its result) and the pass remembers the value, so - // the walk below gets the same value again without this method holding any. - for (c, cell) in sh.cells.iter_range(range) { - if cell.formula.is_some() || cell.py.is_some() { - let _ = self.cell_value(sheet, c); - } - } - // Spilled values sit between the stored cells (and the formulas just calculated may have - // produced some): leave such blocks to `range_values`. - if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { - return false; - } - for (c, cell) in sh.cells.iter_range(range) { - let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; - if !go { - break; - } - } - true - } - fn used_range(&mut self, sheet: usize) -> Option { - let wb = self.wb; - *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) - } - fn now_serial(&self) -> f64 { - self.now - } - fn random(&mut self) -> f64 { - // xorshift64* - let mut x = *self.rng; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - *self.rng = x; - (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 - } + (comp, size) } impl Calc { @@ -1020,6 +153,7 @@ impl Calc { if circ.is_empty() { return Vec::new(); } + let n = circ.len(); let index: HashMap = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect(); // Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way. let mut deps = Vec::new(); @@ -1034,59 +168,8 @@ impl Calc { e }) .collect(); - // Tarjan's strongly connected components, iterative. - let n = circ.len(); - let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]); - let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]); - let (mut next, mut comps) = (0usize, 0usize); - for root in 0..n { - if num[root] != usize::MAX { - continue; - } - let mut call: Vec<(usize, usize)> = vec![(root, 0)]; - num[root] = next; - low[root] = next; - next += 1; - stack.push(root); - on_stack[root] = true; - while let Some(top) = call.last_mut() { - let v = top.0; - if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) { - top.1 += 1; - if num[w] == usize::MAX { - num[w] = next; - low[w] = next; - next += 1; - stack.push(w); - on_stack[w] = true; - call.push((w, 0)); - } else if on_stack[w] { - low[v] = low[v].min(num[w]); - } - continue; - } - call.pop(); - if let Some(&(u, _)) = call.last() { - low[u] = low[u].min(low[v]); - } - if low[v] == num[v] { - while let Some(w) = stack.pop() { - on_stack[w] = false; - comp[w] = comps; - if w == v { - break; - } - } - comps += 1; - } - } - } - let mut size = vec![0usize; comps]; - for &c in &comp { - if let Some(s) = size.get_mut(c) { - *s += 1; - } - } + // Loop members: nodes in a component of more than one, or with an edge to themselves. + let (comp, size) = components(&edges); let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i)); // A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see. let mut out: Vec = @@ -1295,105 +378,13 @@ impl Calc { self.circular.retain(|k| !evaluated.contains(k)); } self.circular.extend(cycles); - // Write back. - let mut spill_changes: Vec = Vec::new(); - let mut freed: Vec<(usize, RangeRef)> = Vec::new(); - for (k, v) in results { - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - if let Some(cell) = sheet.cells.get_mut(k.1) - && cell.value != v - { - cell.value = v; - } - } - for k in &dirty { - let new = spills.get(k); - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - let old = sheet.spill_ranges.get(&k.1).copied(); - if old.is_none() && new.is_none() { - continue; - } - // An array that spills exactly as before changes nothing its dependents read, so - // they aren't queued again. Otherwise two arrays that read each other's spill areas - // queue each other in every round, up to the round limit. - if let (Some(r), Some(arr)) = (old, new) - && spills_as_before(sheet, k.1, r, arr) - { - continue; - } - if let Some(r) = old { - for c in r.iter() { - sheet.spill.remove(&c); - } - sheet.spill_ranges.remove(&k.1); - } - let mut changed_range = old; - if let Some(arr) = new { - let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1)); - let range = RangeRef::new(k.1, end); - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c))) - || sheet.merges.iter().any(|m| m.intersects(&range)) - || sheet.tables.iter().any(|t| t.range.contains(k.1)); - if blocked { - if let Some(cell) = sheet.cells.get_mut(k.1) { - cell.value = Value::Error(CellError::Spill); - } - // Not blocked while the pass ran (it would have been #SPILL! there), so - // blocked by another array of this pass: the formulas that read this one, - // at its anchor or over the area it would have spilled into, read #SPILL! - // or empty cells now. - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, range, &mut deps); - spill_changes.extend(deps.into_iter().filter(|d| d != k)); - } else { - for (i, c) in range.iter().enumerate() { - if c != k.1 { - sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default()); - } - } - sheet.spill_ranges.insert(k.1, range); - changed_range = Some(changed_range.map_or(range, |o| o.union(&range))); - } - } - if let Some(r) = changed_range { - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, r, &mut deps); - // Not the anchor itself (that would read its own array), unless its old array - // is gone or partly freed: its new formula may have read the old values, still - // laid out while it was evaluated. - let freed = match (old, sheet.spill_ranges.get(&k.1)) { - (Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)), - (_, None) => true, - (None, Some(_)) => false, - }; - spill_changes.extend(deps.into_iter().filter(|d| d != k || freed)); - } - if let Some(o) = old - && !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end)) - { - freed.push((k.0, o)); - } - } + let mut spill_changes = self.write_back(wb, &dirty, results, &spills); // Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read // a spill area before it was laid out: they are evaluated once more. if dynamic_again && !spills.is_empty() { dynamic_again = false; spill_changes.extend(self.graph.dynamic.iter().copied()); } - // An area a formula no longer spills over may unblock another formula's array. - for (si, o) in freed { - for &(s, c) in self.graph.nodes.keys() { - if s == si - && c.row <= o.end.row - && c.col <= o.end.col - && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) - { - spill_changes.push((s, c)); - } - } - } if full { // Everything was already evaluated once; spill dependents need another pass. } @@ -1415,6 +406,105 @@ impl Calc { self.circular.retain(|k| self.graph.nodes.contains_key(k)); } } + + fn write_back( + &mut self, + wb: &mut Workbook, + dirty: &[Key], + results: HashMap, + spills: &HashMap>, + ) -> Vec { + let mut spill_changes = Vec::new(); + let mut freed = Vec::new(); + for (key, value) in results { + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + if let Some(cell) = sheet.cells.get_mut(key.1) + && cell.value != value + { + cell.value = value; + } + } + for key in dirty { + let new = spills.get(key); + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + let old = sheet.spill_ranges.get(&key.1).copied(); + if old.is_none() && new.is_none() { + continue; + } + // An unchanged spill does not alter any values its dependents read. + if let (Some(range), Some(array)) = (old, new) + && spills_as_before(sheet, key.1, range, array) + { + continue; + } + if let Some(range) = old { + for cell in range.iter() { + sheet.spill.remove(&cell); + } + sheet.spill_ranges.remove(&key.1); + } + let mut changed_range = old; + if let Some(array) = new { + let end = CellRef::new(key.1.row.saturating_add(array.rows as u32 - 1), key.1.col.saturating_add(array.cols as u32 - 1)); + let range = RangeRef::new(key.1, end); + let blocked = spill_is_blocked(sheet, key.1, range, end); + if blocked { + if let Some(cell) = sheet.cells.get_mut(key.1) { + cell.value = Value::Error(CellError::Spill); + } + let mut blocked_dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut blocked_dependents); + spill_changes.extend(blocked_dependents.into_iter().filter(|dependent| dependent != key)); + } else { + fill_spill(sheet, key.1, range, array); + changed_range = Some(changed_range.map_or(range, |previous| previous.union(&range))); + } + } + if let Some(range) = changed_range { + let mut dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut dependents); + let freed_old_range = match (old, sheet.spill_ranges.get(&key.1)) { + (Some(previous), Some(current)) => !(current.contains(previous.start) && current.contains(previous.end)), + (_, None) => true, + (None, Some(_)) => false, + }; + spill_changes.extend(dependents.into_iter().filter(|dependent| dependent != key || freed_old_range)); + } + if let Some(previous) = old + && !sheet.spill_ranges.get(&key.1).is_some_and(|current| current.contains(previous.start) && current.contains(previous.end)) + { + freed.push((key.0, previous)); + } + } + for (sheet_index, range) in freed { + for &(sheet, cell) in self.graph.nodes.keys() { + let was_blocked_cell = cell.row <= range.end.row + && cell.col <= range.end.col + && wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill)); + if sheet == sheet_index && was_blocked_cell { + spill_changes.push((sheet, cell)); + } + } + } + spill_changes + } +} + +fn spill_is_blocked(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, end: CellRef) -> bool { + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || range.iter().any(|cell| cell != anchor && (sheet.cells.has(cell) || sheet.spill.contains_key(&cell))) + || sheet.merges.iter().any(|merge| merge.intersects(&range)) + || sheet.tables.iter().any(|table| table.range.contains(anchor)) +} + +fn fill_spill(sheet: &mut gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, array: &Array) { + for (index, cell) in range.iter().enumerate() { + if cell != anchor { + sheet.spill.insert(cell, array.data.get(index).cloned().unwrap_or_default()); + } + } + sheet.spill_ranges.insert(anchor, range); } /// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm). @@ -1440,21 +530,13 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + gridcraft_core::date::unix_now_ms().unwrap_or(0.0) } #[cfg(test)] pub(crate) mod tests { - use std::time::{Duration, Instant}; + use std::time::Duration; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; @@ -1567,11 +649,10 @@ pub(crate) mod tests { } /// Opening (full recalc), changing every value of a column, and re-entering every formula. - #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm fn open_and_bulk_edit(rows: u32) -> Duration { let mut wb = sum_rows(rows); let mut calc = Calc::new(); - let t = Instant::now(); + let started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0); calc.recalc_all(&mut wb); let values: Vec = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect(); if let Some(sh) = wb.sheet_mut(0) { @@ -1582,7 +663,8 @@ pub(crate) mod tests { calc.cells_changed(&mut wb, &values); let formulas: Vec = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect(); calc.cells_changed(&mut wb, &formulas); - let elapsed = t.elapsed(); + let ended = gridcraft_core::date::unix_now_ms().unwrap_or(started); + let elapsed = Duration::from_secs_f64((ended - started).max(0.0) / 1000.0); let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10))); let r = rows - 1; assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::())))); @@ -1597,7 +679,6 @@ pub(crate) mod tests { #[test] #[ignore = "timing-sensitive; run with --ignored in release"] - #[allow(clippy::disallowed_methods)] fn recalc_with_range_formulas_scales_linearly() { // Each row's SUM reads a range in the same columns. Recalculating on open used to walk // every row per range read (the used range), and finding a cell's dependents scanned diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs new file mode 100644 index 000000000..ab034f8cc --- /dev/null +++ b/crates/calc/src/recalc/graph.rs @@ -0,0 +1,347 @@ +use super::RangeRef; +use crate::eval::Area; +use gridcraft_core::CellRef; +use std::collections::BTreeSet; +use std::hash::{BuildHasherDefault, Hasher}; + +/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). +#[derive(Default, Clone, Copy)] +pub struct FxHasher(u64); + +impl Hasher for FxHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.write_u64(*b as u64); + } + } + fn write_u64(&mut self, i: u64) { + self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); + } + fn write_u32(&mut self, i: u32) { + self.write_u64(i as u64); + } + fn write_usize(&mut self, i: usize) { + self.write_u64(i as u64); + } +} + +pub(super) type HashMap = std::collections::HashMap>; + +pub(super) type HashSet = std::collections::HashSet>; + +pub type Key = (usize, CellRef); + +#[derive(Clone, Debug, Default)] +pub(super) struct Node { + pub(super) areas: Vec, + pub(super) dynamic: bool, +} + +/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. +const WIDE: u32 = 64; + +/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. +const PENDING: usize = 32; + +/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows +/// `a..=b`?" in O(log n + k). +/// +/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query +/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one +/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops +/// dead entries once either grows, so mutations cost O(log n) amortised. +#[derive(Clone, Debug)] +pub(super) struct Intervals { + /// Sorted, without duplicates. + items: Vec<(u32, u32, T)>, + dead: Vec, + dead_count: usize, + /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at + /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). + max_end: Vec, + /// Inserted since the last merge. + pending: BTreeSet<(u32, u32, T)>, +} + +impl Default for Intervals { + fn default() -> Self { + Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } + } +} + +impl Intervals { + pub(super) fn is_empty(&self) -> bool { + self.items.len() == self.dead_count && self.pending.is_empty() + } + + pub(super) fn insert(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && *d + { + *d = false; + self.dead_count -= 1; + } + return; + } + self.pending.insert(it); + } + + pub(super) fn remove(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && !*d + { + *d = true; + self.dead_count += 1; + } + return; + } + self.pending.remove(&it); + } + + /// Merges pending insertions and drops dead entries when there are enough of them. + pub(super) fn settle(&mut self) { + if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { + return; + } + let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); + all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); + all.extend(std::mem::take(&mut self.pending)); + // Two sorted runs: the stable sort merges them in linear time. + all.sort(); + all.dedup(); + let size = all.len().next_power_of_two(); + let mut max_end = vec![0; 2 * size]; + if let Some(leaves) = max_end.get_mut(size..) { + for (slot, it) in leaves.iter_mut().zip(&all) { + *slot = it.1; + } + } + for i in (1..size).rev() { + let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); + if let Some(slot) = max_end.get_mut(i) { + *slot = m; + } + } + self.dead = vec![false; all.len()]; + self.dead_count = 0; + self.items = all; + self.max_end = max_end; + } + + /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the + /// number of tree nodes visited. + pub(super) fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + // Entries before `p` start at or before row `b`. + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return 0; + } + self.walk(1, 0, self.max_end.len() / 2, p, a, f) + } + + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return 1; + } + if hi - lo <= 1 { + if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) + && it.1 >= a + { + f(&it.2); + } + return 1; + } + let mid = lo + (hi - lo) / 2; + 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Graph { + pub(super) nodes: HashMap, + /// single cell → dependents + cell_deps: HashMap>, + /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column + col_deps: HashMap<(usize, u32), Intervals>, + /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) + wide_deps: HashMap>, + pub(super) dynamic: HashSet, + /// Range indexes changed since the last [`Graph::settle`]. + unsettled_cols: HashSet<(usize, u32)>, + unsettled_wide: HashSet, +} + +/// A formula's precedents split by shape: single cells, column intervals and wide ranges. +type Entries = (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>); + +impl Graph { + pub fn len(&self) -> usize { + self.nodes.len() + } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Index entries of a formula's precedents, without duplicates: single cells, column + /// intervals and wide ranges. + fn entries(areas: &[Area]) -> Entries { + let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for a in areas { + if a.range.is_single() { + cells.push((a.sheet, a.range.start)); + } else if a.range.width() > WIDE { + wide.push((a.sheet, a.range)); + } else { + for c in a.range.start.col..=a.range.end.col { + cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); + } + } + } + cells.sort_unstable(); + cells.dedup(); + cols.sort_unstable(); + cols.dedup(); + wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); + wide.dedup(); + (cells, cols, wide) + } + + pub(super) fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + let (cells, cols, wide) = Self::entries(&node.areas); + for c in cells { + self.cell_deps.entry(c).or_default().insert(k); + } + for (s, c, r0, r1) in cols { + self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + for (s, r) in wide { + self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + if node.dynamic { + self.dynamic.insert(k); + } + self.nodes.insert(k, node); + } + + pub(super) fn remove(&mut self, k: Key) { + let Some(old) = self.nodes.remove(&k) else { return }; + let (cells, cols, wide) = Self::entries(&old.areas); + for c in cells { + if let Some(v) = self.cell_deps.get_mut(&c) { + v.remove(&k); + if v.is_empty() { + self.cell_deps.remove(&c); + } + } + } + for (s, c, r0, r1) in cols { + if let Some(v) = self.col_deps.get_mut(&(s, c)) { + v.remove(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + } + for (s, r) in wide { + if let Some(v) = self.wide_deps.get_mut(&s) { + v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + } + self.dynamic.remove(&k); + } + + /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, + /// just slower). + pub(super) fn settle(&mut self) { + for key in std::mem::take(&mut self.unsettled_cols) { + if let Some(v) = self.col_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.col_deps.remove(&key); + } + } + } + for key in std::mem::take(&mut self.unsettled_wide) { + if let Some(v) = self.wide_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.wide_deps.remove(&key); + } + } + } + } + + /// Formulas that read cell `c` of `sheet`. + pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query(c.row, c.row, &mut |k| out.push(*k)); + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(c.row, c.row, &mut |(c0, c1, k)| { + if *c0 <= c.col && c.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Formulas that read any cell of `range` (a formula can be listed more than once). + pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { + if range.count() <= 4096 { + for c in range.iter() { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + } + } else { + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend(v.iter().copied()); + } + } + } + let (r0, r1) = (range.start.row, range.end.row); + if (range.width() as usize) <= self.col_deps.len() { + for col in range.start.col..=range.end.col { + if let Some(v) = self.col_deps.get(&(sheet, col)) { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } else { + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(r0, r1, &mut |(c0, c1, k)| { + if *c0 <= range.end.col && range.start.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Precedent areas of a formula cell (for Trace Precedents). + pub fn precedents_of(&self, k: Key) -> Vec { + self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() + } +} diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs new file mode 100644 index 000000000..42cc6a834 --- /dev/null +++ b/crates/calc/src/recalc/pass_host.rs @@ -0,0 +1,605 @@ +use super::graph::{HashMap, HashSet, Key}; +use super::{Evaluator, Host, Workbook, stack_position}; +use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use gridcraft_formula::Expr; + +/// Formulas a formula may calculate on demand one inside the other (each needing one another's +/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and +/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate +/// whatever order they come in, with bounded stack. +const MAX_NESTED: usize = 64; + +/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas +/// evaluated again), however high Maximum Iterations is. +const MAX_ITERATION_WORK: usize = 10_000_000; + +pub(super) struct PassHost<'a> { + wb: &'a Workbook, + exprs: &'a HashMap>, + pub(super) results: HashMap, + pending: HashSet, + in_progress: HashSet, + /// New spills found during the pass: anchor → array. + pub(super) spills: HashMap>, + /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks + /// every row, so once per sheet rather than once per range read. + used: HashMap>, + rng: &'a mut u64, + now: f64, + cycle: bool, + pub(super) cycles: Vec, + depth: usize, + /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned + /// and it is calculated first. + suspend: Option, + /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. + /// They all need it, so reading one of them while it is calculated is a circular reference, + /// as reading a formula in progress is. + waiting: HashSet, + /// The formulas abandoned since the last suspension. + abandoned: Vec, + /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They + /// are calculated along with the suspended one, rather than one per restart (a wide range + /// read at the limit would restart the abandoned evaluations once per cell in it). + also: Vec, + /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). + #[cfg(test)] + pub(super) suspensions: usize, + /// The formulas in `cycles`. + cycle_seen: HashSet, + /// Iterative calculation: a circular reference reads the cell's previous value instead of + /// giving `#CIRC!`. + pub(super) iterate: bool, + /// Values from the previous iteration; cells not in it read their stored value. + prev: HashMap, + /// Formulas being evaluated, innermost last (kept only when iterating). + stack: Vec, + /// Formulas found on a circular reference (when iterating). + pub(super) members: HashSet, + /// Results that read a previous value, directly or through other formulas. + pub(super) tainted: HashSet, + /// Whether the formula being evaluated has read a previous value. + taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, + /// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its + /// result is laid out (an array spills from the cell, as a formula's does). Each one is in + /// `pending` until it is laid out, so a formula that reads it first gets its new value. + python: HashMap, +} + +impl<'a> PassHost<'a> { + pub(super) fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { + PassHost { + wb, + exprs, + results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), + pending: exprs.keys().copied().collect(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng, + now, + cycle: false, + cycles: vec![], + depth: 0, + suspend: None, + waiting: HashSet::default(), + abandoned: Vec::new(), + also: Vec::new(), + #[cfg(test)] + suspensions: 0, + cycle_seen: HashSet::default(), + iterate: false, + prev: HashMap::default(), + stack: Vec::new(), + members: HashSet::default(), + tainted: HashSet::default(), + taint: false, + stack_top: 0, + out_of_stack: false, + python: HashMap::default(), + } + } + + /// Adds the dirty Python cells of the pass and their kept results: like the formulas, they + /// are pending until laid out, so every read path calculates them before reading them. + pub(super) fn with_python(mut self, python: HashMap) -> Self { + self.pending.extend(python.keys().copied()); + self.python = python; + self + } +} + +/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, +/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is +/// recalculated in this pass, and so may spill elsewhere now). +fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { + let Some(sh) = host.wb.sheet(sheet) else { return false }; + let end = + CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); + let range = RangeRef::new(anchor, end); + let own = sh.spill_ranges.get(&anchor); + let spilled_over = |c: CellRef| { + !own.is_some_and(|o| o.contains(c)) + && sh.spill_ranges.iter().any(|(o, r)| { + // A formula or Python cell recalculated in this pass lays its array out again. + *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o)) + }) + }; + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.merges.iter().any(|m| m.intersects(&range)) + || sh.tables.iter().any(|t| t.range.contains(anchor)) + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) + || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) +} + +/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over +/// `range`: the same area, nothing new in the way, and the same values. +pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { + let end = + CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); + RangeRef::new(anchor, end) == range + && !sheet.merges.iter().any(|m| m.intersects(&range)) + && !sheet.tables.iter().any(|t| t.range.contains(anchor)) + && range + .iter() + .enumerate() + .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) +} + +impl PassHost<'_> { + /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would + /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is + /// calculated from here, then the suspended ones again (reading the results kept so far). + pub(super) fn calculate(&mut self, k: Key) { + // Formulas to calculate, innermost last, each with the ones abandoned to calculate the + // next. + let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; + while let Some(&(top, _)) = work.last() { + self.compute(top); + match self.suspend.take() { + Some(next) => { + #[cfg(test)] + { + self.suspensions += 1; + } + let held = std::mem::take(&mut self.abandoned); + self.waiting.extend(held.iter().copied()); + if let Some(entry) = work.last_mut() { + entry.1 = held; + } + // The other formulas read at the limit are calculated after `next`, before the + // held evaluations restart. Each has nothing held of its own, so the held ones + // are released only when the last of them is done. + let mut seen: HashSet = HashSet::default(); + seen.insert(next); + for s in std::mem::take(&mut self.also) { + if seen.insert(s) { + work.push((s, Vec::new())); + } + } + work.push((next, Vec::new())); + } + None => { + work.pop(); + if let Some(entry) = work.last_mut() { + for a in entry.1.drain(..) { + self.waiting.remove(&a); + } + } + } + } + } + } + + fn compute(&mut self, k: Key) -> Value { + if let Some(v) = self.results.get(&k) { + let v = v.clone(); + if self.iterate && self.tainted.contains(&k) { + self.taint = true; + } + return v; + } + if let Some(result) = self.python.get(&k).cloned() { + return self.python_result(k, result); + } + let Some(expr) = self.exprs.get(&k) else { + return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); + }; + if self.suspend.is_some() { + // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is + // calculated with the suspended one, so the evaluations are not restarted for each. + if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { + self.also.push(k); + } + return Value::Error(CellError::Calc); + } + if self.in_progress.contains(&k) || self.waiting.contains(&k) { + self.cycle = true; + if self.iterate { + return self.circular_read(k); + } + // Once each, though a suspended evaluation meets it again. + if self.cycle_seen.insert(k) { + self.cycles.push(k); + } + return Value::Error(CellError::Circ); + } + if self.depth >= MAX_NESTED { + self.suspend = Some(k); + return Value::Error(CellError::Calc); + } + if self.depth == 0 { + self.stack_top = stack_position(); + } + self.in_progress.insert(k); + self.depth += 1; + let outer_taint = std::mem::replace(&mut self.taint, false); + if self.iterate { + self.stack.push(k); + } + let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); + let v = { + let mut ev = Evaluator::new(self, k.0, k.1); + ev.value(&expr) + }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } + if self.suspend.is_some() { + // Abandoned: `calculate` evaluates this formula again once the one it needs is done. + self.depth -= 1; + self.in_progress.remove(&k); + self.abandoned.push(k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } + // A legacy array formula fills the range it was entered in. + let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); + let v = match legacy { + Some(range) => fit_to_range(v, range), + None => v, + }; + self.depth -= 1; + self.in_progress.remove(&k); + self.pending.remove(&k); + if self.iterate { + self.stack.pop(); + if self.taint { + self.tainted.insert(k); + } else { + self.tainted.remove(&k); + } + } + self.taint |= outer_taint; + // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, + // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells + // of their own, which stay blank. + let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); + let v = match v { + Value::Array(a) => { + let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { + a + } else { + let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); + Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) + }; + // A dynamic-array formula anchors a spill range even while its result is one + // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` + // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that + // returns a scalar never reaches this arm, so it does not become an anchor. + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } + Value::Empty => Value::Number(0.0), + v => v, + }; + // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; + self.results.insert(k, v.clone()); + v + } + + /// A Python cell's kept result as its value: an array spills from the cell unless it is + /// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a + /// crafted file holds one), which shows `#SPILL!`. + fn python_result(&mut self, k: Key, result: Value) -> Value { + self.pending.remove(&k); + let v = match result { + Value::Array(a) => { + let sane = a.rows >= 1 + && a.cols >= 1 + && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS); + if sane { + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } else { + Value::Error(CellError::Spill) + } + } + v => v, + }; + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; + self.results.insert(k, v.clone()); + v + } + + /// A formula's previous value for iterative calculation: from the last iteration, else the + /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. + fn previous(&self, k: Key) -> Value { + let v = match self.prev.get(&k) { + Some(v) => v.clone(), + None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), + }; + if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } + } + + /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. + /// The formulas around the loop are the ones whose changes decide when iteration stops. + fn circular_read(&mut self, k: Key) -> Value { + self.taint = true; + match self.stack.iter().rposition(|s| *s == k) { + Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), + // Too deep to follow: the formula itself is watched. + None => { + self.members.insert(k); + } + } + self.previous(k) + } + + /// Iterative calculation, after a first pass over `order` that met circular references. The + /// formulas that read a previous value (the loops and what depends on them) are evaluated + /// again in the same order, each pass starting from the last one's values and reading what it + /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or + /// more, or after `max_iterations` passes in all. Returns the number of passes. + pub(super) fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { + let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); + let set: HashSet = again.iter().copied().collect(); + // Bounds the work whatever Maximum Iterations says. + let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); + let mut passes = 1u32; + while passes < max_iterations.min(max_passes) && !self.converged(max_change) { + self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); + self.pending = set.clone(); + self.spills.retain(|a, _| !set.contains(a)); + for k in &again { + self.calculate(*k); + } + passes += 1; + } + passes + } + + /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, + /// logical and error values: not at all). + fn converged(&self, max_change: f64) -> bool { + self.members.iter().all(|k| { + let Some(new) = self.results.get(k) else { return true }; + match (new, &self.previous(*k)) { + (Value::Number(a), Value::Number(b)) => { + let d = (a - b).abs(); + d < max_change || d == 0.0 + } + (new, old) => new == old, + } + }) + } +} + +/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as +/// Excel does: a single row or column repeats across the range, a single value fills it, cells +/// past the result show `#N/A`, and a result larger than the range is cut off. +fn fit_to_range(v: Value, range: RangeRef) -> Value { + if range.count() > crate::eval::MAX_CELLS { + return Value::Error(CellError::Num); + } + let (h, w) = (range.height() as usize, range.width() as usize); + let Value::Array(a) = v else { + return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; + }; + if h * w == 1 { + return a.data.first().cloned().unwrap_or_default(); + } + let mut data = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); + data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + } + } + Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) +} + +impl Host for PassHost<'_> { + fn workbook(&self) -> &Workbook { + self.wb + } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } + fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { + let k = (sheet, c); + if self.pending.contains(&k) || self.results.contains_key(&k) { + return self.compute(k); + } + // A cell covered by a spill computed in this pass. + for (anchor, arr) in &self.spills { + if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { + let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); + if dr < arr.rows && dc < arr.cols { + if self.iterate && self.tainted.contains(anchor) { + self.taint = true; + } + return arr.get(dr, dc).cloned().unwrap_or_default(); + } + } + } + self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() + } + fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { + let (h, w) = (range.height() as usize, range.width() as usize); + let mut out = vec![Value::Empty; h * w]; + let Some(sh) = self.wb.sheet(sheet) else { return out }; + // Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go + // through `compute`, constants are copied straight from the store. + let mut formulas: Vec<(usize, CellRef)> = Vec::new(); + for (c, cell) in sh.cells.iter_range(range) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if cell.formula.is_some() || cell.py.is_some() { + formulas.push((i, c)); + } else if let Some(slot) = out.get_mut(i) { + *slot = cell.value.clone(); + } + } + for (c, v) in sh.spill.range(range.start..=range.end) { + if range.contains(*c) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if let Some(slot) = out.get_mut(i) + && slot.is_empty() + { + *slot = v.clone(); + } + } + } + for (i, c) in formulas { + let v = self.cell_value(sheet, c); + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + if !self.spills.is_empty() { + let new: Vec<(usize, Value)> = self + .spills + .iter() + .filter(|(a, _)| a.0 == sheet) + .flat_map(|(a, arr)| { + let mut v = Vec::new(); + for r in 0..arr.rows { + for cc in 0..arr.cols { + let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); + if c != a.1 && range.contains(c) { + v.push(( + (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, + arr.get(r, cc).cloned().unwrap_or_default(), + )); + } + } + } + v + }) + .collect(); + // Iterating: an array spilled by a formula that read a previous value. + if self.iterate + && self.spills.iter().any(|(a, arr)| { + let end = CellRef::new( + a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), + a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) + }) + { + self.taint = true; + } + for (i, v) in new { + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + } + out + } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula and Python cells first, in row-major order like `range_values`: reading one + // calculates it (a Python cell lays out its result) and the pass remembers the value, so + // the walk below gets the same value again without this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() || cell.py.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } + fn used_range(&mut self, sheet: usize) -> Option { + let wb = self.wb; + *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) + } + fn now_serial(&self) -> f64 { + self.now + } + fn random(&mut self) -> f64 { + // xorshift64* + let mut x = *self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *self.rng = x; + (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 1f68a58fc..4ca0469f4 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() { assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0); } +#[test] +fn lifted_lookups_stop_past_their_work_budget() { + // Security: each needle searched the whole lookup vector, so this one cell ran for hours + // (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now. + let mut t = T::new(); + t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))"); + assert_eq!(t.get("A1"), Value::Error(CellError::Num)); + // Ordinary lifted lookups are unaffected. + for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() { + t.set(&format!("B{}", i + 1), &k.to_string()); + t.set(&format!("C{}", i + 1), &(i + 1).to_string()); + } + t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))"); + t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))"); + t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))"); + t.set("D4", "=SUM(MATCH({25,50},B1:B5))"); + assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]); +} + #[test] fn accounting_negative_text_coerces() { let mut t = T::new(); diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 3257082bb..6a64558a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -1,15 +1,28 @@ //! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. -use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind}; +use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; +mod cartesian; +mod frame; +mod kinds; +mod legend; +mod lines; +mod trend; + +use cartesian::{cartesian, scatter}; +use frame::{Bx, axis_titles, chart_title}; +use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall}; +use legend::draw_legend; +pub use legend::{Swatch, legend_entries}; +pub use trend::supports_trendline; + pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF]; const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF]; @@ -31,32 +44,6 @@ const MAX_MARKERS: usize = 5000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; -#[derive(Clone, Copy, Debug)] -struct Bx { - x: f32, - y: f32, - w: f32, - h: f32, -} - -impl Bx { - fn right(&self) -> f32 { - self.x + self.w - } - fn bottom(&self) -> f32 { - self.y + self.h - } - fn ok(&self) -> bool { - self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 - } - fn cx(&self) -> f32 { - self.x + self.w / 2.0 - } - fn cy(&self) -> f32 { - self.y + self.h / 2.0 - } -} - struct Ctx<'a> { out: Vec, m: &'a dyn Measure, @@ -82,8 +69,7 @@ impl Ctx<'_> { } String::new() } - #[allow(clippy::too_many_arguments)] - fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { + fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) { if s.is_empty() { return; } @@ -105,85 +91,6 @@ impl Ctx<'_> { } } -/// Text colour readable on `fill`. -fn on(fill: Rgba) -> Rgba { - let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; - if l < 150.0 { WHITE } else { DARK_TEXT } -} - -/// How a legend entry's swatch is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Swatch { - Box, - Line, - LineMarker, - Marker, -} - -/// The kind a series is drawn as (per-series kinds only apply to combo charts). -fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { - if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } -} - -/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). -fn point_color(data: &ChartData, j: usize) -> Rgba { - match (j, data.series.first()) { - (0, Some(s)) => s.color, - _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), - } -} - -fn first_len(data: &ChartData) -> usize { - data.series.first().map(|s| s.values.len()).unwrap_or(0) -} - -fn cat_label(data: &ChartData, j: usize) -> String { - data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) -} - -/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. -pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { - if chart.legend == LegendPos::None { - return vec![]; - } - match chart.kind { - ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { - let n = first_len(data).min(MAX_CATS); - (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() - } - ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], - ChartKind::Waterfall => { - if data.series.is_empty() { - return vec![]; - } - let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); - vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] - } - _ => { - let series = data.series.iter().take(MAX_SERIES); - let mut out: Vec<(String, Rgba, Swatch)> = series - .clone() - .map(|s| { - let sw = match plot_kind(chart, s) { - ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, - ChartKind::LineMarkers => Swatch::LineMarker, - ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, - _ => Swatch::Box, - }; - (s.name.clone(), s.color, sw) - }) - .collect(); - // Trendlines follow the series, named "Linear (Sales)" unless given a name. - for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); - } - } - out - } - } -} - fn sanitize_vals(v: &[Option]) -> Vec> { v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() } @@ -213,14 +120,6 @@ fn sanitize(d: &ChartData) -> ChartData { } } -fn chart_title(chart: &Chart, data: &ChartData) -> Option { - match &chart.title { - Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), - None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), - None => None, - } -} - /// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { @@ -234,7 +133,7 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) if let Some(t) = chart_title(chart, &data) { let size = TITLE_SIZE.min(h * 0.12).max(4.0); let t = ctx.fit(&t, size, area.w); - ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top); + ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top)); let used = size * 1.3 + 4.0; area.y += used; area.h -= used; @@ -276,1483 +175,6 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { } } -fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { - let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; - if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); - ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - area.h -= lh; - } - if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); - if !t.is_empty() { - ctx.out.push(Prim::Text { - x: area.x, - y: area.cy(), - text: t, - size: AXIS_TITLE_SIZE, - color: TEXT, - bold: false, - align: HAlign::Center, - valign: VAlign::Top, - rotation: -PI / 2.0, - }); - } - area.x += lh; - area.w -= lh; - } - area -} - -// ---------------------------------------------------------------- legend - -fn swatch_w(sw: Swatch, s: f32) -> f32 { - if sw == Swatch::Box { s } else { s * 2.0 } -} - -fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { - match sw { - Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), - Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), - Swatch::LineMarker => { - ctx.hline(x, x + 2.0 * s, yc, color, 2.0); - ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); - } - Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), - } -} - -fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { - let size = LABEL_SIZE; - let row_h = size * 1.6; - let s = size * 0.8; - let gap = 4.0; - let spacing = size * 1.3; - let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); - match pos { - LegendPos::None => area, - LegendPos::Top | LegendPos::Bottom => { - let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); - let mut rows: Vec> = vec![vec![]]; - let mut cur = 0.0f32; - for (i, e) in entries.iter().enumerate() { - let iw = item_w(ctx, e).min(area.w); - let need = if cur > 0.0 { spacing + iw } else { iw }; - if cur > 0.0 && cur + need > area.w { - if rows.len() >= max_rows { - break; - } - rows.push(vec![]); - cur = 0.0; - } - cur += if cur > 0.0 { spacing + iw } else { iw }; - if let Some(r) = rows.last_mut() { - r.push((i, iw)); - } - } - let lh = rows.len() as f32 * row_h; - let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; - for (ri, row) in rows.iter().enumerate() { - let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; - let mut x = area.x + (area.w - total).max(0.0) / 2.0; - let yc = y0 + ri as f32 * row_h + row_h / 2.0; - for &(i, iw) in row { - if let Some(e) = entries.get(i) { - draw_swatch(ctx, e.2, x, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); - ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - x += iw + spacing; - } - } - let used = lh + 4.0; - if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } - } - LegendPos::Left | LegendPos::Right => { - let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); - let max_rows = ((area.h / row_h).floor() as usize).max(1); - let n = entries.len().min(max_rows); - let lh = n as f32 * row_h; - let y0 = area.y + (area.h - lh).max(0.0) / 2.0; - let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; - for (i, e) in entries.iter().take(n).enumerate() { - let yc = y0 + i as f32 * row_h + row_h / 2.0; - draw_swatch(ctx, e.2, x0, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); - ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - let used = colw + 8.0; - if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } - } - } -} - -// ---------------------------------------------------------------- axes frame - -enum CatAxis<'a> { - /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; - /// one entry per category, non-empty where a group starts). - Labels(&'a [String], &'a [Vec]), - Numeric(Scale, &'a Fmt), -} - -/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every -/// non-empty entry (and at the first category). -fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { - let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); - starts.push(n); - starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() -} - -struct ValAxis<'a> { - scale: Scale, - fmt: &'a Fmt, -} - -impl ValAxis<'_> { - fn labels(&self) -> Vec<(f64, String)> { - self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() - } -} - -/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. -fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { - let ls = LABEL_SIZE; - let lh = ls * 1.4; - let ylabels = y.labels(); - let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); - if !horizontal { - let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); - let y2labels = y2.map(ValAxis::labels); - let right = match (&y2labels, &cat) { - (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), - (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), - _ => 4.0, - }; - let levels = match &cat { - CatAxis::Labels(_, o) => o.len() as f32, - CatAxis::Numeric(..) => 0.0, - }; - let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; - if !plot.ok() { - return plot; - } - for (v, t) in &ylabels { - let yy = plot.bottom() - y.scale.frac(*v) * plot.h; - if grid { - ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); - } - ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - if let (Some(a), Some(l)) = (y2, &y2labels) { - for (v, t) in l { - let yy = plot.bottom() - a.scale.frac(*v) * plot.h; - ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; - ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); - match cat { - CatAxis::Labels(l, outer) => { - let n = l.len().max(1); - let cw = plot.w / n as f32; - let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in l.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, cw * k as f32 - 2.0); - ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - // Outer levels, one row each below the labels: a group's name centred under its - // categories, and a separator at each group boundary down through the rows. - for (i, level) in outer.iter().enumerate() { - let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); - for (first, end) in level_groups(level, n) { - let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, x1 - x0 - 2.0); - ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if first > 0 { - ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - CatAxis::Numeric(s, f) => { - let ticks = s.ticks(); - let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let slot = plot.w / ticks.len().max(1) as f32; - let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (v, t) in ticks.iter().zip(&labels).step_by(k) { - let xx = plot.x + s.frac(*v) * plot.w; - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if s.min <= 0.0 && s.max >= 0.0 { - let xx = plot.x + s.frac(0.0) * plot.w; - ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); - } - } - } - plot - } else { - let (labels, outer): (Vec, &[Vec]) = match cat { - CatAxis::Labels(l, o) => (l.to_vec(), o), - CatAxis::Numeric(..) => (vec![], &[]), - }; - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); - // Outer levels stand in columns left of the labels, each as wide as its longest name. - let level_w: Vec = - outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); - let levels_w: f32 = level_w.iter().sum(); - let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; - if !plot.ok() { - return plot; - } - let vw = maxw(ctx, &ylabels); - let slot = plot.w / ylabels.len().max(1) as f32; - let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (i, (v, t)) in ylabels.iter().enumerate() { - let xx = plot.x + y.scale.frac(*v) * plot.w; - if grid { - ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); - } - if i % k == 0 { - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let x0 = plot.x + y.scale.frac(zero) * plot.w; - ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); - let n = labels.len().max(1); - let ch = plot.h / n as f32; - let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in labels.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, lw - 6.0); - ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - // Outer levels: a group's name centred beside its categories, separators at the - // boundaries running right to the axis. - let mut x1 = plot.x - lw; - for (level, w) in outer.iter().zip(&level_w) { - let x0 = x1 - w; - for (first, end) in level_groups(level, n) { - let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, w - 4.0); - ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); - } - if first > 0 { - ctx.hline(x0, plot.x, y1, AXIS, 0.75); - } - } - ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); - x1 = x0; - } - plot - } -} - -// ---------------------------------------------------------------- helpers - -/// Reduces a long polyline to first/min/max/last per pixel column. -fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { - if pts.len() <= max { - return pts; - } - let mut out = Vec::with_capacity(max * 2); - let mut i = 0; - while let Some(p) = pts.get(i) { - let col = p[0].floor(); - let (start, mut mn, mut mx) = (i, i, i); - let mut k = i + 1; - while let Some(q) = pts.get(k) { - if q[0].floor() != col { - break; - } - if pts.get(mn).is_some_and(|m| q[1] < m[1]) { - mn = k; - } - if pts.get(mx).is_some_and(|m| q[1] > m[1]) { - mx = k; - } - k += 1; - } - let mut idx = [start, mn, mx, k - 1]; - idx.sort_unstable(); - let mut last = usize::MAX; - for ix in idx { - if ix != last - && let Some(q) = pts.get(ix) - { - out.push(*q); - last = ix; - } - } - i = k; - } - if out.len() > max * 4 { - let step = out.len() / (max * 2) + 1; - let last = out.last().copied(); - let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); - if let Some(l) = last - && s.last() != Some(&l) - { - s.push(l); - } - return s; - } - out -} - -/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), -/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. -fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { - let mut run: Vec<[f32; 2]> = Vec::new(); - for p in pts.iter().chain(std::iter::once(&None)) { - match p { - Some(p) => run.push(*p), - None => { - if run.len() >= 2 { - let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); - let r = match smooth { - Some(plot) => smooth_run(r, plot), - None => r, - }; - ctx.line(r, color, width); - } else { - run.clear(); - } - } - } - } -} - -/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to -/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are -/// duplicated so the curve passes through the first and last points, and the curve is clamped -/// to the `plot` box (a spline overshoots its data near sharp turns). -fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { - let n = pts.len(); - if n < 3 { - return pts; - } - let cap = MAX_LINE_PTS * 2; - let steps = (cap / n).clamp(1, 100); - let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); - let at = |i: isize| -> [f32; 2] { - let j = i.clamp(0, n as isize - 1) as usize; - pts.get(j).copied().unwrap_or([0.0, 0.0]) - }; - let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); - let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); - let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; - for i in 0..n.saturating_sub(1) { - let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); - for s in 0..steps { - let t = s as f32 / steps as f32; - out.push(clip(catmull(p0, p1, p2, p3, t))); - } - } - out.push(clip(at(n as isize - 1))); - out -} - -/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. -fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { - let t2 = t * t; - let t3 = t2 * t; - let mut o = [0.0f32; 2]; - for k in 0..2 { - let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); - o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); - } - o -} - -fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { - let mut seen = HashSet::new(); - for p in pts { - if seen.len() >= MAX_MARKERS { - break; - } - if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); - } - } -} - -fn value_range(it: impl Iterator) -> Option<(f64, f64)> { - it.fold(None, |acc, v| match acc { - None => Some((v, v)), - Some((a, b)) => Some((a.min(v), b.max(v))), - }) -} - -// ---------------------------------------------------------------- trendlines - -/// Samples along a regression trendline. -const TREND_STEPS: usize = 200; - -/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, -/// bar, line, area, scatter and bubble charts). -pub fn supports_trendline(k: ChartKind) -> bool { - use ChartKind::*; - matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) -} - -/// A forecast distance as drawn: finite, non-negative and bounded. -fn forecast(v: f64) -> f64 { - if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } -} - -/// Most empty category slots a forward forecast may add after `ncat` categories: four times the -/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every -/// column to nothing. -fn max_forward_slots(ncat: usize) -> f64 { - (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 -} - -/// Longest forward forecast among `s`'s regression trendlines. -fn max_forward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) -} - -/// Longest backward forecast among `s`'s regression trendlines. -fn max_backward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) -} - -/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and -/// end were cut. -#[allow(clippy::type_complexity)] -fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let (mut t0, mut t1) = (0.0f64, 1.0f64); - for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { - if p == 0.0 { - if q < 0.0 { - return None; - } - } else { - let r = q / p; - if p < 0.0 { - if r > t1 { - return None; - } - t0 = t0.max(r); - } else { - if r < t0 { - return None; - } - t1 = t1.min(r); - } - } - } - Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) -} - -/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit -/// square), returning the visible runs. -fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { - const LIM: f64 = 1e9; - let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); - let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); - let mut runs: Vec> = Vec::new(); - let mut run: Vec<(f64, f64)> = Vec::new(); - for w in pts.windows(2) { - let seg = match w { - [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), - _ => None, - }; - match seg { - Some((p, q, cut_start, cut_end)) => { - if cut_start || run.is_empty() { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - run.push(p); - } - run.push(q); - if cut_end { - runs.push(std::mem::take(&mut run)); - } - } - None => { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - } - } - } - if run.len() >= 2 { - runs.push(run); - } - runs -} - -/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to -/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. -/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. -fn draw_trendlines( - ctx: &mut Ctx, - s: &SeriesData, - pts: &[(f64, f64)], - frac: &dyn Fn(f64, f64) -> (f64, f64), - px: &dyn Fn((f64, f64)) -> [f32; 2], - labels: &mut Vec, - window: (f64, f64), -) { - let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - let spec = &t.spec; - let samples: Vec> = match spec.kind { - TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), - _ => { - let Some(fit) = fit_trendline(spec, pts) else { continue }; - // Sample only the part of the forecast that is on the plot. - let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); - if spec.display_equation { - labels.push(fit.equation()); - } - if spec.display_r_squared { - labels.push(fit.r_squared()); - } - (0..=TREND_STEPS) - .map(|i| { - let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; - fit.eval(x).map(|y| frac(x, y)) - }) - .collect() - } - }; - let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); - let dash = spec.dash != Some(LineDash::Solid); - for run in clip_unit(&samples) { - let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); - if line.len() >= 2 { - ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); - } - } - } -} - -/// Trendline equation / R² labels, stacked in the plot's top-right corner. -fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { - let lh = LABEL_SIZE * 1.3; - for (i, t) in labels.iter().enumerate() { - let y = plot.y + 4.0 + i as f32 * lh; - if y + lh > plot.bottom() { - break; - } - let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); - ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); - } -} - -// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Fam { - Col, - ColStack, - Col100, - Line, - LineStack, - Area, - AreaStack, - Area100, -} - -fn family(k: ChartKind) -> Fam { - use ChartKind::*; - match k { - ColumnStacked | BarStacked => Fam::ColStack, - ColumnStacked100 | BarStacked100 => Fam::Col100, - Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, - LineStacked => Fam::LineStack, - Area => Fam::Area, - AreaStacked => Fam::AreaStack, - AreaStacked100 => Fam::Area100, - _ => Fam::Col, - } -} - -/// Families stacked to 100%: each category's values are shares of its total. -fn is_100(f: Fam) -> bool { - matches!(f, Fam::Col100 | Fam::Area100) -} - -fn is_col(f: Fam) -> bool { - matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) -} -fn is_line(f: Fam) -> bool { - matches!(f, Fam::Line | Fam::LineStack) -} - -fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); - let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); - let any_primary = data.series.iter().any(|s| !s.secondary); - let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - - // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). - let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - if is_100(fam) { - let key = (sec.get(i).copied().unwrap_or(false), fam); - let p = match totals.iter().position(|(k, _)| *k == key) { - Some(p) => p, - None => { - totals.push((key, vec![0.0; ncat])); - totals.len() - 1 - } - }; - for (j, v) in s.values.iter().take(ncat).enumerate() { - if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { - *t += v.abs(); - } - } - } - } - // A value's share of its category's total, for the 100% families. - let share = |key: (bool, Fam), j: usize, v: f64| { - let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); - if t > 0.0 { v / t } else { 0.0 } - }; - // Stack accumulators keyed by (axis, family): positive and negative running sums. - let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); - let mut spans: Vec>> = Vec::with_capacity(data.series.len()); - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - let a = sec.get(i).copied().unwrap_or(false); - let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); - let key = (a, fam); - let si = if stacked { - match stacks.iter().position(|(k, _, _)| *k == key) { - Some(p) => Some(p), - None => { - stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); - Some(stacks.len() - 1) - } - } - } else { - None - }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { - let v = s.values.get(j).copied().flatten(); - let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { - (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { - let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; - let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; - let base = *acc; - *acc += v; - Some((base, *acc)) - }), - (Some((_, pos, _)), _) => { - let v = v.unwrap_or(0.0); - let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; - pos.get_mut(j).map(|acc| { - let base = *acc; - *acc = clean(*acc + v); - (base, *acc) - }) - } - (None, _) => v.map(|v| (0.0, v)), - }; - row.push(span); - } - spans.push(row); - } - - // Axis scales. - let mut axes: Vec<(Scale, Fmt)> = Vec::new(); - for axis in [false, true] { - let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); - if members.is_empty() { - continue; - } - let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); - let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); - let range = value_range(members.iter().flat_map(|i| { - let line = fams.get(*i).is_some_and(|f| is_line(*f)); - spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) - })); - let (lo, hi) = range.unwrap_or((0.0, 0.0)); - let (scale, fmt) = if all100 { - (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) - } else { - let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); - (nice_scale(lo, hi, force_zero), Fmt::new(code)) - }; - axes.push((scale, fmt)); - if !axis && sec.iter().all(|s| !s) { - break; - } - } - let Some((s1, f1)) = axes.first() else { return }; - let y1 = ValAxis { scale: *s1, fmt: f1 }; - let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); - // A forward forecast on a category axis adds empty category slots after the data. - let fwd = data - .series - .iter() - .enumerate() - .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) - .map(|(_, s)| max_forward(s)) - .fold(0.0, f64::max); - let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); - let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); - // Outer levels padded to the slots (forecast slots stay in the last group). - let outer: Vec> = - data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); - if !plot.ok() { - return; - } - let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; - let cat_len = if horizontal { plot.h } else { plot.w }; - let cw = cat_len / ncat_slots as f32; - let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; - let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; - - // Column slots: each clustered series gets one, each stack group shares one. - let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); - let mut slot_of = vec![0usize; data.series.len()]; - for (i, f) in fams.iter().enumerate() { - if !is_col(*f) { - continue; - } - let a = sec.get(i).copied().unwrap_or(false); - let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; - let k = match slot_keys.iter().position(|k| *k == key) { - Some(p) => p, - None => { - slot_keys.push(key); - slot_keys.len() - 1 - } - }; - if let Some(s) = slot_of.get_mut(i) { - *s = k; - } - } - let nslots = slot_keys.len().max(1) as f32; - let bw = cw / (nslots + 1.5); - let labels_on = chart.data_labels && ncat <= MAX_LABELS; - // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. - let mut area_labels = Vec::new(); - - // Areas first, then columns, then lines on top. - for pass in 0..3 { - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - let want = match pass { - 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), - 1 => is_col(fam), - _ => is_line(fam), - }; - if !want { - continue; - } - let sc = scale_of(i); - let fmt = Fmt::new(&s.number_format); - let row = match spans.get(i) { - Some(r) => r, - None => continue, - }; - match pass { - 0 => { - let mut top = Vec::with_capacity(ncat * 2); - let mut base = Vec::with_capacity(ncat); - for (j, sp) in row.iter().enumerate() { - let (b, t) = sp.unwrap_or((0.0, 0.0)); - let c = center(j); - top.push([c, vpos(t, &sc)]); - base.push([c, vpos(b, &sc)]); - // Source values (not stack totals), just above the top edge like the line labels. - if labels_on - && sp.is_some() - && let Some(v) = s.values.get(j).copied().flatten() - { - area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); - } - } - if top.len() == 1 { - // A single point: give it some width. - if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { - top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; - base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; - } - } - let top = decimate(top, MAX_LINE_PTS * 2); - let base = decimate(base, MAX_LINE_PTS * 2); - let mut poly = top; - poly.extend(base.into_iter().rev()); - if poly.len() >= 3 { - ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); - } - } - 1 => { - let k = slot_of.get(i).copied().unwrap_or(0) as f32; - for (j, sp) in row.iter().enumerate() { - let Some((b, t)) = sp else { continue }; - let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); - let c = center(j); - let lo = pb.min(pt); - let len = (pb - pt).abs(); - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; - ctx.rect(lo, y, len, bw, s.color, None); - } else { - let x = c - nslots * bw / 2.0 + k * bw; - ctx.rect(x, lo, bw, len, s.color, None); - } - if labels_on && let Some(v) = s.values.get(j).copied().flatten() { - let t = fmt.format(v, None); - let stacked = fam != Fam::Col; - let mid = (pb + pt) / 2.0; - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; - if stacked { - ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); - } else { - ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } else { - let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; - if stacked { - ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } else { - ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); - } - } - } - } - } - _ => { - let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); - draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); - let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); - if k == ChartKind::LineMarkers && cw >= 3.0 { - draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); - } - if labels_on { - for (j, p) in pts.iter().enumerate() { - if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { - ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - } - } - for (x, y, t) in area_labels.drain(..) { - ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - // Trendlines on top: x is the category number 1..n. - let mut trend_labels = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { - continue; - } - let sc = scale_of(i); - let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); - let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); - let px = |(fx, fy): (f64, f64)| { - if horizontal { - [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] - } else { - [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] - } - }; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- scatter & bubble - -fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let pts_of = |s: &SeriesData| -> Vec> { - s.values - .iter() - .enumerate() - .map(|(j, v)| { - let x = match &s.x { - Some(xs) => xs.get(j).copied().flatten(), - None => Some((j + 1) as f64), - }; - match (x, v) { - (Some(x), Some(y)) => Some((x, *y)), - _ => None, - } - }) - .collect() - }; - let all: Vec>> = data.series.iter().map(pts_of).collect(); - let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; - // Forecasts extend the x axis to the trendline's ends. - for (s, pts) in data.series.iter().zip(&all) { - if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { - xlo = xlo.min(lo - max_backward(s)); - xhi = xhi.max(hi + max_forward(s)); - } - } - let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; - let xs = nice_scale(xlo, xhi, false); - let ys = nice_scale(ylo, yhi, false); - let fx = Fmt::new("General"); - let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; - let bubble = chart.kind == ChartKind::Bubble; - let rmax = plot.w.min(plot.h) * 0.12; - for (s, pts) in data.series.iter().zip(&all) { - let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); - if bubble { - let sizes = s.sizes.as_deref().unwrap_or(&[]); - let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); - let mut bubbles: Vec<([f32; 2], f64)> = px - .iter() - .enumerate() - .filter_map(|(j, p)| { - let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; - match (p, sz) { - (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), - _ => None, - } - }) - .take(MAX_MARKERS) - .collect(); - bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); - let smax = if sizes.is_empty() { 1.0 } else { smax }; - let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; - for (p, sz) in bubbles { - let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); - } - } else { - let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; - if lines { - draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); - } - if !lines || px.len() <= 500 { - draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); - } - } - if chart.data_labels && pts.len() <= MAX_LABELS { - for (p, raw) in px.iter().zip(pts) { - if let (Some(p), Some((_, y))) = (p, raw) { - ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - let mut trend_labels = Vec::new(); - for (s, pts) in data.series.iter().zip(&all) { - if s.trendlines.is_empty() { - continue; - } - let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); - let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); - let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- histogram - -fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().flatten().copied().collect(); - let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; - let n = vals.len(); - let span = hi - lo; - let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); - if !(span.is_finite() && span > 0.0) { - bins = 1; - } - let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; - // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value - // equal to an edge belongs to the bin that ends there. - let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); - let mut counts = vec![0usize; bins]; - for v in &vals { - let b = edges.partition_point(|e| e < v).min(bins - 1); - if let Some(c) = counts.get_mut(b) { - *c += 1; - } - } - let f = Fmt::new(&s.number_format); - let labels: Vec = (0..bins) - .map(|b| { - let a = lo + b as f64 * width; - let e = edges.get(b).copied().unwrap_or(a); - let open = if b == 0 { '[' } else { '(' }; - format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) - }) - .collect(); - let maxc = counts.iter().copied().max().unwrap_or(0) as f64; - let ys = nice_scale(0.0, maxc, true); - let fc = Fmt::new("General"); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / bins as f32; - for (b, c) in counts.iter().enumerate() { - let top = plot.bottom() - ys.frac(*c as f64) * plot.h; - let x = plot.x + b as f32 * cw; - ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); - if chart.data_labels { - ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- waterfall - -fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); - if ncat == 0 { - return; - } - let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); - let mut is_total = vec![false; ncat]; - let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); - if let Some(t) = is_total.last_mut() { - *t = last_total; - } - if !last_total { - labels.push("Total".into()); - vals.push(0.0); - is_total.push(true); - ncat += 1; - } - let mut bars = Vec::with_capacity(ncat); - let mut cum = 0.0f64; - for (j, v) in vals.iter().enumerate() { - if is_total.get(j).copied().unwrap_or(false) { - let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; - bars.push((0.0, total, 0u8)); - cum = total; - } else { - let top = clean(cum + v); - bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); - cum = top; - } - } - let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); - let ys = nice_scale(lo, hi, true); - let f = Fmt::new(&s.number_format); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / ncat as f32; - let bw = cw / 1.5; - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (j, (b, t, kind)) in bars.iter().enumerate() { - let color = match kind { - 1 => UP, - 2 => DOWN, - _ => s.color, - }; - let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; - let (yb, yt) = (vy(*b), vy(*t)); - ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); - if j + 1 < bars.len() && ncat <= 500 { - ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); - } - if chart.data_labels && ncat <= MAX_LABELS { - let v = if *kind == 0 { *t } else { t - b }; - ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- funnel - -fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let n = data.categories.len().max(s.values.len()).min(MAX_CATS); - if n == 0 { - return; - } - let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); - let max = vals.iter().copied().fold(0.0f64, f64::max); - if max.is_nan() || max <= 0.0 { - return; - } - let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); - let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; - if !plot.ok() { - return; - } - let slot = plot.h / n as f32; - let bh = slot * 0.85; - let f = Fmt::new(&s.number_format); - let show = slot >= LABEL_SIZE * 0.9; - for (j, v) in vals.iter().enumerate() { - let bw = (*v / max) as f32 * plot.w; - let x = plot.x + (plot.w - bw) / 2.0; - let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; - if bw > 0.0 { - ctx.rect(x, y, bw, bh, s.color, None); - } - if show { - let yc = y + bh / 2.0; - if chart.data_labels { - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); - } - let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); - ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } -} - -// ---------------------------------------------------------------- pie, doughnut, sunburst - -fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if !(total.is_finite() && total > 0.0) { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let r = area.w.min(area.h) / 2.0 * 0.92; - if r < 2.0 { - return; - } - let ri = r * inner; - let f = Fmt::new(&s.number_format); - let mut a = 0.0f32; - let mut labels = Vec::new(); - for (j, v) in vals.iter().enumerate() { - let sweep = (*v / total) as f32 * TAU; - if sweep > 1e-4 { - let fill = point_color(data, j); - ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); - if (chart.data_labels || names) && vals.len() <= MAX_LABELS { - let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; - let mid = a + sweep / 2.0; - let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; - let room = sweep * rr; - if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { - labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); - } - } - } - a += sweep; - } - for (x, y, t, c) in labels { - ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); - } -} - -// ---------------------------------------------------------------- treemap - -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); - if s <= 0.0 || side <= 0.0 { - return f64::INFINITY; - } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); - let s2 = s * s; - let w2 = side * side; - (w2 * mx / s2).max(s2 / (w2 * mn)) -} - -/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. -fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { - let total: f64 = items.iter().map(|i| i.1).sum(); - let area = (r.w as f64) * (r.h as f64); - if !(total > 0.0 && area > 0.0) { - return vec![]; - } - let scale = area / total; - let mut out = Vec::with_capacity(items.len()); - let mut rem = r; - let mut row: Vec<(usize, f64)> = Vec::new(); - let mut i = 0; - let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { - let s: f64 = row.iter().map(|x| x.1).sum(); - if s <= 0.0 { - return; - } - if rem.w >= rem.h { - let cw = (s / rem.h.max(1e-6) as f64) as f32; - let mut y = rem.y; - for (j, a) in row { - let h = (*a / cw.max(1e-6) as f64) as f32; - out.push((*j, Bx { x: rem.x, y, w: cw, h })); - y += h; - } - rem.x += cw; - rem.w = (rem.w - cw).max(0.0); - } else { - let rh = (s / rem.w.max(1e-6) as f64) as f32; - let mut x = rem.x; - for (j, a) in row { - let w = (*a / rh.max(1e-6) as f64) as f32; - out.push((*j, Bx { x, y: rem.y, w, h: rh })); - x += w; - } - rem.y += rh; - rem.h = (rem.h - rh).max(0.0); - } - }; - while let Some(&(j, v)) = items.get(i) { - let a = v * scale; - let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { - row.push((j, a)); - i += 1; - } else { - layout(&row, &mut rem, &mut out); - row.clear(); - } - } - if !row.is_empty() { - layout(&row, &mut rem, &mut out); - } - out -} - -fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut items: Vec<(usize, f64)> = - s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); - items.sort_by(|a, b| b.1.total_cmp(&a.1)); - let f = Fmt::new(&s.number_format); - for (j, b) in squarify(&items, area) { - let fill = point_color(data, j); - ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); - if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { - let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); - let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - } - } - } -} - -// ---------------------------------------------------------------- radar - -fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); - let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); - if r < 5.0 { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); - let sc = nice_scale(lo, hi, true); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let ang = |j: usize| j as f32 / ncat as f32 * TAU; - let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; - let ticks = sc.ticks(); - let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; - let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); - for (ti, t) in ticks.into_iter().enumerate() { - let rr = sc.frac(t) * r; - if rr > 0.5 && chart.gridlines { - let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); - if ncat < 3 { - ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); - } else if let Some(p) = ring.first().copied() { - ring.push(p); - } - ctx.line(ring, GRID, 0.75); - } - if ti % every == 0 { - ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); - } - } - let step = ncat.div_ceil(72).max(1); - for j in (0..ncat).step_by(step) { - let a = ang(j); - ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); - let p = pt(a, r + 6.0); - let (sa, ca) = (a.sin(), a.cos()); - let align = if sa > 0.1 { - HAlign::Left - } else if sa < -0.1 { - HAlign::Right - } else { - HAlign::Center - }; - let valign = if ca > 0.1 { - VAlign::Bottom - } else if ca < -0.1 { - VAlign::Top - } else { - VAlign::Middle - }; - let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); - ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); - } - for s in &data.series { - let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); - if let Some(p) = pts.first().copied() { - pts.push(p); - } - ctx.line(pts, s.color, 2.0); - } -} - -// ---------------------------------------------------------------- box & whisker - -fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { - let n = sorted.len(); - if n == 0 { - return 0.0; - } - // Clamp exclusive ranks for short samples so one or two points remain drawable. - let pos = match method { - QuartileMethod::Inclusive => q * (n - 1) as f64, - QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), - }; - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = sorted.get(i).copied().unwrap_or(0.0); - let b = sorted.get(i + 1).copied().unwrap_or(a); - a + (b - a) * frac -} - -fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let n = data.series.len().max(1); - let cw = plot.w / n as f32; - let bw = (cw * 0.4).min(80.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (i, s) in data.series.iter().enumerate() { - let mut v: Vec = s.values.iter().flatten().copied().collect(); - if v.is_empty() { - continue; - } - v.sort_by(f64::total_cmp); - let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); - let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); - let iqr = q3 - q1; - let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); - let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); - let mean = v.iter().sum::() / v.len() as f64; - let c = plot.x + (i as f32 + 0.5) * cw; - ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); - ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); - let (yt, yb) = (vy(q3), vy(q1)); - ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); - ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); - let (mx, my, d) = (c, vy(mean), 3.0); - ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); - ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); - for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { - ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); - } - } -} - -// ---------------------------------------------------------------- stock - -fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let ser = |k: usize| data.series.get(k); - let (open, high, low, close) = match data.series.len() { - 0 => return, - 1 => (None, None, None, ser(0)), - 2 => (None, ser(0), ser(1), None), - 3 => (None, ser(0), ser(1), ser(2)), - _ => (ser(0), ser(1), ser(2), ser(3)), - }; - let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); - let cw = plot.w / ncat as f32; - let tick = (cw * 0.25).max(1.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for j in 0..ncat { - let c = plot.x + (j as f32 + 0.5) * cw; - if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { - ctx.vline(c, vy(h), vy(l), STOCK, 1.25); - } - match (get(open, j), get(close, j)) { - (Some(o), Some(cl)) => { - let (yo, yc) = (vy(o), vy(cl)); - let bw = (cw * 0.5).max(1.0); - let fill = if cl >= o { WHITE } else { STOCK }; - ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); - } - (None, Some(cl)) => { - if high.is_some() { - ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); - } else { - ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); - } - } - _ => {} - } - } -} - // ---------------------------------------------------------------- final safety pass fn fin(v: f32) -> bool { @@ -1829,60 +251,3 @@ pub(crate) fn finalize(prims: Vec, w: f32, h: f32) -> Vec { }) .collect() } - -#[cfg(test)] -mod smooth_tests { - use super::*; - - #[test] - fn long_runs_are_smoothed_uniformly_within_the_cap() { - let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; - let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); - let out = smooth_run(pts, plot); - assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); - // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. - assert_eq!(out.len(), 1499 * 2 + 1); - assert_eq!(out.last().copied(), Some([2998.0, 80.0])); - } - - #[test] - fn spline_overshoot_is_clamped_to_the_plot() { - let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; - // A step right at the plot edges: Catmull-Rom overshoots past both. - let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; - let out = smooth_run(pts, plot); - assert!(out.len() > 5); - assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); - } -} - -#[cfg(test)] -mod quartile_tests { - use super::*; - - #[test] - fn box_whisker_default_quartiles_are_exclusive() { - let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; - assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); - assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); - assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); - } - - #[test] - fn quartile_methods_and_short_samples() { - for (values, exclusive, inclusive) in [ - (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), - (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), - (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), - (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), - (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), - (vec![1., 2.], (1., 2.), (1.25, 1.75)), - (vec![1.], (1., 1.), (1., 1.)), - (vec![], (0., 0.), (0., 0.)), - ] { - for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { - assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); - } - } - } -} diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs new file mode 100644 index 000000000..d449498e7 --- /dev/null +++ b/crates/chart/src/render/cartesian.rs @@ -0,0 +1,452 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, plot_kind}; +use super::lines::{decimate, draw_markers, draw_runs}; +use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; +use super::{ + Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, + VAlign, WHITE, clean, fixed_scale, nice_scale, +}; + +// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fam { + Col, + ColStack, + Col100, + Line, + LineStack, + Area, + AreaStack, + Area100, +} + +/// Stack accumulator: (axis, family) key with positive and negative running sums. +type Stack = ((bool, Fam), Vec, Vec); + +fn family(k: ChartKind) -> Fam { + use ChartKind::*; + match k { + ColumnStacked | BarStacked => Fam::ColStack, + ColumnStacked100 | BarStacked100 => Fam::Col100, + Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, + LineStacked => Fam::LineStack, + Area => Fam::Area, + AreaStacked => Fam::AreaStack, + AreaStacked100 => Fam::Area100, + _ => Fam::Col, + } +} + +/// Families stacked to 100%: each category's values are shares of its total. +fn is_100(f: Fam) -> bool { + matches!(f, Fam::Col100 | Fam::Area100) +} + +fn is_col(f: Fam) -> bool { + matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) +} + +fn is_line(f: Fam) -> bool { + matches!(f, Fam::Line | Fam::LineStack) +} + +fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec>> { + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + if is_100(fam) { + let key = (sec.get(i).copied().unwrap_or(false), fam); + let p = match totals.iter().position(|(k, _)| *k == key) { + Some(p) => p, + None => { + totals.push((key, vec![0.0; ncat])); + totals.len() - 1 + } + }; + for (j, v) in s.values.iter().take(ncat).enumerate() { + if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { + *t += v.abs(); + } + } + } + } + // A value's share of its category's total, for the 100% families. + let share = |key: (bool, Fam), j: usize, v: f64| { + let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + }; + // Stack accumulators keyed by (axis, family): positive and negative running sums. + let mut stacks: Vec = Vec::new(); + let mut spans: Vec>> = Vec::with_capacity(data.series.len()); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let a = sec.get(i).copied().unwrap_or(false); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); + let key = (a, fam); + let si = if stacked { + match stacks.iter().position(|(k, _, _)| *k == key) { + Some(p) => Some(p), + None => { + stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); + Some(stacks.len() - 1) + } + } + } else { + None + }; + let mut row = Vec::with_capacity(ncat); + for j in 0..ncat { + let v = s.values.get(j).copied().flatten(); + let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { + (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { + let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; + let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; + let base = *acc; + *acc += v; + Some((base, *acc)) + }), + (Some((_, pos, _)), _) => { + let v = v.unwrap_or(0.0); + let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; + pos.get_mut(j).map(|acc| { + let base = *acc; + *acc = clean(*acc + v); + (base, *acc) + }) + } + (None, _) => v.map(|v| (0.0, v)), + }; + row.push(span); + } + spans.push(row); + } + + spans +} + +fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec>]) -> Vec<(Scale, Fmt)> { + // Axis scales. + let mut axes: Vec<(Scale, Fmt)> = Vec::new(); + for axis in [false, true] { + let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); + if members.is_empty() { + continue; + } + let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); + let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); + let range = value_range(members.iter().flat_map(|i| { + let line = fams.get(*i).is_some_and(|f| is_line(*f)); + spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) + })); + let (lo, hi) = range.unwrap_or((0.0, 0.0)); + let (scale, fmt) = if all100 { + (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + } else { + let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); + (nice_scale(lo, hi, force_zero), Fmt::new(code)) + }; + axes.push((scale, fmt)); + if !axis && sec.iter().all(|s| !s) { + break; + } + } + axes +} + +fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec, f32) { + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; fams.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + (slot_of, slot_keys.len().max(1) as f32) +} + +pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + let spans = series_spans(data, &fams, &sec, ncat); + + let axes = axis_scales(data, &fams, &sec, &spans); + let Some((s1, f1)) = axes.first() else { return }; + let y1 = ValAxis { scale: *s1, fmt: f1 }; + let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); + // A forward forecast on a category axis adds empty category slots after the data. + let fwd = data + .series + .iter() + .enumerate() + .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) + .map(|(_, s)| max_forward(s)) + .fold(0.0, f64::max); + let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); + let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); + // Outer levels padded to the slots (forecast slots stay in the last group). + let outer: Vec> = + data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); + if !plot.ok() { + return; + } + let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; + let cat_len = if horizontal { plot.h } else { plot.w }; + let cw = cat_len / ncat_slots as f32; + let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; + let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; + + let (slot_of, nslots) = column_slots(&fams, &sec); + let bw = cw / (nslots + 1.5); + let labels_on = chart.data_labels && ncat <= MAX_LABELS; + // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. + let mut area_labels = Vec::new(); + + // Areas first, then columns, then lines on top. + for pass in 0..3 { + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let want = match pass { + 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), + 1 => is_col(fam), + _ => is_line(fam), + }; + if !want { + continue; + } + let sc = scale_of(i); + let fmt = Fmt::new(&s.number_format); + let row = match spans.get(i) { + Some(r) => r, + None => continue, + }; + match pass { + 0 => { + let mut top = Vec::with_capacity(ncat * 2); + let mut base = Vec::with_capacity(ncat); + for (j, sp) in row.iter().enumerate() { + let (b, t) = sp.unwrap_or((0.0, 0.0)); + let c = center(j); + top.push([c, vpos(t, &sc)]); + base.push([c, vpos(b, &sc)]); + // Source values (not stack totals), just above the top edge like the line labels. + if labels_on + && sp.is_some() + && let Some(v) = s.values.get(j).copied().flatten() + { + area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); + } + } + if top.len() == 1 { + // A single point: give it some width. + if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { + top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; + base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; + } + } + let top = decimate(top, MAX_LINE_PTS * 2); + let base = decimate(base, MAX_LINE_PTS * 2); + let mut poly = top; + poly.extend(base.into_iter().rev()); + if poly.len() >= 3 { + ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); + } + } + 1 => { + let k = slot_of.get(i).copied().unwrap_or(0) as f32; + for (j, sp) in row.iter().enumerate() { + let Some((b, t)) = sp else { continue }; + let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); + let c = center(j); + let lo = pb.min(pt); + let len = (pb - pt).abs(); + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; + ctx.rect(lo, y, len, bw, s.color, None); + } else { + let x = c - nslots * bw / 2.0 + k * bw; + ctx.rect(x, lo, bw, len, s.color, None); + } + if labels_on && let Some(v) = s.values.get(j).copied().flatten() { + let t = fmt.format(v, None); + let stacked = fam != Fam::Col; + let mid = (pb + pt) / 2.0; + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; + if stacked { + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle)); + } else if v >= 0.0 { + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle)); + } else { + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle)); + } + } else { + let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; + if stacked { + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle)); + } else if v >= 0.0 { + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } else { + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top)); + } + } + } + } + } + _ => { + let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); + draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); + let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); + if k == ChartKind::LineMarkers && cw >= 3.0 { + draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); + } + if labels_on { + for (j, p) in pts.iter().enumerate() { + if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } + } + } + } + } + } + for (x, y, t) in area_labels.drain(..) { + ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } + } + // Trendlines on top: x is the category number 1..n. + let mut trend_labels = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { + continue; + } + let sc = scale_of(i); + let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); + let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); + let px = |(fx, fy): (f64, f64)| { + if horizontal { + [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] + } else { + [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] + } + }; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} + +// ---------------------------------------------------------------- scatter & bubble + +pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let pts_of = |s: &SeriesData| -> Vec> { + s.values + .iter() + .enumerate() + .map(|(j, v)| { + let x = match &s.x { + Some(xs) => xs.get(j).copied().flatten(), + None => Some((j + 1) as f64), + }; + match (x, v) { + (Some(x), Some(y)) => Some((x, *y)), + _ => None, + } + }) + .collect() + }; + let all: Vec>> = data.series.iter().map(pts_of).collect(); + let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + // Forecasts extend the x axis to the trendline's ends. + for (s, pts) in data.series.iter().zip(&all) { + if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { + xlo = xlo.min(lo - max_backward(s)); + xhi = xhi.max(hi + max_forward(s)); + } + } + let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; + let xs = nice_scale(xlo, xhi, false); + let ys = nice_scale(ylo, yhi, false); + let fx = Fmt::new("General"); + let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; + let bubble = chart.kind == ChartKind::Bubble; + let rmax = plot.w.min(plot.h) * 0.12; + for (s, pts) in data.series.iter().zip(&all) { + let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); + if bubble { + let sizes = s.sizes.as_deref().unwrap_or(&[]); + let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); + let mut bubbles: Vec<([f32; 2], f64)> = px + .iter() + .enumerate() + .filter_map(|(j, p)| { + let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; + match (p, sz) { + (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), + _ => None, + } + }) + .take(MAX_MARKERS) + .collect(); + bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); + let smax = if sizes.is_empty() { 1.0 } else { smax }; + let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; + for (p, sz) in bubbles { + let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); + } + } else { + let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; + if lines { + draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); + } + if !lines || px.len() <= 500 { + draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); + } + } + if chart.data_labels && pts.len() <= MAX_LABELS { + for (p, raw) in px.iter().zip(pts) { + if let (Some(p), Some((_, y))) = (p, raw) { + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } + } + } + } + let mut trend_labels = Vec::new(); + for (s, pts) in data.series.iter().zip(&all) { + if s.trendlines.is_empty() { + continue; + } + let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); + let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); + let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs new file mode 100644 index 000000000..0117423b3 --- /dev/null +++ b/crates/chart/src/render/frame.rs @@ -0,0 +1,245 @@ +use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE}; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Bx { + pub(super) x: f32, + pub(super) y: f32, + pub(super) w: f32, + pub(super) h: f32, +} + +impl Bx { + pub(super) fn right(&self) -> f32 { + self.x + self.w + } + pub(super) fn bottom(&self) -> f32 { + self.y + self.h + } + pub(super) fn ok(&self) -> bool { + self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 + } + pub(super) fn cx(&self) -> f32 { + self.x + self.w / 2.0 + } + pub(super) fn cy(&self) -> f32 { + self.y + self.h / 2.0 + } +} + +/// Text colour readable on `fill`. +pub(super) fn on(fill: Rgba) -> Rgba { + let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; + if l < 150.0 { WHITE } else { DARK_TEXT } +} + +pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option { + match &chart.title { + Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), + None => None, + } +} + +pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { + let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; + if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + area.h -= lh; + } + if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); + if !t.is_empty() { + ctx.out.push(Prim::Text { + x: area.x, + y: area.cy(), + text: t, + size: AXIS_TITLE_SIZE, + color: TEXT, + bold: false, + align: HAlign::Center, + valign: VAlign::Top, + rotation: -PI / 2.0, + }); + } + area.x += lh; + area.w -= lh; + } + area +} + +// ---------------------------------------------------------------- axes frame + +pub(super) enum CatAxis<'a> { + /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; + /// one entry per category, non-empty where a group starts). + Labels(&'a [String], &'a [Vec]), + Numeric(Scale, &'a Fmt), +} + +/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every +/// non-empty entry (and at the first category). +fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { + let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); + starts.push(n); + starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + +pub(super) struct ValAxis<'a> { + pub(super) scale: Scale, + pub(super) fmt: &'a Fmt, +} + +impl ValAxis<'_> { + fn labels(&self) -> Vec<(f64, String)> { + self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() + } +} + +/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. +pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { + let ls = LABEL_SIZE; + let lh = ls * 1.4; + let ylabels = y.labels(); + let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); + if !horizontal { + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let y2labels = y2.map(ValAxis::labels); + let right = match (&y2labels, &cat) { + (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), + (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), + _ => 4.0, + }; + let levels = match &cat { + CatAxis::Labels(_, o) => o.len() as f32, + CatAxis::Numeric(..) => 0.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; + if !plot.ok() { + return plot; + } + for (v, t) in &ylabels { + let yy = plot.bottom() - y.scale.frac(*v) * plot.h; + if grid { + ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); + } + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle)); + } + if let (Some(a), Some(l)) = (y2, &y2labels) { + for (v, t) in l { + let yy = plot.bottom() - a.scale.frac(*v) * plot.h; + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle)); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; + ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); + match cat { + CatAxis::Labels(l, outer) => { + let n = l.len().max(1); + let cw = plot.w / n as f32; + let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in l.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, cw * k as f32 - 2.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top)); + } + // Outer levels, one row each below the labels: a group's name centred under its + // categories, and a separator at each group boundary down through the rows. + for (i, level) in outer.iter().enumerate() { + let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); + for (first, end) in level_groups(level, n) { + let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, x1 - x0 - 2.0); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top)); + } + if first > 0 { + ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + CatAxis::Numeric(s, f) => { + let ticks = s.ticks(); + let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let slot = plot.w / ticks.len().max(1) as f32; + let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (v, t) in ticks.iter().zip(&labels).step_by(k) { + let xx = plot.x + s.frac(*v) * plot.w; + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top)); + } + if s.min <= 0.0 && s.max >= 0.0 { + let xx = plot.x + s.frac(0.0) * plot.w; + ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); + } + } + } + plot + } else { + let (labels, outer): (Vec, &[Vec]) = match cat { + CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Numeric(..) => (vec![], &[]), + }; + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + // Outer levels stand in columns left of the labels, each as wide as its longest name. + let level_w: Vec = + outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); + let levels_w: f32 = level_w.iter().sum(); + let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; + let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; + if !plot.ok() { + return plot; + } + let vw = maxw(ctx, &ylabels); + let slot = plot.w / ylabels.len().max(1) as f32; + let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (i, (v, t)) in ylabels.iter().enumerate() { + let xx = plot.x + y.scale.frac(*v) * plot.w; + if grid { + ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); + } + if i % k == 0 { + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top)); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let x0 = plot.x + y.scale.frac(zero) * plot.w; + ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); + let n = labels.len().max(1); + let ch = plot.h / n as f32; + let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in labels.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, lw - 6.0); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle)); + } + // Outer levels: a group's name centred beside its categories, separators at the + // boundaries running right to the axis. + let mut x1 = plot.x - lw; + for (level, w) in outer.iter().zip(&level_w) { + let x0 = x1 - w; + for (first, end) in level_groups(level, n) { + let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, w - 4.0); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle)); + } + if first > 0 { + ctx.hline(x0, plot.x, y1, AXIS, 0.75); + } + } + ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); + x1 = x0; + } + plot + } +} + +pub(super) fn value_range(it: impl Iterator) -> Option<(f64, f64)> { + it.fold(None, |acc, v| match acc { + None => Some((v, v)), + Some((a, b)) => Some((a.min(v), b.max(v))), + }) +} diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs new file mode 100644 index 000000000..54ce6bec8 --- /dev/null +++ b/crates/chart/src/render/kinds.rs @@ -0,0 +1,508 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, point_color}; +use super::{ + AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData, + TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, +}; + +// ---------------------------------------------------------------- histogram + +pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().flatten().copied().collect(); + let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; + let n = vals.len(); + let span = hi - lo; + let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); + if !(span.is_finite() && span > 0.0) { + bins = 1; + } + let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value + // equal to an edge belongs to the bin that ends there. + let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); + let mut counts = vec![0usize; bins]; + for v in &vals { + let b = edges.partition_point(|e| e < v).min(bins - 1); + if let Some(c) = counts.get_mut(b) { + *c += 1; + } + } + let f = Fmt::new(&s.number_format); + let labels: Vec = (0..bins) + .map(|b| { + let a = lo + b as f64 * width; + let e = edges.get(b).copied().unwrap_or(a); + let open = if b == 0 { '[' } else { '(' }; + format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) + }) + .collect(); + let maxc = counts.iter().copied().max().unwrap_or(0) as f64; + let ys = nice_scale(0.0, maxc, true); + let fc = Fmt::new("General"); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / bins as f32; + for (b, c) in counts.iter().enumerate() { + let top = plot.bottom() - ys.frac(*c as f64) * plot.h; + let x = plot.x + b as f32 * cw; + ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); + if chart.data_labels { + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } + } +} + +// ---------------------------------------------------------------- waterfall + +pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); + if ncat == 0 { + return; + } + let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); + let mut is_total = vec![false; ncat]; + let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); + if let Some(t) = is_total.last_mut() { + *t = last_total; + } + if !last_total { + labels.push("Total".into()); + vals.push(0.0); + is_total.push(true); + ncat += 1; + } + let mut bars = Vec::with_capacity(ncat); + let mut cum = 0.0f64; + for (j, v) in vals.iter().enumerate() { + if is_total.get(j).copied().unwrap_or(false) { + let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; + bars.push((0.0, total, 0u8)); + cum = total; + } else { + let top = clean(cum + v); + bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); + cum = top; + } + } + let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); + let ys = nice_scale(lo, hi, true); + let f = Fmt::new(&s.number_format); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / ncat as f32; + let bw = cw / 1.5; + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (j, (b, t, kind)) in bars.iter().enumerate() { + let color = match kind { + 1 => UP, + 2 => DOWN, + _ => s.color, + }; + let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; + let (yb, yt) = (vy(*b), vy(*t)); + ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); + if j + 1 < bars.len() && ncat <= 500 { + ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); + } + if chart.data_labels && ncat <= MAX_LABELS { + let v = if *kind == 0 { *t } else { t - b }; + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); + } + } +} + +// ---------------------------------------------------------------- funnel + +pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let n = data.categories.len().max(s.values.len()).min(MAX_CATS); + if n == 0 { + return; + } + let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); + let max = vals.iter().copied().fold(0.0f64, f64::max); + if max.is_nan() || max <= 0.0 { + return; + } + let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); + let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; + if !plot.ok() { + return; + } + let slot = plot.h / n as f32; + let bh = slot * 0.85; + let f = Fmt::new(&s.number_format); + let show = slot >= LABEL_SIZE * 0.9; + for (j, v) in vals.iter().enumerate() { + let bw = (*v / max) as f32 * plot.w; + let x = plot.x + (plot.w - bw) / 2.0; + let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; + if bw > 0.0 { + ctx.rect(x, y, bw, bh, s.color, None); + } + if show { + let yc = y + bh / 2.0; + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle)); + } + let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle)); + } + } +} + +// ---------------------------------------------------------------- pie, doughnut, sunburst + +pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let ri = r * inner; + let f = Fmt::new(&s.number_format); + let mut a = 0.0f32; + let mut labels = Vec::new(); + for (j, v) in vals.iter().enumerate() { + let sweep = (*v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + if (chart.data_labels || names) && vals.len() <= MAX_LABELS { + let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; + let mid = a + sweep / 2.0; + let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; + let room = sweep * rr; + if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle)); + } +} + +// ---------------------------------------------------------------- treemap + +fn worst(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut items: Vec<(usize, f64)> = + s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); + items.sort_by(|a, b| b.1.total_cmp(&a.1)); + let f = Fmt::new(&s.number_format); + for (j, b) in squarify(&items, area) { + let fill = point_color(data, j); + ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); + if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { + let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); + if chart.data_labels && b.h > LABEL_SIZE * 3.2 { + let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); + } + } + } +} + +// ---------------------------------------------------------------- radar + +pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); + let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); + if r < 5.0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); + let sc = nice_scale(lo, hi, true); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let ang = |j: usize| j as f32 / ncat as f32 * TAU; + let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; + let ticks = sc.ticks(); + let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; + let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); + for (ti, t) in ticks.into_iter().enumerate() { + let rr = sc.frac(t) * r; + if rr > 0.5 && chart.gridlines { + let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); + if ncat < 3 { + ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); + } else if let Some(p) = ring.first().copied() { + ring.push(p); + } + ctx.line(ring, GRID, 0.75); + } + if ti % every == 0 { + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle)); + } + } + let step = ncat.div_ceil(72).max(1); + for j in (0..ncat).step_by(step) { + let a = ang(j); + ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); + let p = pt(a, r + 6.0); + let (sa, ca) = (a.sin(), a.cos()); + let align = if sa > 0.1 { + HAlign::Left + } else if sa < -0.1 { + HAlign::Right + } else { + HAlign::Center + }; + let valign = if ca > 0.1 { + VAlign::Bottom + } else if ca < -0.1 { + VAlign::Top + } else { + VAlign::Middle + }; + let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign)); + } + for s in &data.series { + let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); + if let Some(p) = pts.first().copied() { + pts.push(p); + } + ctx.line(pts, s.color, 2.0); + } +} + +// ---------------------------------------------------------------- box & whisker + +fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { + let n = sorted.len(); + if n == 0 { + return 0.0; + } + // Clamp exclusive ranks for short samples so one or two points remain drawable. + let pos = match method { + QuartileMethod::Inclusive => q * (n - 1) as f64, + QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), + }; + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = sorted.get(i).copied().unwrap_or(0.0); + let b = sorted.get(i + 1).copied().unwrap_or(a); + a + (b - a) * frac +} + +pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let n = data.series.len().max(1); + let cw = plot.w / n as f32; + let bw = (cw * 0.4).min(80.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (i, s) in data.series.iter().enumerate() { + let mut v: Vec = s.values.iter().flatten().copied().collect(); + if v.is_empty() { + continue; + } + v.sort_by(f64::total_cmp); + let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); + let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); + let iqr = q3 - q1; + let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); + let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); + let mean = v.iter().sum::() / v.len() as f64; + let c = plot.x + (i as f32 + 0.5) * cw; + ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); + ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); + let (yt, yb) = (vy(q3), vy(q1)); + ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); + ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); + let (mx, my, d) = (c, vy(mean), 3.0); + ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); + ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); + for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { + ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); + } + } +} + +// ---------------------------------------------------------------- stock + +pub(super) fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let ser = |k: usize| data.series.get(k); + let (open, high, low, close) = match data.series.len() { + 0 => return, + 1 => (None, None, None, ser(0)), + 2 => (None, ser(0), ser(1), None), + 3 => (None, ser(0), ser(1), ser(2)), + _ => (ser(0), ser(1), ser(2), ser(3)), + }; + let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); + let cw = plot.w / ncat as f32; + let tick = (cw * 0.25).max(1.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for j in 0..ncat { + let c = plot.x + (j as f32 + 0.5) * cw; + if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { + ctx.vline(c, vy(h), vy(l), STOCK, 1.25); + } + match (get(open, j), get(close, j)) { + (Some(o), Some(cl)) => { + let (yo, yc) = (vy(o), vy(cl)); + let bw = (cw * 0.5).max(1.0); + let fill = if cl >= o { WHITE } else { STOCK }; + ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); + } + (None, Some(cl)) => { + if high.is_some() { + ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); + } else { + ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod quartile_tests { + use super::*; + + #[test] + fn box_whisker_default_quartiles_are_exclusive() { + let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); + assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); + assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); + } + + #[test] + fn quartile_methods_and_short_samples() { + for (values, exclusive, inclusive) in [ + (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), + (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), + (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), + (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), + (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), + (vec![1., 2.], (1., 2.), (1.25, 1.75)), + (vec![1.], (1., 1.), (1., 1.)), + (vec![], (0., 0.), (0., 0.)), + ] { + for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { + assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); + } + } + } +} diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs new file mode 100644 index 000000000..fec1e4549 --- /dev/null +++ b/crates/chart/src/render/legend.rs @@ -0,0 +1,165 @@ +use super::frame::Bx; +use super::trend::supports_trendline; +use super::{ + Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP, + VAlign, auto_name, series_color, +}; +use gridcraft_model::LegendPos; + +/// How a legend entry's swatch is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Swatch { + Box, + Line, + LineMarker, + Marker, +} + +/// The kind a series is drawn as (per-series kinds only apply to combo charts). +pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { + if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +} + +/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). +pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba { + match (j, data.series.first()) { + (0, Some(s)) => s.color, + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), + } +} + +fn first_len(data: &ChartData) -> usize { + data.series.first().map(|s| s.values.len()).unwrap_or(0) +} + +pub(super) fn cat_label(data: &ChartData, j: usize) -> String { + data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) +} + +/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. +pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { + if chart.legend == LegendPos::None { + return vec![]; + } + match chart.kind { + ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { + let n = first_len(data).min(MAX_CATS); + (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() + } + ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], + ChartKind::Waterfall => { + if data.series.is_empty() { + return vec![]; + } + let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); + vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] + } + _ => { + let series = data.series.iter().take(MAX_SERIES); + let mut out: Vec<(String, Rgba, Swatch)> = series + .clone() + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(); + // Trendlines follow the series, named "Linear (Sales)" unless given a name. + for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); + } + } + out + } + } +} + +// ---------------------------------------------------------------- legend + +fn swatch_w(sw: Swatch, s: f32) -> f32 { + if sw == Swatch::Box { s } else { s * 2.0 } +} + +fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { + match sw { + Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), + Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), + Swatch::LineMarker => { + ctx.hline(x, x + 2.0 * s, yc, color, 2.0); + ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); + } + Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), + } +} + +pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { + let size = LABEL_SIZE; + let row_h = size * 1.6; + let s = size * 0.8; + let gap = 4.0; + let spacing = size * 1.3; + let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); + match pos { + LegendPos::None => area, + LegendPos::Top | LegendPos::Bottom => { + let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); + let mut rows: Vec> = vec![vec![]]; + let mut cur = 0.0f32; + for (i, e) in entries.iter().enumerate() { + let iw = item_w(ctx, e).min(area.w); + let need = if cur > 0.0 { spacing + iw } else { iw }; + if cur > 0.0 && cur + need > area.w { + if rows.len() >= max_rows { + break; + } + rows.push(vec![]); + cur = 0.0; + } + cur += if cur > 0.0 { spacing + iw } else { iw }; + if let Some(r) = rows.last_mut() { + r.push((i, iw)); + } + } + let lh = rows.len() as f32 * row_h; + let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; + for (ri, row) in rows.iter().enumerate() { + let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; + let mut x = area.x + (area.w - total).max(0.0) / 2.0; + let yc = y0 + ri as f32 * row_h + row_h / 2.0; + for &(i, iw) in row { + if let Some(e) = entries.get(i) { + draw_swatch(ctx, e.2, x, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); + ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle)); + } + x += iw + spacing; + } + } + let used = lh + 4.0; + if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } + } + LegendPos::Left | LegendPos::Right => { + let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + let max_rows = ((area.h / row_h).floor() as usize).max(1); + let n = entries.len().min(max_rows); + let lh = n as f32 * row_h; + let y0 = area.y + (area.h - lh).max(0.0) / 2.0; + let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; + for (i, e) in entries.iter().take(n).enumerate() { + let yc = y0 + i as f32 * row_h + row_h / 2.0; + draw_swatch(ctx, e.2, x0, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); + ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle)); + } + let used = colw + 8.0; + if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } + } + } +} diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs new file mode 100644 index 000000000..ae0229cfb --- /dev/null +++ b/crates/chart/src/render/lines.rs @@ -0,0 +1,158 @@ +use super::frame::Bx; +use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE}; +use std::collections::HashSet; + +// ---------------------------------------------------------------- helpers + +/// Reduces a long polyline to first/min/max/last per pixel column. +pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + if pts.len() <= max { + return pts; + } + let mut out = Vec::with_capacity(max * 2); + let mut i = 0; + while let Some(p) = pts.get(i) { + let col = p[0].floor(); + let (start, mut mn, mut mx) = (i, i, i); + let mut k = i + 1; + while let Some(q) = pts.get(k) { + if q[0].floor() != col { + break; + } + if pts.get(mn).is_some_and(|m| q[1] < m[1]) { + mn = k; + } + if pts.get(mx).is_some_and(|m| q[1] > m[1]) { + mx = k; + } + k += 1; + } + let mut idx = [start, mn, mx, k - 1]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last + && let Some(q) = pts.get(ix) + { + out.push(*q); + last = ix; + } + } + i = k; + } + if out.len() > max * 4 { + let step = out.len() / (max * 2) + 1; + let last = out.last().copied(); + let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); + if let Some(l) = last + && s.last() != Some(&l) + { + s.push(l); + } + return s; + } + out +} + +/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), +/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. +pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { + let mut run: Vec<[f32; 2]> = Vec::new(); + for p in pts.iter().chain(std::iter::once(&None)) { + match p { + Some(p) => run.push(*p), + None => { + if run.len() >= 2 { + let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); + let r = match smooth { + Some(plot) => smooth_run(r, plot), + None => r, + }; + ctx.line(r, color, width); + } else { + run.clear(); + } + } + } + } +} + +/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to +/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are +/// duplicated so the curve passes through the first and last points, and the curve is clamped +/// to the `plot` box (a spline overshoots its data near sharp turns). +fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { + let n = pts.len(); + if n < 3 { + return pts; + } + let cap = MAX_LINE_PTS * 2; + let steps = (cap / n).clamp(1, 100); + let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); + let at = |i: isize| -> [f32; 2] { + let j = i.clamp(0, n as isize - 1) as usize; + pts.get(j).copied().unwrap_or([0.0, 0.0]) + }; + let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); + let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); + let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; + for i in 0..n.saturating_sub(1) { + let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); + for s in 0..steps { + let t = s as f32 / steps as f32; + out.push(clip(catmull(p0, p1, p2, p3, t))); + } + } + out.push(clip(at(n as isize - 1))); + out +} + +/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. +fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { + let t2 = t * t; + let t3 = t2 * t; + let mut o = [0.0f32; 2]; + for k in 0..2 { + let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); + o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); + } + o +} + +pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { + let mut seen = HashSet::new(); + for p in pts { + if seen.len() >= MAX_MARKERS { + break; + } + if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); + } + } +} + +#[cfg(test)] +mod smooth_tests { + use super::*; + + #[test] + fn long_runs_are_smoothed_uniformly_within_the_cap() { + let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; + let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); + let out = smooth_run(pts, plot); + assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); + // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. + assert_eq!(out.len(), 1499 * 2 + 1); + assert_eq!(out.last().copied(), Some([2998.0, 80.0])); + } + + #[test] + fn spline_overshoot_is_clamped_to_the_plot() { + let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; + // A step right at the plot edges: Catmull-Rom overshoots past both. + let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; + let out = smooth_run(pts, plot); + assert!(out.len() > 5); + assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); + } +} diff --git a/crates/chart/src/render/trend.rs b/crates/chart/src/render/trend.rs new file mode 100644 index 000000000..b642df262 --- /dev/null +++ b/crates/chart/src/render/trend.rs @@ -0,0 +1,170 @@ +use super::frame::{Bx, value_range}; +use super::lines::decimate; +use super::{ + ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign, + fit_trendline, moving_average, +}; + +// ---------------------------------------------------------------- trendlines + +/// Samples along a regression trendline. +const TREND_STEPS: usize = 200; + +/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, +/// bar, line, area, scatter and bubble charts). +pub fn supports_trendline(k: ChartKind) -> bool { + use ChartKind::*; + matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) +} + +/// A forecast distance as drawn: finite, non-negative and bounded. +fn forecast(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } +} + +/// Most empty category slots a forward forecast may add after `ncat` categories: four times the +/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every +/// column to nothing. +pub(super) fn max_forward_slots(ncat: usize) -> f64 { + (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 +} + +/// Longest forward forecast among `s`'s regression trendlines. +pub(super) fn max_forward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) +} + +/// Longest backward forecast among `s`'s regression trendlines. +pub(super) fn max_backward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) +} + +type ClippedSegment = ((f64, f64), (f64, f64), bool, bool); + +/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and +/// end were cut. +fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + if r > t1 { + return None; + } + t0 = t0.max(r); + } else { + if r < t0 { + return None; + } + t1 = t1.min(r); + } + } + } + Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) +} + +/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit +/// square), returning the visible runs. +fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { + const LIM: f64 = 1e9; + let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); + let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); + let mut runs: Vec> = Vec::new(); + let mut run: Vec<(f64, f64)> = Vec::new(); + for w in pts.windows(2) { + let seg = match w { + [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), + _ => None, + }; + match seg { + Some((p, q, cut_start, cut_end)) => { + if cut_start || run.is_empty() { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + run.push(p); + } + run.push(q); + if cut_end { + runs.push(std::mem::take(&mut run)); + } + } + None => { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + } + } + } + if run.len() >= 2 { + runs.push(run); + } + runs +} + +/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to +/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. +/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. +pub(super) fn draw_trendlines( + ctx: &mut Ctx, + s: &SeriesData, + pts: &[(f64, f64)], + frac: &dyn Fn(f64, f64) -> (f64, f64), + px: &dyn Fn((f64, f64)) -> [f32; 2], + labels: &mut Vec, + window: (f64, f64), +) { + let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + let spec = &t.spec; + let samples: Vec> = match spec.kind { + TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), + _ => { + let Some(fit) = fit_trendline(spec, pts) else { continue }; + // Sample only the part of the forecast that is on the plot. + let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); + if spec.display_equation { + labels.push(fit.equation()); + } + if spec.display_r_squared { + labels.push(fit.r_squared()); + } + (0..=TREND_STEPS) + .map(|i| { + let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; + fit.eval(x).map(|y| frac(x, y)) + }) + .collect() + } + }; + let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); + let dash = spec.dash != Some(LineDash::Solid); + for run in clip_unit(&samples) { + let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); + if line.len() >= 2 { + ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); + } + } + } +} + +/// Trendline equation / R² labels, stacked in the plot's top-right corner. +pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { + let lh = LABEL_SIZE * 1.3; + for (i, t) in labels.iter().enumerate() { + let y = plot.y + 4.0 + i as f32 * lh; + if y + lh > plot.bottom() { + break; + } + let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); + ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top)); + } +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index fd8ffec11..9d10ea87c 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -295,15 +295,16 @@ mod tests { use super::*; #[test] - fn range_parse_rejects_signed_rows() { + fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> { assert!(RangeRef::parse("+1:+3").is_none()); assert!(RangeRef::parse("-1:3").is_none()); assert!(RangeRef::parse("$$1:3").is_none()); assert!(RangeRef::parse(":3").is_none()); assert!(RangeRef::parse("$$A:$$C").is_none()); assert!(RangeRef::parse("$A:$C").is_some()); - let r = RangeRef::parse("$1:$3").unwrap(); + let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?; assert_eq!((r.start.row, r.end.row), (0, 2)); + Ok(()) } #[test] @@ -389,18 +390,20 @@ mod tests { } #[test] - fn ranges() { - let r = RangeRef::parse("C3:A1").unwrap(); + fn ranges() -> Result<(), std::io::Error> { + let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range")); + let r = parse("C3:A1")?; assert_eq!(r.a1(), "A1:C3"); assert_eq!(r.count(), 9); - assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C"); - assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5"); + assert_eq!(parse("A:C")?.a1(), "A:C"); + assert_eq!(parse("3:5")?.a1(), "3:5"); assert!(RangeRef::parse("0:5").is_none()); - let a = RangeRef::parse("A1:C3").unwrap(); - let b = RangeRef::parse("B2:D4").unwrap(); - assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3"); + let a = parse("A1:C3")?; + let b = parse("B2:D4")?; + assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3"); assert_eq!(a.union(&b).a1(), "A1:D4"); assert_eq!(a.iter().count(), 9); + Ok(()) } #[test] @@ -458,18 +461,19 @@ mod category_level_tests { use super::*; #[test] - fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() { - let r = |s: &str| RangeRef::parse(s).unwrap(); - assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 })); - assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped"); - assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list"); + fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> { + let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range")); + assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 })); + assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped"); + assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list"); // A row of values: levels are rows. - assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None); + assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None); // Literal values: the categories' own shape decides. - assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns"); + assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns"); + Ok(()) } } diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 8e68695f1..606d3dacf 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -47,13 +47,11 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. -/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`. -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap. pub fn unix_now_ms() -> Option { #[cfg(not(target_arch = "wasm32"))] { - let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?; - Some(d.as_secs_f64() * 1000.0) + Some(chrono::Utc::now().timestamp_millis() as f64) } #[cfg(target_arch = "wasm32")] { @@ -323,7 +321,7 @@ mod tests { } #[test] - fn fictitious_1900_02_29() { + fn fictitious_1900_02_29() -> Result<(), std::io::Error> { // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count // through it. let s = DateSystem::D1900; @@ -345,7 +343,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); // Every serial up to 1900-12-31 comes back from its own year, month and day. for serial in 0..=366 { - let d = datetime_from_serial(s, serial as f64).unwrap(); + let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); } assert_eq!(days_in_month_in(s, 1900, 2), 29); @@ -358,6 +356,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + Ok(()) } #[test] @@ -371,17 +370,18 @@ mod tests { } #[test] - fn roundtrip() { + fn roundtrip() -> Result<(), std::io::Error> { let s = DateSystem::D1900; for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { - let d = datetime_from_serial(s, serial).unwrap(); + let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } - let d = datetime_from_serial(s, 60.0).unwrap(); + let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?; assert_eq!((d.year, d.month, d.day), (1900, 2, 29)); - let d = datetime_from_serial(s, 46302.75).unwrap(); + let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?; assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday - let d = datetime_from_serial(s, 1.0).unwrap(); + let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?; assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar + Ok(()) } } diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index 459372d31..0000028e9 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -477,11 +477,12 @@ mod tests { } #[test] - fn arrays() { - let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap(); + fn arrays() -> Result<(), std::io::Error> { + let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?; assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0))); assert!(Array::new(2, 2, vec![]).is_none()); let row = Array::row(vec![1.0.into(), 2.0.into()]); assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0)); + Ok(()) } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e8ea91c26..1de94c60c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -17,6 +17,7 @@ gridcraft-model = { workspace = true } gridcraft-calc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +web-time = { workspace = true } thiserror = { workspace = true } log = { workspace = true } gridcraft-xlsx = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 007cbf4bc..45455e00b 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("view.unhideWindow", "View", "Window", "Unhide"), ("view.switchWindows", "View", "Window", "Switch Windows"), ("view.macros", "View", "Macros", "Macros"), + ("macro.record", "View", "Macros", "Record Macro"), // Automate ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 0c0f9683e..4c9325e36 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { pub use gridcraft_model::icon_count; +struct RuleEval<'a> { + wb: &'a Workbook, + sheet: usize, + cell: CellRef, + first: CellRef, + value: &'a Value, + text: &'a str, + stats: &'a RuleStats, +} + +fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> { + let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx; + match rule { + CfRule::CellIs { op, a, b, style } => { + let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell)); + let av = eval(a); + let matches = match op { + CfOperator::Between | CfOperator::NotBetween => { + let bv = b.as_deref().map(eval).unwrap_or_default(); + let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; + let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater; + if *op == CfOperator::Between { within } else { !within } + } + CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal, + CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal, + CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater, + CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less, + CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less, + CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater, + }; + (matches && !value.is_error()).then_some(style.as_ref()) + } + CfRule::Expression { formula, style } => { + gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref()) + } + CfRule::ContainsText { text: needle, style } => { + (!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::NotContainsText { text: needle, style } => { + (value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::BeginsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::EndsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), + CfRule::Errors { style } => value.is_error().then_some(style.as_ref()), + CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()), + CfRule::Duplicate { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) + } + CfRule::Unique { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) + } + _ => None, + } +} + +fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool { + let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); + let day = serial.floor(); + let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial); + let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0); + let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1)); + let year_month = date(day).map(|value| (value.year, value.month)); + let month_matches = |offset: i32| { + let shifted_month = month as i32 + offset; + let shifted_year = year + (shifted_month - 1).div_euclid(12); + let shifted_month = (shifted_month - 1).rem_euclid(12) + 1; + year_month == Some((shifted_year, shifted_month as u32)) + }; + match period { + "yesterday" => day == today - 1.0, + "today" => day == today, + "tomorrow" => day == today + 1.0, + "last7Days" => day > today - 7.0 && day <= today, + "lastWeek" => day >= week_start - 7.0 && day < week_start, + "thisWeek" => day >= week_start && day < week_start + 7.0, + "nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0, + "lastMonth" => month_matches(-1), + "thisMonth" => month_matches(0), + "nextMonth" => month_matches(1), + _ => false, + } +} + impl CfCache { pub fn new() -> Self { Self::default() @@ -161,52 +250,8 @@ impl CfCache { None => &no_stats, }; let text = v.display(); - let matched_style: Option<&Style> = match &cf.rule { - CfRule::CellIs { op, a, b, style } => { - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c)); - let av = eval(a); - let ok = match op { - CfOperator::Between | CfOperator::NotBetween => { - let bv = b.as_deref().map(eval).unwrap_or_default(); - let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; - let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater; - if *op == CfOperator::Between { inside } else { !inside } - } - CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal, - CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal, - CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater, - CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less, - CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, - CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, - }; - // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. - (ok && !v.is_error()).then_some(style.as_ref()) - } - CfRule::Expression { formula, style } => { - gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) - } - // As in Excel, an error value never contains, begins or ends with anything (its rule - // formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162). - CfRule::ContainsText { text: t, style } => { - (!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::NotContainsText { text: t, style } => { - (v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::BeginsWith { text: t, style } => { - (!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()), - CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), - CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), - CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), - CfRule::Duplicate { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) - } - CfRule::Unique { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) - } + let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st }; + let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule { CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() { Some(n) if !st.sorted.is_empty() => { let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize }; @@ -229,36 +274,8 @@ impl CfCache { None => None, }, CfRule::TimePeriod { period, style } => match v.as_f64() { - Some(n) => { - let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); - let d = n.floor(); - let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); - let week_start = today - wd; - let (ty, tm) = - gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); - let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month)); - let month_off = |k: i32| { - let m = tm as i32 + k; - let y = ty + (m - 1).div_euclid(12); - let m = (m - 1).rem_euclid(12) + 1; - ym == Some((y, m as u32)) - }; - let ok = match period.as_str() { - "yesterday" => d == today - 1.0, - "today" => d == today, - "tomorrow" => d == today + 1.0, - "last7Days" => d > today - 7.0 && d <= today, - "lastWeek" => d >= week_start - 7.0 && d < week_start, - "thisWeek" => d >= week_start && d < week_start + 7.0, - "nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0, - "lastMonth" => month_off(-1), - "thisMonth" => month_off(0), - "nextMonth" => month_off(1), - _ => false, - }; - ok.then_some(style.as_ref()) - } - None => None, + Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()), + _ => None, }, CfRule::ColorScale { stops } => { if let Some(n) = v.as_f64() { @@ -329,7 +346,8 @@ impl CfCache { } None } - }; + _ => None, + }); if let Some(st) = matched_style { any = true; // Every matching rule applies; where two set the same part, the rule with the diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 93% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs index 2bd2a13bb..00cc008ca 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd.rs @@ -12,6 +12,7 @@ pub mod format; pub mod formulas; pub mod insert; pub mod inspect; +pub mod macros; pub mod pivot; pub mod print; pub mod python; @@ -144,6 +145,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view::specs()); v.extend(inspect::specs()); v.extend(extra::specs()); + v.extend(macros::specs()); v.extend(draw::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); @@ -207,7 +209,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { } fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { - #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -340,6 +341,9 @@ pub struct Ctx<'a> { /// The command applied sheet protection itself (row/column inserts and deletes the /// protection allows), so cells it shifts aren't refused as edits of locked cells. pub protection_checked: bool, + /// The structural edits the command made, in order (the sheet's name, the edit), as + /// references were rewritten for them. + pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>, } pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> Result { @@ -351,26 +355,48 @@ pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> }) } +fn wall_clock() -> std::time::Duration { + std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default() +} + pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) -> Result { - // `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there - // `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app. - #[allow(clippy::disallowed_methods)] - let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; + // `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch + // reads the wall clock without the (disallowed) `now`. + let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) }; + let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0); let mut sel = d.selection.clone(); - let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; + let mut ctx = Ctx { + wb: (*d.wb).clone(), + changed: Vec::new(), + structural: false, + sel: &mut sel, + fit_rows: Vec::new(), + protection_checked: false, + edits: Vec::new(), + }; let r = f(&mut ctx)?; - let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx; + let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx; if !protection_checked { check_protection(&d.wb, &wb, &d.unlocked_ranges)?; } if !structural { check_arrays(&d.wb, &wb)?; } + if let Some(log) = d.touched.as_mut() { + for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) { + if let Some(sh) = wb.sheet(*si) { + log.push((sh.name.clone(), *c)); + } + } + } + if let Some(log) = d.edits.as_mut() { + log.extend(edits); + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } if let Some(t0) = t0 { - eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len()); + eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len()); } if structural { d.calc.recalc_all(&mut wb); @@ -378,7 +404,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) d.calc.cells_changed(&mut wb, &changed); } if let Some(t0) = t0 { - eprintln!("commit: + recalc {:?}", t0.elapsed()); + eprintln!("commit: + recalc {:?}", since(t0)); } fit_rows.sort_unstable(); fit_rows.dedup(); @@ -386,13 +412,17 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) auto_row_height(&mut wb, si, row); } if let Some(t0) = t0 { - eprintln!("commit: + rows {:?}", t0.elapsed()); + eprintln!("commit: + rows {:?}", since(t0)); } d.wb = std::sync::Arc::new(wb); d.selection = sel; Ok(r) } +/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes +/// that kept a cell's value, so a shift is refused then. +pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; + /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index ffcbccf94..35e8426ee 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet}; use serde_json::{Value as Json, json}; use super::whatif::parse_range; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param}; const CMD: &str = "data.analysisToolPak"; /// Most input cells a tool reads. diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 0c255a689..143763a5e 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -4,13 +4,37 @@ use std::cmp::Ordering; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value, sort_compare}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation, + ValidationKind, Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet, + str_param, target_range, target_ranges, target_sheet, +}; use crate::outline; use crate::selection::current_region; +mod filter; +mod grouping; +mod sort; +mod tools; +mod validation; + +pub use filter::filter_values; +pub(crate) use filter::{apply_filter, grow_autofilter}; +use filter::{clear_filter, filter_by, reapply, toggle_filter}; +use grouping::{detail, group, outline_settings, show_levels, subtotal}; +use sort::{quick_sort, sort}; +#[cfg(test)] +use tools::split_delimited; +use tools::{remove_duplicates, text_to_columns}; +pub use validation::{check_validation, list_items}; +use validation::{circle_invalid, validate_cmd, validation}; +pub(super) use validation::{move_rule_formula, remove_validation_cells}; + pub fn specs() -> Vec { vec![ cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort( @@ -200,1902 +224,5 @@ fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { votes > 0 } -#[derive(Clone)] -struct SortKey { - col: u32, - desc: bool, - by: String, - list: Option>, -} - -/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, -/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. -fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - if let Some(t) = str_param(p, "range") - && let (Some(name), _) = split_sheet(t) - && d.wb.sheet_index(&name) != Some(sheet) - { - return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); - } - if sheet != d.wb.active_sheet() && p.get("range").is_none() { - return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); - } - Ok((sheet, data_range(s, p)?)) -} - -fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { - let (sheet, r) = sort_target(s, p)?; - let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = guess_header(&d.wb, sh, r); - do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) -} - -fn sort(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = sort_target(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let by_cols = str_param(p, "orientation") == Some("columns"); - let case = bool_param(p, "matchCase").unwrap_or(false); - let mut keys: Vec = Vec::new(); - for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { - let col = if by_cols { - let row = match k.get("row").and_then(Json::as_u64) { - // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. - Some(n) => { - let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); - Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) - } - None => None, - }; - row.or_else(|| col_param(k.get("column"), r.start.row)) - } else { - col_param(k.get("column"), r.start.col) - }; - let Some(col) = col else { continue }; - keys.push(SortKey { - col, - desc: k.get("order").and_then(Json::as_str) == Some("desc"), - by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), - list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), - }); - } - if keys.is_empty() { - s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); - return ok(); - } - if keys.len() > 64 { - return Err(bad("data.sort", "too many sort levels (64 max)")); - } - do_sort(s, sheet, r, header, keys, by_cols, case) -} - -fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { - // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. - super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; - let body = if by_cols { - if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } - } else if header && r.height() > 1 { - RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) - } else { - r - }; - if body.count() > 20_000_000 { - return Err(bad("data.sort", "range too large")); - } - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; - if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { - return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); - } - let n = if by_cols { body.width() } else { body.height() }; - let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; - let theme = cx.wb.theme.clone(); - let styles = &cx.wb.styles; - let key_val = |i: u32, k: &SortKey| -> (Value, u32) { - let c = line(i, k.col); - let v = sh.value(c); - let color = match k.by.as_str() { - "cellColor" => { - styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - "fontColor" => { - styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - _ => 0, - }; - (v, color) - }; - let mut order: Vec = (0..n).collect(); - let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); - order.sort_by(|&a, &b| { - for (ki, k) in keys.iter().enumerate() { - let (va, ca) = &keyed[a as usize][ki]; - let (vb, cb) = &keyed[b as usize][ki]; - let o = if k.by != "values" { - ca.cmp(cb) - } else if let Some(list) = &k.list { - let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); - pos(va).cmp(&pos(vb)) - } else if case && va.is_text() && vb.is_text() { - sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) - } else { - sort_compare(va, vb) - }; - // Blanks always sort last, whatever the order. - let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; - if o != Ordering::Equal { - return o; - } - } - Ordering::Equal - }); - if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { - return Ok(Json::Null); - } - // Move whole lines (cells with formulas adjusted for relative references). - let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; - // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they - // describe — the way cell pictures and row heights do. Collect them before inserting, - // so a swap can't resurrect one from a destination that is also a source. - let side: Vec<(CellRef, Comment)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) - .collect(); - let links: Vec<(CellRef, Hyperlink)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) - .collect(); - let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); - let mut pictures = Vec::new(); - for (dst_i, &src_i) in order.iter().enumerate() { - for &k in &lanes { - let src = line(src_i, k); - let dst = line(dst_i as u32, k); - let mut cell = sh.cell(src).cloned(); - if let Some(c) = cell.as_mut() - && let Some(f) = &c.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); - c.formula = Some(Arc::new(Formula::from_expr(shifted))); - } - if let Some(picture) = sh.cell_pictures.get(&src) { - pictures.push((dst, picture.clone())); - } - moved.push((dst, cell)); - } - } - // Row heights travel with rows. - let heights: Vec> = - if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; - // Floating objects anchored inside the sorted body travel with their lines, unless they - // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering - // is the inverse of `order`: `dst_of[src] = dst`. - let dst_of: Vec = { - let mut v = vec![0u32; order.len()]; - for (dst_i, &src_i) in order.iter().enumerate() { - if let Some(slot) = v.get_mut(src_i as usize) { - *slot = dst_i as u32; - } - } - v - }; - // The object's own geometry (its top-left anchor cell, offset and size) is read from the - // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it - // can be re-fitted to the lines it spans after the sort. - let objs: Vec = - sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); - let descs: Vec<(u32, u32, f32, f32)> = objs - .iter() - .map(|a| { - let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; - let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; - let tc = sh.col_at(x2); - let tr = sh.row_at(y2); - (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) - }) - .collect(); - let shm = cx.sheet_mut(sheet)?; - for (c, cell) in &moved { - match cell { - Some(x) => shm.set_cell(*c, x.clone()), - None => { - shm.remove_cell(*c); - } - } - } - // Every cell of the body is the destination of exactly one source cell, so clearing the - // body and inserting the permuted entries is the same permutation the cells went through. - for c in body.iter() { - shm.comments.remove(&c); - shm.hyperlinks.remove(&c); - } - for (at, cm) in side { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - shm.cell_pictures.extend(pictures); - for (i, h) in heights.into_iter().enumerate() { - let row = body.start.row + i as u32; - match h { - Some(info) => { - shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); - } - None => { - if let Some(e) = shm.rows.get_mut(&row) { - e.size = None; - } - } - } - } - // Re-anchor floating objects now that the row heights are final. `objs` was collected as - // images ++ charts ++ shapes, so the same index maps straight onto each vec. - // Only objects anchored inside the sorted block move: within its lines on the sort axis - // and within its span on the other axis (a picture beside the block stays put). - let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; - let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; - let ni = shm.images.len(); - let nch = shm.charts.len(); - for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { - let other = if by_cols { a.cell.row } else { a.cell.col }; - if other < o0 || other > o1 { - continue; - } - let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); - if na != a { - if i < ni { - if let Some(o) = shm.images.get_mut(i) { - o.anchor = na; - } - } else if i < ni + nch { - if let Some(o) = shm.charts.get_mut(i - ni) { - o.anchor = na; - } - } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { - o.anchor = na; - } - } - } - for (c, _) in moved { - cx.changed.push((sheet, c)); - } - cx.structural = true; - Ok(Json::Null) - })?; - Ok(json!({"sorted": body.a1()})) -} - -/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the -/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a -/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort -/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. -/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" -/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. -fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { - if a.mode == AnchorMode::Absolute { - return a; - } - let (tc, tr, tdx, tdy) = bottom; - let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; - let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; - if axis_start < b0 || axis_start > b1 { - return a; - } - let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; - if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { - let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; - let x1 = sh.col_left(nc) + a.dx as f64; - 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 } - } else { - Anchor { cell: CellRef::new(nr, nc), ..a } - } -} - -// ---------------------------------------------------------------- filter - -fn toggle_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.filter")?; - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. - let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; - let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); - if let Some(name) = in_table { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { - t.filter_button = !t.filter_button; - if !t.filter_button { - t.filter.clear(); - } - } - Ok(Json::Null) - }); - } - if has { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. - let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); - sh.autofilter = None; - for r in filtered { - if let Some(info) = sh.rows.get_mut(&r) { - info.hidden = false; - } - } - cx.structural = true; - Ok(json!({"filter": false})) - }); - } - install_filter(s, p) -} - -/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. -fn install_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, mut r) = data_target(s, p, "data.filter")?; - // A selected header row filters the data below it (#201). - if p.get("range").is_none() && r.height() == 1 { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let below = current_region(sh, r.start).end.row; - if below > r.end.row { - r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); - } - } - edit(s, |cx| { - cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); - Ok(json!({"filter": true, "range": r.a1()})) - }) -} - -fn filter_by(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let af = match &sh.autofilter { - Some(a) => a.clone(), - None => { - install_filter(s, &json!({}))?; - s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? - } - }; - let col = match col_param(p.get("column"), af.range.start.col) { - Some(c) => c, - // A number too large to be a column is outside the filter range, not a missing parameter. - None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), - None => return Err(bad("data.filterBy", "missing `column`")), - }; - if col < af.range.start.col || col > af.range.end.col { - return Err(bad("data.filterBy", "column outside the filter range")); - } - let off = col - af.range.start.col; - let crit = if bool_param(p, "clear").unwrap_or(false) { - None - } else if let Some(vals) = p.get("values").and_then(Json::as_array) { - Some(FilterCriterion::Values { - values: vals.iter().map(super::edit::json_to_input).collect(), - blanks: bool_param(p, "blanks").unwrap_or(false), - }) - } else if let Some(c) = p.get("custom") { - let a = (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(super::edit::json_to_input).unwrap_or_default()); - let b = c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(super::edit::json_to_input).unwrap_or_default())); - Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) - } else if let Some(t) = p.get("top") { - Some(FilterCriterion::Top10 { - bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), - percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), - count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, - }) - } else { - bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.retain(|(o, _)| *o != off); - if let Some(c) = crit.clone() { - a.criteria.push((off, c)); - } - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - })?; - let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); - Ok(json!({"hiddenRows": hidden})) -} - -/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? -fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { - let num = gridcraft_core::parse::parse_number_text(crit); - let cmp = match (v.as_f64(), num) { - (Some(a), Some(b)) => a.partial_cmp(&b), - // A numeric ordering comparison only matches numbers; blanks and text never do. - (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, - _ => Some(gridcraft_core::compare_text(text, crit)), - }; - let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); - match op { - "=" => { - if crit.contains(['*', '?']) { - wild() - } else { - cmp == Some(Ordering::Equal) - } - } - "<>" => { - if crit.contains(['*', '?']) { - !wild() - } else { - cmp != Some(Ordering::Equal) - } - } - ">" => cmp == Some(Ordering::Greater), - "<" => cmp == Some(Ordering::Less), - ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), - "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), - "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), - "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), - "contains" => text.to_lowercase().contains(&crit.to_lowercase()), - "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), - _ => true, - } -} - -/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. -fn filter_blank(v: &Value) -> bool { - v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) -} - -/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). -/// A table's own filter is left to the table, and a row typed after a blank row does not count. -/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. -pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { - return; - } - let Some(af) = sh.autofilter.as_ref() else { return }; - let r = af.range; - if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { - return; - } - let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); - if let Some(af) = sh.autofilter.as_mut() { - af.range = grown; - } -} - -/// Hides rows of the autofilter range that fail any criterion. -pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { - let Some(sh) = wb.sheet(sheet) else { return }; - let Some(af) = sh.autofilter.clone() else { return }; - let r = af.range; - let mut hide = Vec::new(); - // Precompute per-criterion top10 threshold and average once, not per row. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (i, (off, c)) in af.criteria.iter().enumerate() { - if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { - let col = r.start.col + off; - let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); - let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - let thr = match c { - FilterCriterion::Top10 { bottom, percent, count } => { - nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, nums.len().max(1)); - nums.get(k - 1).copied() - } - _ => None, - }; - col_stats.insert(i, (thr, avg)); - } - } - for row in r.start.row + 1..=r.end.row { - let mut visible = true; - for (i, (off, crit)) in af.criteria.iter().enumerate() { - let c = CellRef::new(row, r.start.col + off); - let v = sh.value(c); - let text = crate::display::cell_text(wb, sh, c); - let ok = match crit { - FilterCriterion::Values { values, blanks } => { - if filter_blank(&v) { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) - } - } - FilterCriterion::Custom { a, b, and } => { - let x = custom_ok(&v, &text, &a.0, &a.1); - match b { - Some(b) => { - let y = custom_ok(&v, &text, &b.0, &b.1); - if *and { x && y } else { x || y } - } - None => x, - } - } - FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((thr, _))) => { - let thr = thr.unwrap_or(n); - if *bottom { n <= thr } else { n >= thr } - } - _ => false, - }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((_, avg))) => { - if *above { - n > *avg - } else { - n < *avg - } - } - _ => false, - }, - FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, - FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, - // Icons are not evaluated here: the row keeps its current visibility. - FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), - }; - if !ok { - visible = false; - break; - } - } - hide.push((row, !visible)); - } - let Some(shm) = wb.sheet_mut(sheet) else { return }; - for (row, h) in hide { - if h { - shm.rows.entry(row).or_default().hidden = true; - } else if let Some(e) = shm.rows.get_mut(&row) { - e.hidden = false; - } - } -} - -fn clear_filter(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.clear(); - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -fn reapply(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Distinct display values of a filter column (for the dropdown list). -pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { - let Some(sh) = wb.sheet(sheet) else { return vec![] }; - let Some(af) = &sh.autofilter else { return vec![] }; - let off = col.saturating_sub(af.range.start.col); - let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); - let mut seen = std::collections::BTreeMap::new(); - for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { - let c = CellRef::new(row, col); - let v = sh.value(c); - let blank = filter_blank(&v); - let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; - let on = match selected { - Some(FilterCriterion::Values { values, blanks }) => { - if blank { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&t)) - } - } - _ => true, - }; - let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; - seen.entry((key, t)).or_insert(on); - } - seen.into_iter().map(|((_, t), on)| (t, on)).collect() -} - -/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts -/// before uppercase at the first letter that differs in case. -fn case_order(a: &str, b: &str) -> Ordering { - a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) -} - -fn sort_key(v: &Value) -> (u8, i64, String) { - match v { - Value::Number(n) => (0, (n * 1e6) as i64, String::new()), - Value::Text(t) => (1, 0, t.to_lowercase()), - Value::Bool(b) => (2, *b as i64, String::new()), - Value::Empty => (9, 0, String::new()), - _ => (3, 0, v.display()), - } -} - -// ---------------------------------------------------------------- tools - -fn remove_duplicates(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - // Rows and columns past the data are blank: only the part with data is compared (whole - // columns compared a million blank rows and reported them as removed duplicates). - let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); - // With no column to compare, every row key is empty: the first row would survive and every - // other row would be deleted as a duplicate. A column outside the range compares data the - // user didn't select. Excel refuses both. - if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { - return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); - } - let start = r.start.row + header as u32; - let mut removed = 0; - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut seen = std::collections::HashSet::new(); - let mut keep: Vec = Vec::new(); - // Cells are compared by the text they show, as in Excel: the same date in two formats - // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. - let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; - for row in start..=r.end.row { - let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); - if seen.insert(key) { - keep.push(row); - } else { - removed += 1; - } - } - let pictures: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) - }) - .collect(); - // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that - // describe them move with them; the ones on removed rows are dropped with the row. - let notes: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) - }) - .collect(); - let links: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) - }) - .collect(); - let rows: Vec>> = - keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); - let shm = cx.sheet_mut(sheet)?; - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - shm.remove_cell(CellRef::new(row, c)); - } - } - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - let at = CellRef::new(row, c); - shm.comments.remove(&at); - shm.hyperlinks.remove(&at); - } - } - for (i, cells) in rows.into_iter().enumerate() { - for (j, cell) in cells.into_iter().enumerate() { - if let Some(cell) = cell { - shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); - } - } - } - shm.cell_pictures.extend(pictures); - for (at, cm) in notes { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - let remaining = (r.end.row + 1 - start) as usize - removed; - Ok(json!({"removed": removed, "remaining": remaining})) -} - -fn text_to_columns(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let sheet = target_sheet(s, p)?; - let mut delims: Vec = p - .get("delimiters") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) - .unwrap_or_else(|| vec!['\t']); - if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { - delims.push(o); - } - let fixed: Option> = - p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); - let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); - let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); - let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut out: Vec<(CellRef, String)> = Vec::new(); - for (i, row) in (r.start.row..=r.end.row).enumerate() { - let text = sh.value(CellRef::new(row, r.start.col)).display(); - let parts: Vec = match &fixed { - Some(w) => { - let chars: Vec = text.chars().collect(); - let mut cuts = vec![0]; - let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); - breaks.sort_unstable(); - breaks.dedup(); - cuts.extend(breaks); - cuts.push(chars.len()); - cuts.windows(2) - .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) - .collect() - } - None => split_delimited(&text, &delims, consecutive, quote), - }; - for (j, part) in parts.into_iter().enumerate() { - out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); - } - } - for (c, t) in out { - let cell = match super::edit::input_to_cell(&t, sheet, c, &mut cx.wb) { - Ok(cell) => cell, - // `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept - // as text rather than lost. - Err(_) if gridcraft_model::python::is_py_formula(&t) => { - let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); - Some(Cell { value: Value::text(t.as_str()), formula: None, style, py: None }) - } - Err(_) => None, - }; - let shm = cx.sheet_mut(sheet)?; - match cell { - Some(x) => shm.set_cell(c, x), - None => { - shm.remove_cell(c); - } - } - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - ok() -} - -fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_q = false; - let mut last_delim = false; - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - if c == quote { - if in_q && chars.peek() == Some("e) { - cur.push(quote); - chars.next(); - } else { - in_q = !in_q; - } - continue; - } - if !in_q && delims.contains(&c) { - if consecutive && last_delim { - continue; - } - out.push(std::mem::take(&mut cur)); - last_delim = true; - continue; - } - last_delim = false; - cur.push(c); - } - out.push(cur); - out -} - -// ---------------------------------------------------------------- validation - -fn op_param(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greater" | "greaterThan" => CfOperator::Greater, - "less" | "lessThan" => CfOperator::Less, - "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -/// `r` minus `cut`, as up to four non-overlapping rectangles. -fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { - let Some(i) = r.intersection(cut) else { return vec![r] }; - let mut out = Vec::new(); - if i.start.row > r.start.row { - out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); - } - if i.end.row < r.end.row { - out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); - } - if i.start.col > r.start.col { - out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); - } - if i.end.col < r.end.col { - out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); - } - out -} - -/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. -pub(super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { - for dv in validations.iter_mut() { - for cut in cuts { - dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); - } - } - validations.retain(|d| !d.ranges.is_empty()); -} - -fn validation(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let sheet = target_sheet(s, p)?; - if bool_param(p, "clear").unwrap_or(false) { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - Ok(Json::Null) - }); - } - let Some(kind) = str_param(p, "type") else { - s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); - return ok(); - }; - let kind = match kind { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - "any" => ValidationKind::Any, - // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. - other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), - }; - if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { - return Err(bad("data.validation", "a list validation needs `formula1`")); - } - const OPERATORS: [&str; 12] = [ - "between", - "notBetween", - "equal", - "notEqual", - "greater", - "greaterThan", - "less", - "lessThan", - "greaterOrEqual", - "greaterThanOrEqual", - "lessOrEqual", - "lessThanOrEqual", - ]; - if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { - return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); - } - let error_style = match str_param(p, "errorStyle") { - None | Some("stop") => ErrorStyle::Stop, - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), - }; - let f1 = str_param(p, "formula1").unwrap_or(""); - let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; - let dv = Validation { - ranges: ranges.clone(), - kind, - op: op_param(str_param(p, "operator")), - f1, - f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), - allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), - in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), - input_title: str_param(p, "inputTitle").unwrap_or("").into(), - input_message: str_param(p, "inputMessage").unwrap_or("").into(), - show_input: true, - error_title: str_param(p, "errorTitle").unwrap_or("").into(), - error_message: str_param(p, "errorMessage").unwrap_or("").into(), - error_style, - show_error: true, - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - sh.validations.push(dv.clone()); - Ok(Json::Null) - }) -} - -/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather -/// than a reference or a name to evaluate. -fn is_literal_list(f: &str) -> bool { - f.starts_with('"') - || (!f.contains('!') - && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) -} - -/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's -/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves -/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a -/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. -fn list_source(f: &str) -> String { - let t = f.trim(); - if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { - return f.trim_start_matches('=').to_string(); - } - format!("\"{}\"", t.replace('"', "\"\"")) -} - -/// List entries of a list validation (literal or from a range). -pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { - let f = dv.f1.trim(); - if is_literal_list(f) { - // A quoted list is a string literal: `""` inside stands for one `"`. - let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { - Some(body) => body.replace("\"\"", "\""), - None => f.trim_matches('"').to_string(), - }; - return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); - } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); - match gridcraft_calc::evaluate(wb, sheet, at, f) { - Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), - v if !v.is_empty() && !v.is_error() => vec![v.display()], - _ => vec![], - } -} - -/// Checks typed input against the validation of a cell. `Ok(None)` = valid. -pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { - let sh = wb.sheet(sheet)?; - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; - if dv.kind == ValidationKind::Any || !dv.show_error { - return None; - } - if input.is_empty() && dv.allow_blank { - return None; - } - let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, - // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); - let entered; - let wb = if sh.value(at) == v { - wb - } else { - let mut w = wb.clone(); - if let Some(sh) = w.sheet_mut(sheet) { - let style = sh.style_id(at); - sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); - } - entered = w; - &entered - }; - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); - let num = |f: &str| eval(f).to_number().ok(); - let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), - ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), - ValidationKind::TextLength => { - let n = input.chars().count() as f64; - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - _ => match v.as_f64() { - Some(n) => { - if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { - false - } else { - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - } - None => false, - }, - }; - if ok { - return None; - } - let msg = if dv.error_message.is_empty() { - "This value doesn't match the data validation restrictions defined for this cell.".to_string() - } else { - dv.error_message.clone() - }; - Some((dv.clone(), msg)) -} - -/// A formula written for `first`, as seen from `at` (relative references shifted). -fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { - if first == at { - return f.to_string(); - } - match gridcraft_formula::parse(f.trim_start_matches('=')) { - Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( - e, - at.row as i64 - first.row as i64, - at.col as i64 - first.col as i64, - )), - Err(_) => f.to_string(), - } -} - -/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` -/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. -pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { - if first == to { - return f.to_string(); - } - let moved = formula_at(f, first, to); - let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); - let eq = if f.starts_with('=') { "=" } else { "" }; - if has_relative { format!("{eq}{moved}") } else { f.to_string() } -} - -fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { - let Some(a) = a else { return false }; - match op { - CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), - CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), - CfOperator::Equal => n == a, - CfOperator::NotEqual => n != a, - CfOperator::Greater => n > a, - CfOperator::Less => n < a, - CfOperator::GreaterOrEqual => n >= a, - CfOperator::LessOrEqual => n <= a, - } -} - -fn validate_cmd(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet(), at, input) { - None => Ok(json!({"ok": true})), - Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), - } -} - -fn circle_invalid(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let mut bad_cells = Vec::new(); - for dv in &sh.validations { - for r in &dv.ranges { - let r = sh.used_range().and_then(|u| r.intersection(&u)); - let Some(r) = r else { continue }; - for c in r.iter().take(100_000) { - let text = sh.input_text(c); - if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { - bad_cells.push(c.a1()); - } - } - } - } - Ok(json!({"invalid": bad_cells})) -} - -// ---------------------------------------------------------------- outline - -fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { - if let Some(r) = str_param(p, "rows").and_then(parse_lines) { - return Ok((true, r.start.row, r.end.row)); - } - if let Some(r) = str_param(p, "cols").and_then(parse_lines) { - return Ok((false, r.start.col, r.end.col)); - } - let r = s.doc()?.selection.current(); - match str_param(p, "by") { - Some("rows") => Ok((true, r.start.row, r.end.row)), - Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), - _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), - _ => Ok((true, r.start.row, r.end.row)), - } -} - -fn group(s: &mut Session, p: &Json, on: bool) -> Result { - let si = target_sheet(s, p)?; - // The selection belongs to the active sheet, so another sheet needs its lines. - if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { - return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); - } - let (rows, a, b) = outline_lines(s, p)?; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - let before = outline::summary_before(sh, rows); - let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let b = b.min(a.saturating_add(1_048_575)); - for i in a..=b { - let e = map.entry(i).or_default(); - e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; - if !on && e.outline == 0 { - e.hidden = false; - } - } - // A summary line whose group is gone has nothing left to collapse. - let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; - if !on - && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) - && let Some(e) = summary.and_then(|i| map.get_mut(&i)) - { - e.collapsed = false; - } - cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it - Ok(Json::Null) - }) -} - -/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or -/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the -/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. -fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { - let (rows, a, b) = outline_lines(s, p)?; - let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); - edit(s, |cx| { - let si = cx.sheet_index(); - let sh = cx.sheet_mut(si)?; - let before = outline::summary_before(sh, rows); - let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let all = outline::groups_with(map, before); - let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); - let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { - let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); - let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); - hits.into_iter().filter(|g| g.level == deepest).collect() - }; - let mut chosen = pick(&|g| g.start <= b && g.end >= a); - if chosen.is_empty() { - chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); - } - for g in chosen { - // Inner groups collapsed on their own (their summary marked), before this one changes. - let inner: Vec = all - .iter() - .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) - .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) - .copied() - .collect(); - for i in g.start..=g.end { - if let Some(e) = map.get_mut(&i) { - set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); - } - } - if !g.has_summary() { - continue; - } - if hide { - map.entry(g.summary()).or_default().collapsed = true; - } else if let Some(e) = map.get_mut(&g.summary()) { - e.collapsed = false; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as -/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the -/// group expanded and leave it invisible (#1714). -fn set_hidden(e: &mut LineInfo, hidden: bool) { - e.hidden = hidden; - if !hidden && e.size == Some(0.0) { - e.size = None; - } -} - -/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the -/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. -fn show_levels(s: &mut Session, p: &Json) -> Result { - let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); - let (rows, cols) = (level("rows"), level("cols")); - if rows.is_none() && cols.is_none() { - return Err(EngineError::BadParams { - cmd: "data.showLevels".into(), - msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), - }); - } - edit(s, |cx| { - let si = cx.sheet_index(); - let sh = cx.sheet_mut(si)?; - let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); - for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { - let Some(n) = n else { continue }; - let groups = outline::groups_with(map, before); - for e in map.values_mut().filter(|e| e.outline > 0) { - set_hidden(e, e.outline >= n); - } - // Inner groups come first, so a summary line shared with the group around it ends up - // as that outer group says. - for g in groups.into_iter().filter(|g| g.has_summary()) { - map.entry(g.summary()).or_default().collapsed = g.level >= n; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns -/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. -/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new -/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost -/// group whose detail is all hidden, or the outermost when none is. -fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { - let mut marked: Vec = - outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect(); - // A mark on a line that nested groups share moves with the outermost of them whose - // detail is all hidden, or with the outermost when none is. - let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for g in &marked { - let key = (!g.collapsed, g.level); - let best = owner.entry(g.summary()).or_insert(key); - *best = (*best).min(key); - } - marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); - for g in &marked { - if let Some(e) = map.get_mut(&g.summary()) { - e.collapsed = false; - } - } - for g in marked { - let g = outline::OutlineGroup { summary_before: !on, ..g }; - if g.has_summary() { - map.entry(g.summary()).or_default().collapsed = true; - } - } -} - -fn outline_settings(s: &mut Session, p: &Json) -> Result { - let si = target_sheet(s, p)?; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { - let Some(on) = bool_param(p, key) else { continue }; - let was_before = outline::summary_before(sh, rows); - if rows { - sh.summary_below = on; - } else { - sh.summary_right = on; - } - if was_before != on { - continue; - } - move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); - } - Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) - }) -} - -fn subtotal(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.subtotal")?; - let header = bool_param(p, "header").unwrap_or(true); - let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); - let func = str_param(p, "function").unwrap_or("sum"); - let code = match func { - "count" => 3, - "average" => 1, - "max" => 4, - "min" => 5, - "product" => 6, - _ => 9, - }; - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| vec![r.end.col]); - let label = match func { - "count" => "Count", - "average" => "Average", - "max" => "Max", - "min" => "Min", - "product" => "Product", - _ => "Total", - }; - // Group boundaries (header in the first row). - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let mut groups: Vec<(u32, u32, String)> = Vec::new(); - let first = r.start.row + u32::from(header); - let mut start = first; - for row in first..=r.end.row + 1 { - let cur = sh.value(CellRef::new(row, group_col)).display(); - let prev = sh.value(CellRef::new(start, group_col)).display(); - if row > r.end.row || cur != prev { - groups.push((start, row - 1, prev)); - start = row; - } - } - // The totals go below each group, so the outline puts summary rows below their detail (the - // Subtotal dialog's "Summary below data"), in the same undo step. - if !sh.summary_below { - s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?; - } - // Insert a total row after each group, bottom-up so indices stay valid. - let mut inserted = 0; - for (g0, g1, name) in groups.iter().rev() { - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; - let row = g1 + 1; - let lbl = CellRef::new(row, group_col).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - s.execute( - "home.bold", - json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), - )?; - s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; - inserted += 1; - } - let last = r.end.row + inserted + 1; - let lbl = CellRef::new(last, group_col).a1(); - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - Ok(json!({"groups": inserted})) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn filter_blanks_include_empty_text_and_formula_results() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); - let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); - assert_eq!(r["hiddenRows"], json!(2)); - let vals = filter_values(&s.doc().unwrap().wb, 0, 1); - // The `=""` result joins (Blanks) instead of showing as its own empty entry. - assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); - let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); - assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); - } - - #[test] - fn subtotal_honours_header_false() { - // #131: with header:false the first data row was treated as a header and left out of its - // group's subtotal and the grand total. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); - s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!("East")); - assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); - assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); - assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); - assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row"); - } - - #[test] - fn subtotal_puts_summary_rows_below_their_detail() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert!(sh.summary_below, "the totals sit below their groups"); - // East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing. - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row); - assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden"); - assert!(!hidden(&s, 3), "East Total stays visible"); - // One undo takes the subtotals and the setting back. - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!(!sh.summary_below); - assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); - } - - #[test] - fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); - for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { - let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); - assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); - } - let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); - assert_eq!(ok["hiddenRows"], json!(1)); - } - - #[test] - fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { - for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); - s.execute("selection.set", sel.clone()).unwrap(); - s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); - assert!(sh.tables[0].filter_button, "{sel}"); - assert!(!sh.is_row_hidden(1), "{sel}"); - assert!(sh.is_row_hidden(2), "{sel}"); - assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); - } - } - - #[test] - fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); - let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); - s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); - assert_eq!(range(&s), Some("A1:B4".to_string())); - s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A row typed after a blank row does not grow it. - s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A cell outside the filter's columns does not either. - s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // Empty text is a blank entry, not a value. - s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A formula and Ctrl+Enter (range.fill) are entries too. - s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); - assert_eq!(range(&s), Some("A1:B6".to_string())); - s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); - assert_eq!(range(&s), Some("A1:B7".to_string())); - } - - #[test] - fn list_validation_without_formula1_is_an_error() { - let mut s = Session::new(); - s.new_workbook(); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); - assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); - } - - #[test] - fn partial_validation_edits_keep_the_rest_of_the_old_rule() { - let mut s = Session::new(); - s.new_workbook(); - let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); - let ok = |s: &mut Session, cell: &str, input: &str| { - s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true) - }; - s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); - s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); - assert!(ok(&mut s, "B2", "999")); - assert!(!ok(&mut s, "B1", "999")); - assert!(!ok(&mut s, "B3", "999")); - s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); - for cell in ["B2", "B3", "B4"] { - assert!(ok(&mut s, cell, "150"), "{cell}"); - assert!(!ok(&mut s, cell, "5"), "{cell}"); - } - assert!(ok(&mut s, "B1", "5")); - assert!(!ok(&mut s, "B1", "150")); - } - - #[test] - fn showing_detail_gives_zero_width_columns_their_width_back() { - // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. - let mut s = Session::new(); - s.new_workbook(); - let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; - let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); - for c in 2..=4 { - sh.cols.insert(c, hidden); - } - sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); - s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - for c in 2..=4 { - assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); - } - // Hiding again hides them; the level buttons show zero-width columns too. - s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); - Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); - s.execute("data.showLevels", json!({"cols": 2})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); - } - - #[test] - fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("data.group", json!({"rows": "2:4"})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - let hidden = |s: &Session| -> Vec { - let sh = s.doc().unwrap().wb.active().unwrap(); - sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() - }; - let before = hidden(&s); - assert_eq!(before, [1, 2, 3, 9]); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); - assert_eq!(hidden(&s), before); - assert_eq!(row_state(&s, 4..=4), [(false, true)]); - } - - #[test] - fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); - s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - s.execute("selection.set", json!({"range": "E1"})).unwrap(); - // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); - } - - /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). - fn nested_rows() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "5:8"})).unwrap(); - s.execute("data.group", json!({"rows": "6:7"})).unwrap(); - s - } - - /// (hidden, collapsed) of 0-based rows. - fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { - let sh = s.doc().unwrap().wb.active().unwrap(); - rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() - } - - #[test] - fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Each step is one undoable command. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); - } - - #[test] - fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); - } - - #[test] - fn show_levels_collapses_and_expands_to_a_level() { - let mut s = nested_rows(); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showLevels", json!({"rows": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showLevels", json!({"rows": 3})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Columns are separate; no level at all is an error rather than a guess. - s.execute("data.group", json!({"cols": "C:E"})).unwrap(); - s.execute("data.showLevels", json!({"cols": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); - assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); - assert_eq!(row_state(&s, 4..=4), [(false, false)]); - assert!(s.execute("data.showLevels", json!({})).is_err()); - } - - #[test] - fn group_by_columns_for_a_block_of_cells() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); - s.execute("data.group", json!({"by": "cols"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); - assert!(sh.rows.values().all(|l| l.outline == 0)); - } - - #[test] - fn group_shortcuts_are_the_windows_ones() { - let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); - assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); - assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); - } - - #[test] - fn summary_rows_above_the_detail_take_the_collapsed_mark() { - let mut s = nested_rows(); - let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); - // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); - // Hide Detail on the summary row collapses the group below it. - s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); - assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); - // Ungrouping the outer group clears its mark on the row above. - s.execute("data.showLevels", json!({"rows": 3})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); - assert_eq!(row_state(&s, 3..=3), [(false, false)]); - } - - #[test] - fn changing_the_summary_direction_moves_collapsed_marks() { - let mut s = nested_rows(); - s.execute("data.group", json!({"cols": "C:E"})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); - let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); - assert!(col_marked(&s, 1) && !col_marked(&s, 5)); - s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); - assert!(col_marked(&s, 1), "columns keep their direction"); - // One undo step per change; no params leaves the settings as they are. - s.execute("edit.undo", json!({})).unwrap(); - assert!(row_state(&s, 3..=3)[0].1); - assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); - // Nested groups that end on one row share its mark: it moves to the outer group only. - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "5:8"})).unwrap(); - s.execute("data.group", json!({"rows": "6:8"})).unwrap(); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); - s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); - // With only the inner group collapsed, its mark goes to row 5, and the outer group's - // Hide and Show Detail keep it collapsed. - s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); - // A group on the first row has no row above it to mark. - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "1:3"})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); - assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); - } - - #[test] - fn column_sort_rejects_row_key_zero_without_panicking() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); - for row in [0u64, 1 << 32] { - let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); - let err = s.execute("data.sort", params).unwrap_err(); - assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); - } - } - - #[test] - fn single_row_and_column_parameters() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); - s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); - s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); - let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); - assert_eq!(v(&mut s, "C3"), json!(4.0)); - s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); - s.execute("data.group", json!({"cols": "C"})).unwrap(); - assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); - s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); - s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); - assert_eq!(v(&mut s, "B2"), json!(4.0)); - } - - #[test] - fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { - let cases: [(Json, [&str; 4]); 5] = [ - (json!([6, 3]), ["abc", "def", "ghij", ""]), - (json!([3, 3]), ["abc", "defghij", "", ""]), - (json!([0]), ["abcdefghij", "", "", ""]), - (json!([20]), ["abcdefghij", "", "", ""]), - (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), - ]; - for (widths, want) in cases { - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); - s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); - assert_eq!(got, want, "{widths}"); - } - } - - #[test] - fn split() { - assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); - assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); - assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); - } - - #[test] - fn remove_duplicates_without_a_valid_column_is_refused() { - // `columns: []` (or only unusable names) used to compare nothing and delete every row - // but the first. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); - for columns in [json!([]), json!(["ZZZ"])] { - let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); - assert!(err.to_string().contains("columns"), "{err}"); - } - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); - assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); - } - - #[test] - fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { - // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. - // References, names and formulas stay unquoted, and every rule survives a save and reopen. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); - s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); - let rules = [ - ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), - ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), - ("C1", " 1,2,3 ", r#""1,2,3""#), - ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), - ("E1", "=$K$1:$K$3", "$K$1:$K$3"), - ("F1", "$K$1:$K$3", "$K$1:$K$3"), - ("G1", "=Colors", "Colors"), - ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), - ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), - ]; - for (cell, source, _) in rules { - s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); - } - let accepted = [ - ("A1", "In progress"), - ("B1", "Closed"), - ("C1", "2"), - ("D1", r#"Say "hi""#), - ("E1", "Green"), - ("F1", "Blue"), - ("G1", "Red"), - ("H1", "Green"), - ]; - let check = |wb: &Workbook| { - for (cell, input) in accepted { - let at = CellRef::parse(cell).unwrap(); - assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); - assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); - } - }; - let wb = s.doc().unwrap().wb.clone(); - check(&wb); - - let sheet_xml = |wb: &Workbook| { - let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut xml = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); - xml - }; - let xml = sheet_xml(&wb); - for (cell, _, saved) in rules { - assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); - } - let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); - check(&reopened); - assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); - } - - #[test] - fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { - // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, - // with no error. - let mut s = Session::new(); - s.new_workbook(); - for params in [ - json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), - json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), - json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), - json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), - ] { - let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); - assert!(e.contains("unknown"), "{params}: {e}"); - } - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - for params in [ - json!({"range": "A1", "type": "any"}), - json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), - json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), - ] { - s.execute("data.validation", params).unwrap(); - } - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert_eq!(sh.validations.len(), 3); - assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs new file mode 100644 index 000000000..1b52e4537 --- /dev/null +++ b/crates/engine/src/cmd/data/filter.rs @@ -0,0 +1,319 @@ +use super::sort::sort_key; +use super::{ + AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param, + current_region, data_target, edit, json, +}; + +// ---------------------------------------------------------------- filter + +pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.filter")?; + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. + let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; + let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); + let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); + if let Some(name) = in_table { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { + t.filter_button = !t.filter_button; + if !t.filter_button { + t.filter.clear(); + } + } + Ok(Json::Null) + }); + } + if has { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); + sh.autofilter = None; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } + } + cx.structural = true; + Ok(json!({"filter": false})) + }); + } + install_filter(s, p) +} + +/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. +fn install_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, mut r) = data_target(s, p, "data.filter")?; + // A selected header row filters the data below it (#201). + if p.get("range").is_none() && r.height() == 1 { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let below = current_region(sh, r.start).end.row; + if below > r.end.row { + r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); + } + } + edit(s, |cx| { + cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); + Ok(json!({"filter": true, "range": r.a1()})) + }) +} + +pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let af = match &sh.autofilter { + Some(a) => a.clone(), + None => { + install_filter(s, &json!({}))?; + s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? + } + }; + let col = match col_param(p.get("column"), af.range.start.col) { + Some(c) => c, + // A number too large to be a column is outside the filter range, not a missing parameter. + None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), + None => return Err(bad("data.filterBy", "missing `column`")), + }; + if col < af.range.start.col || col > af.range.end.col { + return Err(bad("data.filterBy", "column outside the filter range")); + } + let off = col - af.range.start.col; + let crit = if bool_param(p, "clear").unwrap_or(false) { + None + } else if let Some(vals) = p.get("values").and_then(Json::as_array) { + Some(FilterCriterion::Values { + values: vals.iter().map(crate::cmd::edit::json_to_input).collect(), + blanks: bool_param(p, "blanks").unwrap_or(false), + }) + } else if let Some(c) = p.get("custom") { + let a = + (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default()); + let b = + c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default())); + Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) + } else if let Some(t) = p.get("top") { + Some(FilterCriterion::Top10 { + bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), + percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), + count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, + }) + } else { + bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.retain(|(o, _)| *o != off); + if let Some(c) = crit.clone() { + a.criteria.push((off, c)); + } + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + })?; + let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); + Ok(json!({"hiddenRows": hidden})) +} + +/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? +fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { + let num = gridcraft_core::parse::parse_number_text(crit); + let cmp = match (v.as_f64(), num) { + (Some(a), Some(b)) => a.partial_cmp(&b), + // A numeric ordering comparison only matches numbers; blanks and text never do. + (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, + _ => Some(gridcraft_core::compare_text(text, crit)), + }; + let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); + match op { + "=" => { + if crit.contains(['*', '?']) { + wild() + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if crit.contains(['*', '?']) { + !wild() + } else { + cmp != Some(Ordering::Equal) + } + } + ">" => cmp == Some(Ordering::Greater), + "<" => cmp == Some(Ordering::Less), + ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), + "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), + "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), + "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), + "contains" => text.to_lowercase().contains(&crit.to_lowercase()), + "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), + _ => true, + } +} + +/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. +fn filter_blank(v: &Value) -> bool { + v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) +} + +/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). +/// A table's own filter is left to the table, and a row typed after a blank row does not count. +/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. +pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { + return; + } + let Some(af) = sh.autofilter.as_ref() else { return }; + let r = af.range; + if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { + return; + } + let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); + if let Some(af) = sh.autofilter.as_mut() { + af.range = grown; + } +} + +/// Hides rows of the autofilter range that fail any criterion. +pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { + let Some(sh) = wb.sheet(sheet) else { return }; + let Some(af) = sh.autofilter.clone() else { return }; + let r = af.range; + let mut hide = Vec::new(); + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { + if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { + let col = r.start.col + off; + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(i, (thr, avg)); + } + } + for row in r.start.row + 1..=r.end.row { + let mut visible = true; + for (i, (off, crit)) in af.criteria.iter().enumerate() { + let c = CellRef::new(row, r.start.col + off); + let v = sh.value(c); + let text = crate::display::cell_text(wb, sh, c); + let ok = match crit { + FilterCriterion::Values { values, blanks } => { + if filter_blank(&v) { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) + } + } + FilterCriterion::Custom { a, b, and } => { + let x = custom_ok(&v, &text, &a.0, &a.1); + match b { + Some(b) => { + let y = custom_ok(&v, &text, &b.0, &b.1); + if *and { x && y } else { x || y } + } + None => x, + } + } + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); + if *bottom { n <= thr } else { n >= thr } + } + _ => false, + }, + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((_, avg))) => { + if *above { + n > *avg + } else { + n < *avg + } + } + _ => false, + }, + FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, + FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, + // Icons are not evaluated here: the row keeps its current visibility. + FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), + }; + if !ok { + visible = false; + break; + } + } + hide.push((row, !visible)); + } + let Some(shm) = wb.sheet_mut(sheet) else { return }; + for (row, h) in hide { + if h { + shm.rows.entry(row).or_default().hidden = true; + } else if let Some(e) = shm.rows.get_mut(&row) { + e.hidden = false; + } + } +} + +pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.clear(); + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Distinct display values of a filter column (for the dropdown list). +pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(af) = &sh.autofilter else { return vec![] }; + let off = col.saturating_sub(af.range.start.col); + let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); + let mut seen = std::collections::BTreeMap::new(); + for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { + let c = CellRef::new(row, col); + let v = sh.value(c); + let blank = filter_blank(&v); + let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; + let on = match selected { + Some(FilterCriterion::Values { values, blanks }) => { + if blank { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&t)) + } + } + _ => true, + }; + let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; + seen.entry((key, t)).or_insert(on); + } + seen.into_iter().map(|((_, t), on)| (t, on)).collect() +} diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs new file mode 100644 index 000000000..d2e183ae1 --- /dev/null +++ b/crates/engine/src/cmd/data/grouping.rs @@ -0,0 +1,270 @@ +use super::{ + CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines, + str_param, target_sheet, +}; + +// ---------------------------------------------------------------- outline + +fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { + if let Some(r) = str_param(p, "rows").and_then(parse_lines) { + return Ok((true, r.start.row, r.end.row)); + } + if let Some(r) = str_param(p, "cols").and_then(parse_lines) { + return Ok((false, r.start.col, r.end.col)); + } + let r = s.doc()?.selection.current(); + match str_param(p, "by") { + Some("rows") => Ok((true, r.start.row, r.end.row)), + Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), + _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), + _ => Ok((true, r.start.row, r.end.row)), + } +} + +pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { + let si = target_sheet(s, p)?; + // The selection belongs to the active sheet, so another sheet needs its lines. + if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { + return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); + } + let (rows, a, b) = outline_lines(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let b = b.min(a.saturating_add(1_048_575)); + for i in a..=b { + let e = map.entry(i).or_default(); + e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; + if !on && e.outline == 0 { + e.hidden = false; + } + } + // A summary line whose group is gone has nothing left to collapse. + let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; + if !on + && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) + && let Some(e) = summary.and_then(|i| map.get_mut(&i)) + { + e.collapsed = false; + } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it + Ok(Json::Null) + }) +} + +/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or +/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the +/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. +pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { + let (rows, a, b) = outline_lines(s, p)?; + let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); + edit(s, |cx| { + let si = cx.sheet_index(); + let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let all = outline::groups_with(map, before); + let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); + let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { + let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); + let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); + hits.into_iter().filter(|g| g.level == deepest).collect() + }; + let mut chosen = pick(&|g| g.start <= b && g.end >= a); + if chosen.is_empty() { + chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); + } + for g in chosen { + // Inner groups collapsed on their own (their summary marked), before this one changes. + let inner: Vec = all + .iter() + .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) + .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) + .copied() + .collect(); + for i in g.start..=g.end { + if let Some(e) = map.get_mut(&i) { + set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); + } + } + if !g.has_summary() { + continue; + } + if hide { + map.entry(g.summary()).or_default().collapsed = true; + } else if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as +/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the +/// group expanded and leave it invisible (#1714). +fn set_hidden(e: &mut LineInfo, hidden: bool) { + e.hidden = hidden; + if !hidden && e.size == Some(0.0) { + e.size = None; + } +} + +/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the +/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. +pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { + let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); + let (rows, cols) = (level("rows"), level("cols")); + if rows.is_none() && cols.is_none() { + return Err(EngineError::BadParams { + cmd: "data.showLevels".into(), + msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), + }); + } + edit(s, |cx| { + let si = cx.sheet_index(); + let sh = cx.sheet_mut(si)?; + let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); + for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { + let Some(n) = n else { continue }; + let groups = outline::groups_with(map, before); + for e in map.values_mut().filter(|e| e.outline > 0) { + set_hidden(e, e.outline >= n); + } + // Inner groups come first, so a summary line shared with the group around it ends up + // as that outer group says. + for g in groups.into_iter().filter(|g| g.has_summary()) { + map.entry(g.summary()).or_default().collapsed = g.level >= n; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns +/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. +/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new +/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost +/// group whose detail is all hidden, or the outermost when none is. +fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { + let mut marked: Vec = + outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect(); + // A mark on a line that nested groups share moves with the outermost of them whose + // detail is all hidden, or with the outermost when none is. + let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &marked { + let key = (!g.collapsed, g.level); + let best = owner.entry(g.summary()).or_insert(key); + *best = (*best).min(key); + } + marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); + for g in &marked { + if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + for g in marked { + let g = outline::OutlineGroup { summary_before: !on, ..g }; + if g.has_summary() { + map.entry(g.summary()).or_default().collapsed = true; + } + } +} + +pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { + let Some(on) = bool_param(p, key) else { continue }; + let was_before = outline::summary_before(sh, rows); + if rows { + sh.summary_below = on; + } else { + sh.summary_right = on; + } + if was_before != on { + continue; + } + move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); + } + Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) + }) +} + +pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.subtotal")?; + let header = bool_param(p, "header").unwrap_or(true); + let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); + let func = str_param(p, "function").unwrap_or("sum"); + let code = match func { + "count" => 3, + "average" => 1, + "max" => 4, + "min" => 5, + "product" => 6, + _ => 9, + }; + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| vec![r.end.col]); + let label = match func { + "count" => "Count", + "average" => "Average", + "max" => "Max", + "min" => "Min", + "product" => "Product", + _ => "Total", + }; + // Group boundaries (header in the first row). + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let mut groups: Vec<(u32, u32, String)> = Vec::new(); + let first = r.start.row + u32::from(header); + let mut start = first; + for row in first..=r.end.row + 1 { + let cur = sh.value(CellRef::new(row, group_col)).display(); + let prev = sh.value(CellRef::new(start, group_col)).display(); + if row > r.end.row || cur != prev { + groups.push((start, row - 1, prev)); + start = row; + } + } + // The totals go below each group, so the outline puts summary rows below their detail (the + // Subtotal dialog's "Summary below data"), in the same undo step. + if !sh.summary_below { + s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?; + } + // Insert a total row after each group, bottom-up so indices stay valid. + let mut inserted = 0; + for (g0, g1, name) in groups.iter().rev() { + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; + let row = g1 + 1; + let lbl = CellRef::new(row, group_col).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + s.execute( + "home.bold", + json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), + )?; + s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; + inserted += 1; + } + let last = r.end.row + inserted + 1; + let lbl = CellRef::new(last, group_col).a1(); + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + Ok(json!({"groups": inserted})) +} diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs new file mode 100644 index 000000000..a36527650 --- /dev/null +++ b/crates/engine/src/cmd/data/sort.rs @@ -0,0 +1,325 @@ +use super::{ + Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet, + Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet, +}; + +#[derive(Clone)] +struct SortKey { + col: u32, + desc: bool, + by: String, + list: Option>, +} + +/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, +/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. +fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + if let Some(t) = str_param(p, "range") + && let (Some(name), _) = split_sheet(t) + && d.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); + } + if sheet != d.wb.active_sheet() && p.get("range").is_none() { + return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); + } + Ok((sheet, data_range(s, p)?)) +} + +pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { + let (sheet, r) = sort_target(s, p)?; + let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = guess_header(&d.wb, sh, r); + do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) +} + +pub(super) fn sort(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = sort_target(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let by_cols = str_param(p, "orientation") == Some("columns"); + let case = bool_param(p, "matchCase").unwrap_or(false); + let mut keys: Vec = Vec::new(); + for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { + let col = if by_cols { + let row = match k.get("row").and_then(Json::as_u64) { + // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. + Some(n) => { + let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); + Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) + } + None => None, + }; + row.or_else(|| col_param(k.get("column"), r.start.row)) + } else { + col_param(k.get("column"), r.start.col) + }; + let Some(col) = col else { continue }; + keys.push(SortKey { + col, + desc: k.get("order").and_then(Json::as_str) == Some("desc"), + by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), + list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), + }); + } + if keys.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); + return ok(); + } + if keys.len() > 64 { + return Err(bad("data.sort", "too many sort levels (64 max)")); + } + do_sort(s, sheet, r, header, keys, by_cols, case) +} + +fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { + // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. + crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; + let body = if by_cols { + if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } + } else if header && r.height() > 1 { + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + }; + if body.count() > 20_000_000 { + return Err(bad("data.sort", "range too large")); + } + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; + if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { + return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); + } + let n = if by_cols { body.width() } else { body.height() }; + let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; + let theme = cx.wb.theme.clone(); + let styles = &cx.wb.styles; + let key_val = |i: u32, k: &SortKey| -> (Value, u32) { + let c = line(i, k.col); + let v = sh.value(c); + let color = match k.by.as_str() { + "cellColor" => { + styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + "fontColor" => { + styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + _ => 0, + }; + (v, color) + }; + let mut order: Vec = (0..n).collect(); + let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); + order.sort_by(|&a, &b| { + for (ki, k) in keys.iter().enumerate() { + let (va, ca) = &keyed[a as usize][ki]; + let (vb, cb) = &keyed[b as usize][ki]; + let o = if k.by != "values" { + ca.cmp(cb) + } else if let Some(list) = &k.list { + let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); + pos(va).cmp(&pos(vb)) + } else if case && va.is_text() && vb.is_text() { + sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) + } else { + sort_compare(va, vb) + }; + // Blanks always sort last, whatever the order. + let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; + if o != Ordering::Equal { + return o; + } + } + Ordering::Equal + }); + if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { + return Ok(Json::Null); + } + // Move whole lines (cells with formulas adjusted for relative references). + let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; + // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they + // describe — the way cell pictures and row heights do. Collect them before inserting, + // so a swap can't resurrect one from a destination that is also a source. + let side: Vec<(CellRef, Comment)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) + .collect(); + let links: Vec<(CellRef, Hyperlink)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) + .collect(); + let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); + let mut pictures = Vec::new(); + for (dst_i, &src_i) in order.iter().enumerate() { + for &k in &lanes { + let src = line(src_i, k); + let dst = line(dst_i as u32, k); + let mut cell = sh.cell(src).cloned(); + if let Some(c) = cell.as_mut() + && let Some(f) = &c.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); + c.formula = Some(Arc::new(Formula::from_expr(shifted))); + } + if let Some(picture) = sh.cell_pictures.get(&src) { + pictures.push((dst, picture.clone())); + } + moved.push((dst, cell)); + } + } + // Row heights travel with rows. + let heights: Vec> = + if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + // Floating objects anchored inside the sorted body travel with their lines, unless they + // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering + // is the inverse of `order`: `dst_of[src] = dst`. + let dst_of: Vec = { + let mut v = vec![0u32; order.len()]; + for (dst_i, &src_i) in order.iter().enumerate() { + if let Some(slot) = v.get_mut(src_i as usize) { + *slot = dst_i as u32; + } + } + v + }; + // The object's own geometry (its top-left anchor cell, offset and size) is read from the + // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it + // can be re-fitted to the lines it spans after the sort. + let objs: Vec = + sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); + let descs: Vec<(u32, u32, f32, f32)> = objs + .iter() + .map(|a| { + let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; + let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; + let tc = sh.col_at(x2); + let tr = sh.row_at(y2); + (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) + }) + .collect(); + let shm = cx.sheet_mut(sheet)?; + for (c, cell) in &moved { + match cell { + Some(x) => shm.set_cell(*c, x.clone()), + None => { + shm.remove_cell(*c); + } + } + } + // Every cell of the body is the destination of exactly one source cell, so clearing the + // body and inserting the permuted entries is the same permutation the cells went through. + for c in body.iter() { + shm.comments.remove(&c); + shm.hyperlinks.remove(&c); + } + for (at, cm) in side { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + shm.cell_pictures.extend(pictures); + for (i, h) in heights.into_iter().enumerate() { + let row = body.start.row + i as u32; + match h { + Some(info) => { + shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); + } + None => { + if let Some(e) = shm.rows.get_mut(&row) { + e.size = None; + } + } + } + } + // Re-anchor floating objects now that the row heights are final. `objs` was collected as + // images ++ charts ++ shapes, so the same index maps straight onto each vec. + // Only objects anchored inside the sorted block move: within its lines on the sort axis + // and within its span on the other axis (a picture beside the block stays put). + let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; + let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; + let ni = shm.images.len(); + let nch = shm.charts.len(); + for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { + let other = if by_cols { a.cell.row } else { a.cell.col }; + if other < o0 || other > o1 { + continue; + } + let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); + if na != a { + if i < ni { + if let Some(o) = shm.images.get_mut(i) { + o.anchor = na; + } + } else if i < ni + nch { + if let Some(o) = shm.charts.get_mut(i - ni) { + o.anchor = na; + } + } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { + o.anchor = na; + } + } + } + for (c, _) in moved { + cx.changed.push((sheet, c)); + } + cx.structural = true; + Ok(Json::Null) + })?; + Ok(json!({"sorted": body.a1()})) +} + +/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the +/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a +/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort +/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. +/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" +/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. +fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { + if a.mode == AnchorMode::Absolute { + return a; + } + let (tc, tr, tdx, tdy) = bottom; + let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; + let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; + if axis_start < b0 || axis_start > b1 { + return a; + } + let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; + if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { + let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; + let x1 = sh.col_left(nc) + a.dx as f64; + 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 } + } else { + Anchor { cell: CellRef::new(nr, nc), ..a } + } +} + +/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts +/// before uppercase at the first letter that differs in case. +fn case_order(a: &str, b: &str) -> Ordering { + a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) +} + +pub(super) fn sort_key(v: &Value) -> (u8, i64, String) { + match v { + Value::Number(n) => (0, (n * 1e6) as i64, String::new()), + Value::Text(t) => (1, 0, t.to_lowercase()), + Value::Bool(b) => (2, *b as i64, String::new()), + Value::Empty => (9, 0, String::new()), + _ => (3, 0, v.display()), + } +} diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs new file mode 100644 index 000000000..5b848e5c0 --- /dev/null +++ b/crates/engine/src/cmd/data/tests.rs @@ -0,0 +1,509 @@ +use super::*; + +#[test] +fn filter_blanks_include_empty_text_and_formula_results() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); + let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); + assert_eq!(r["hiddenRows"], json!(2)); + let vals = filter_values(&s.doc().unwrap().wb, 0, 1); + // The `=""` result joins (Blanks) instead of showing as its own empty entry. + assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); + let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); + assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); +} + +#[test] +fn subtotal_honours_header_false() { + // #131: with header:false the first data row was treated as a header and left out of its + // group's subtotal and the grand total. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!("East")); + assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); + assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); + assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); + assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row"); +} + +#[test] +fn subtotal_puts_summary_rows_below_their_detail() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.summary_below, "the totals sit below their groups"); + // East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing. + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row); + assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden"); + assert!(!hidden(&s, 3), "East Total stays visible"); + // One undo takes the subtotals and the setting back. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(!sh.summary_below); + assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); +} + +#[test] +fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); + for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { + let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); + assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); + } + let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); + assert_eq!(ok["hiddenRows"], json!(1)); +} + +#[test] +fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { + for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); + s.execute("selection.set", sel.clone()).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); + assert!(sh.tables[0].filter_button, "{sel}"); + assert!(!sh.is_row_hidden(1), "{sel}"); + assert!(sh.is_row_hidden(2), "{sel}"); + assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); + } +} + +#[test] +fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); + s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); + assert_eq!(range(&s), Some("A1:B4".to_string())); + s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A row typed after a blank row does not grow it. + s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A cell outside the filter's columns does not either. + s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // Empty text is a blank entry, not a value. + s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A formula and Ctrl+Enter (range.fill) are entries too. + s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); + assert_eq!(range(&s), Some("A1:B6".to_string())); + s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); + assert_eq!(range(&s), Some("A1:B7".to_string())); +} + +#[test] +fn list_validation_without_formula1_is_an_error() { + let mut s = Session::new(); + s.new_workbook(); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); +} + +#[test] +fn partial_validation_edits_keep_the_rest_of_the_old_rule() { + let mut s = Session::new(); + s.new_workbook(); + let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); + let ok = + |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true); + s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); + s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); + assert!(ok(&mut s, "B2", "999")); + assert!(!ok(&mut s, "B1", "999")); + assert!(!ok(&mut s, "B3", "999")); + s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); + for cell in ["B2", "B3", "B4"] { + assert!(ok(&mut s, cell, "150"), "{cell}"); + assert!(!ok(&mut s, cell, "5"), "{cell}"); + } + assert!(ok(&mut s, "B1", "5")); + assert!(!ok(&mut s, "B1", "150")); +} + +#[test] +fn showing_detail_gives_zero_width_columns_their_width_back() { + // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. + let mut s = Session::new(); + s.new_workbook(); + let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; + let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + for c in 2..=4 { + sh.cols.insert(c, hidden); + } + sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); + s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + for c in 2..=4 { + assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); + } + // Hiding again hides them; the level buttons show zero-width columns too. + s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); + Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); + s.execute("data.showLevels", json!({"cols": 2})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); +} + +#[test] +fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); +} + +#[test] +fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); +} + +/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). +fn nested_rows() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:7"})).unwrap(); + s +} + +/// (hidden, collapsed) of 0-based rows. +fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { + let sh = s.doc().unwrap().wb.active().unwrap(); + rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() +} + +#[test] +fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Each step is one undoable command. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); +} + +#[test] +fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); +} + +#[test] +fn show_levels_collapses_and_expands_to_a_level() { + let mut s = nested_rows(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showLevels", json!({"rows": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Columns are separate; no level at all is an error rather than a guess. + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.showLevels", json!({"cols": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); + assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); + assert_eq!(row_state(&s, 4..=4), [(false, false)]); + assert!(s.execute("data.showLevels", json!({})).is_err()); +} + +#[test] +fn group_by_columns_for_a_block_of_cells() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("data.group", json!({"by": "cols"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); + assert!(sh.rows.values().all(|l| l.outline == 0)); +} + +#[test] +fn group_shortcuts_are_the_windows_ones() { + let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); + assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); +} + +#[test] +fn summary_rows_above_the_detail_take_the_collapsed_mark() { + let mut s = nested_rows(); + let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); + // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // Hide Detail on the summary row collapses the group below it. + s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); + // Ungrouping the outer group clears its mark on the row above. + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); + assert_eq!(row_state(&s, 3..=3), [(false, false)]); +} + +#[test] +fn changing_the_summary_direction_moves_collapsed_marks() { + let mut s = nested_rows(); + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); + let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + assert!(col_marked(&s, 1) && !col_marked(&s, 5)); + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); + assert!(col_marked(&s, 1), "columns keep their direction"); + // One undo step per change; no params leaves the settings as they are. + s.execute("edit.undo", json!({})).unwrap(); + assert!(row_state(&s, 3..=3)[0].1); + assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); + // Nested groups that end on one row share its mark: it moves to the outer group only. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:8"})).unwrap(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // With only the inner group collapsed, its mark goes to row 5, and the outer group's + // Hide and Show Detail keep it collapsed. + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + // A group on the first row has no row above it to mark. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "1:3"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); + assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); +} + +#[test] +fn column_sort_rejects_row_key_zero_without_panicking() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + for row in [0u64, 1 << 32] { + let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); + let err = s.execute("data.sort", params).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); + } +} + +#[test] +fn single_row_and_column_parameters() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "C3"), json!(4.0)); + s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); + s.execute("data.group", json!({"cols": "C"})).unwrap(); + assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); + s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); + assert_eq!(v(&mut s, "B2"), json!(4.0)); +} + +#[test] +fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { + let cases: [(Json, [&str; 4]); 5] = [ + (json!([6, 3]), ["abc", "def", "ghij", ""]), + (json!([3, 3]), ["abc", "defghij", "", ""]), + (json!([0]), ["abcdefghij", "", "", ""]), + (json!([20]), ["abcdefghij", "", "", ""]), + (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), + ]; + for (widths, want) in cases { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); + s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); + assert_eq!(got, want, "{widths}"); + } +} + +#[test] +fn split() { + assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); + assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); + assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); +} + +#[test] +fn remove_duplicates_without_a_valid_column_is_refused() { + // `columns: []` (or only unusable names) used to compare nothing and delete every row + // but the first. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); + for columns in [json!([]), json!(["ZZZ"])] { + let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); + assert!(err.to_string().contains("columns"), "{err}"); + } + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); + assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); +} + +#[test] +fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { + // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. + // References, names and formulas stay unquoted, and every rule survives a save and reopen. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); + let rules = [ + ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), + ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), + ("C1", " 1,2,3 ", r#""1,2,3""#), + ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), + ("E1", "=$K$1:$K$3", "$K$1:$K$3"), + ("F1", "$K$1:$K$3", "$K$1:$K$3"), + ("G1", "=Colors", "Colors"), + ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), + ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), + ]; + for (cell, source, _) in rules { + s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); + } + let accepted = [ + ("A1", "In progress"), + ("B1", "Closed"), + ("C1", "2"), + ("D1", r#"Say "hi""#), + ("E1", "Green"), + ("F1", "Blue"), + ("G1", "Red"), + ("H1", "Green"), + ]; + let check = |wb: &Workbook| { + for (cell, input) in accepted { + let at = CellRef::parse(cell).unwrap(); + assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); + assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); + } + }; + let wb = s.doc().unwrap().wb.clone(); + check(&wb); + + let sheet_xml = |wb: &Workbook| { + let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); + xml + }; + let xml = sheet_xml(&wb); + for (cell, _, saved) in rules { + assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); + } + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + check(&reopened); + assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); +} + +#[test] +fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { + // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, + // with no error. + let mut s = Session::new(); + s.new_workbook(); + for params in [ + json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), + json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), + json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), + json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), + ] { + let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); + assert!(e.contains("unknown"), "{params}: {e}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + for params in [ + json!({"range": "A1", "type": "any"}), + json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), + json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), + ] { + s.execute("data.validation", params).unwrap(); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.validations.len(), 3); + assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); +} diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs new file mode 100644 index 000000000..c4ae43ef4 --- /dev/null +++ b/crates/engine/src/cmd/data/tools.rs @@ -0,0 +1,202 @@ +use super::{ + Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json, + ok, str_param, target_range, target_sheet, +}; + +// ---------------------------------------------------------------- tools + +pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + // Rows and columns past the data are blank: only the part with data is compared (whole + // columns compared a million blank rows and reported them as removed duplicates). + let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); + // With no column to compare, every row key is empty: the first row would survive and every + // other row would be deleted as a duplicate. A column outside the range compares data the + // user didn't select. Excel refuses both. + if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { + return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); + } + let start = r.start.row + header as u32; + let mut removed = 0; + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut seen = std::collections::HashSet::new(); + let mut keep: Vec = Vec::new(); + // Cells are compared by the text they show, as in Excel: the same date in two formats + // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. + let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; + for row in start..=r.end.row { + let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); + if seen.insert(key) { + keep.push(row); + } else { + removed += 1; + } + } + let pictures: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) + }) + .collect(); + // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that + // describe them move with them; the ones on removed rows are dropped with the row. + let notes: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) + }) + .collect(); + let links: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) + }) + .collect(); + let rows: Vec>> = + keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); + let shm = cx.sheet_mut(sheet)?; + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + shm.remove_cell(CellRef::new(row, c)); + } + } + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + let at = CellRef::new(row, c); + shm.comments.remove(&at); + shm.hyperlinks.remove(&at); + } + } + for (i, cells) in rows.into_iter().enumerate() { + for (j, cell) in cells.into_iter().enumerate() { + if let Some(cell) = cell { + shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); + } + } + } + shm.cell_pictures.extend(pictures); + for (at, cm) in notes { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + let remaining = (r.end.row + 1 - start) as usize - removed; + Ok(json!({"removed": removed, "remaining": remaining})) +} + +pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let sheet = target_sheet(s, p)?; + let mut delims: Vec = p + .get("delimiters") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) + .unwrap_or_else(|| vec!['\t']); + if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { + delims.push(o); + } + let fixed: Option> = + p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); + let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); + let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); + let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut out: Vec<(CellRef, String)> = Vec::new(); + for (i, row) in (r.start.row..=r.end.row).enumerate() { + let text = sh.value(CellRef::new(row, r.start.col)).display(); + let parts: Vec = match &fixed { + Some(w) => { + let chars: Vec = text.chars().collect(); + let mut cuts = vec![0]; + let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); + breaks.sort_unstable(); + breaks.dedup(); + cuts.extend(breaks); + cuts.push(chars.len()); + cuts.windows(2) + .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) + .collect() + } + None => split_delimited(&text, &delims, consecutive, quote), + }; + for (j, part) in parts.into_iter().enumerate() { + out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); + } + } + for (c, t) in out { + let cell = match crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb) { + Ok(cell) => cell, + // `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept + // as text rather than lost. + Err(_) if gridcraft_model::python::is_py_formula(&t) => { + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); + Some(Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style, py: None }) + } + Err(_) => None, + }; + let shm = cx.sheet_mut(sheet)?; + match cell { + Some(x) => shm.set_cell(c, x), + None => { + shm.remove_cell(c); + } + } + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + ok() +} + +pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + let mut last_delim = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == quote { + if in_q && chars.peek() == Some("e) { + cur.push(quote); + chars.next(); + } else { + in_q = !in_q; + } + continue; + } + if !in_q && delims.contains(&c) { + if consecutive && last_delim { + continue; + } + out.push(std::mem::take(&mut cur)); + last_delim = true; + continue; + } + last_delim = false; + cur.push(c); + } + out.push(cur); + out +} diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs new file mode 100644 index 000000000..ce0d7e3b8 --- /dev/null +++ b/crates/engine/src/cmd/data/validation.rs @@ -0,0 +1,293 @@ +use super::{ + Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad, + bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet, +}; + +// ---------------------------------------------------------------- validation + +fn op_param(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greater" | "greaterThan" => CfOperator::Greater, + "less" | "lessThan" => CfOperator::Less, + "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +/// `r` minus `cut`, as up to four non-overlapping rectangles. +fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { + let Some(i) = r.intersection(cut) else { return vec![r] }; + let mut out = Vec::new(); + if i.start.row > r.start.row { + out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); + } + if i.end.row < r.end.row { + out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); + } + if i.start.col > r.start.col { + out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); + } + if i.end.col < r.end.col { + out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); + } + out +} + +/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. +pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { + for dv in validations.iter_mut() { + for cut in cuts { + dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); + } + } + validations.retain(|d| !d.ranges.is_empty()); +} + +pub(super) fn validation(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let sheet = target_sheet(s, p)?; + if bool_param(p, "clear").unwrap_or(false) { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + Ok(Json::Null) + }); + } + let Some(kind) = str_param(p, "type") else { + s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); + return ok(); + }; + let kind = match kind { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + "any" => ValidationKind::Any, + // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. + other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), + }; + if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { + return Err(bad("data.validation", "a list validation needs `formula1`")); + } + const OPERATORS: [&str; 12] = [ + "between", + "notBetween", + "equal", + "notEqual", + "greater", + "greaterThan", + "less", + "lessThan", + "greaterOrEqual", + "greaterThanOrEqual", + "lessOrEqual", + "lessThanOrEqual", + ]; + if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { + return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); + } + let error_style = match str_param(p, "errorStyle") { + None | Some("stop") => ErrorStyle::Stop, + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), + }; + let f1 = str_param(p, "formula1").unwrap_or(""); + let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; + let dv = Validation { + ranges: ranges.clone(), + kind, + op: op_param(str_param(p, "operator")), + f1, + f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), + allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), + in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), + input_title: str_param(p, "inputTitle").unwrap_or("").into(), + input_message: str_param(p, "inputMessage").unwrap_or("").into(), + show_input: true, + error_title: str_param(p, "errorTitle").unwrap_or("").into(), + error_message: str_param(p, "errorMessage").unwrap_or("").into(), + error_style, + show_error: true, + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + sh.validations.push(dv.clone()); + Ok(Json::Null) + }) +} + +/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather +/// than a reference or a name to evaluate. +fn is_literal_list(f: &str) -> bool { + f.starts_with('"') + || (!f.contains('!') + && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) +} + +/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's +/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves +/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a +/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. +fn list_source(f: &str) -> String { + let t = f.trim(); + if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { + return f.trim_start_matches('=').to_string(); + } + format!("\"{}\"", t.replace('"', "\"\"")) +} + +/// List entries of a list validation (literal or from a range). +pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { + let f = dv.f1.trim(); + if is_literal_list(f) { + // A quoted list is a string literal: `""` inside stands for one `"`. + let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { + Some(body) => body.replace("\"\"", "\""), + None => f.trim_matches('"').to_string(), + }; + return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + } + let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); + match gridcraft_calc::evaluate(wb, sheet, at, f) { + Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), + v if !v.is_empty() && !v.is_error() => vec![v.display()], + _ => vec![], + } +} + +/// Checks typed input against the validation of a cell. `Ok(None)` = valid. +pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { + let sh = wb.sheet(sheet)?; + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; + if dv.kind == ValidationKind::Any || !dv.show_error { + return None; + } + if input.is_empty() && dv.allow_blank { + return None; + } + let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; + // The formulas are written for the first cell of the range and see the value being entered, + // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). + let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let entered; + let wb = if sh.value(at) == v { + wb + } else { + let mut w = wb.clone(); + if let Some(sh) = w.sheet_mut(sheet) { + let style = sh.style_id(at); + sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); + } + entered = w; + &entered + }; + let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); + let num = |f: &str| eval(f).to_number().ok(); + let ok = match dv.kind { + ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), + ValidationKind::TextLength => { + let n = input.chars().count() as f64; + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + _ => match v.as_f64() { + Some(n) => { + if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { + false + } else { + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + } + None => false, + }, + }; + if ok { + return None; + } + let msg = if dv.error_message.is_empty() { + "This value doesn't match the data validation restrictions defined for this cell.".to_string() + } else { + dv.error_message.clone() + }; + Some((dv.clone(), msg)) +} + +/// A formula written for `first`, as seen from `at` (relative references shifted). +fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { + if first == at { + return f.to_string(); + } + match gridcraft_formula::parse(f.trim_start_matches('=')) { + Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( + e, + at.row as i64 - first.row as i64, + at.col as i64 - first.col as i64, + )), + Err(_) => f.to_string(), + } +} + +/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` +/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. +pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { + if first == to { + return f.to_string(); + } + let moved = formula_at(f, first, to); + let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); + let eq = if f.starts_with('=') { "=" } else { "" }; + if has_relative { format!("{eq}{moved}") } else { f.to_string() } +} + +fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { + let Some(a) = a else { return false }; + match op { + CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), + CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), + CfOperator::Equal => n == a, + CfOperator::NotEqual => n != a, + CfOperator::Greater => n > a, + CfOperator::Less => n < a, + CfOperator::GreaterOrEqual => n >= a, + CfOperator::LessOrEqual => n <= a, + } +} + +pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let input = str_param(p, "input").unwrap_or(""); + match check_validation(&d.wb, d.wb.active_sheet(), at, input) { + None => Ok(json!({"ok": true})), + Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), + } +} + +pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let mut bad_cells = Vec::new(); + for dv in &sh.validations { + for r in &dv.ranges { + let r = sh.used_range().and_then(|u| r.intersection(&u)); + let Some(r) = r else { continue }; + for c in r.iter().take(100_000) { + let text = sh.input_text(c); + if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { + bad_cells.push(c.a1()); + } + } + } + } + Ok(json!({"invalid": bad_cells})) +} diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a86be3099..f6184509c 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,10 +1,10 @@ //! Draw tab: pen and eraser tools, ink strokes, Ink to Shape. use gridcraft_core::CellRef; -use gridcraft_model::*; +use gridcraft_model::{Anchor, Color, Shape, ShapeKind}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64b26b022..3bba84748 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,10 +4,40 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc, + locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; +mod cell; +mod clipboard; +mod fill; +mod find; +mod go_to; +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}; +#[cfg(test)] +pub(crate) use clipboard::parse_tsv; +use clipboard::{clear_clipboard, copy, cut, paste}; +use fill::{auto_fill, fill_dir, fill_series, flash_fill}; +#[cfg(test)] +use find::replace_ci; +pub(crate) use find::wildcard; +use find::{find, replace}; +pub(super) use go_to::go_to; +pub use go_to::go_to_names; +use go_to::go_to_special; +#[cfg(test)] +use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; +use paste_special::paste_special; +pub(crate) use paste_special::{rewrite_all_formulas, rewrite_python_refs}; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; + pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), @@ -116,667 +146,6 @@ pub fn specs() -> Vec { ] } -// ---------------------------------------------------------------- entry - -/// Typing `=PY(` doesn't make a Python cell; this says what does. -pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…)."; - -/// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Inherits the destination's cell, row or column style, including its number -/// format, before parsing. Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - if input.is_empty() { - let c = Cell { value: Value::Empty, formula: None, style, py: None }; - return Ok(if c.is_blank() { None } else { Some(c) }); - } - let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; - if fmt_is_text { - return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None })); - } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula && gridcraft_model::python::is_py_formula(input) { - return Err(EngineError::Other(PY_FORMULA.into())); - } - if is_formula { - let body = input.strip_prefix('=').unwrap_or(input); - let expr = wb.name_call_case(parse_typed_formula(body)?); - check_arg_counts(&expr)?; - let mut cell = Cell::formula(Formula::from_expr(expr)); - cell.style = style; - // Formulas whose result is a date/time get a format from functions like TODAY(). - if wb.styles.get(style).num_fmt.as_str() == "General" { - let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); - let auto = if f.starts_with("TODAY(") - || f.starts_with("DATE(") - || f.starts_with("EDATE(") - || f.starts_with("EOMONTH(") - || f.starts_with("WORKDAY(") - { - Some("m/d/yyyy") - } else if f.starts_with("NOW(") { - Some("m/d/yyyy h:mm") - } else if f.starts_with("TIME(") { - Some("h:mm AM/PM") - } else { - None - }; - if let Some(code) = auto { - cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - return Ok(Some(cell)); - } - let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); - // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already - // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. - if parsed.format.is_none() - && !input.contains('%') - && wb.styles.get(style).num_fmt.as_str().ends_with('%') - && let Value::Number(n) = parsed.value - && n.abs() >= 1.0 - { - parsed.value = Value::Number(n / 100.0); - } - let mut style = style; - if let Some(code) = parsed.format { - let cur = wb.styles.get(style).num_fmt.as_str().to_string(); - if cur == "General" { - style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - // Wrap text automatically when the input has line breaks (Alt+Enter). - if input.contains('\n') && !wb.styles.get(style).align.wrap { - style = wb.styles.derive(style, |s| s.align.wrap = true); - } - Ok(Some(Cell { value: parsed.value, formula: None, style, py: None })) -} - -/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, -/// and refuses a formula that still doesn't parse. -pub(crate) fn parse_typed_formula(body: &str) -> Result { - // Excel closes missing parentheses for you. - let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); - parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) -} - -/// Like Excel, a formula that calls a built-in function with too few or too many arguments is -/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't -/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and -/// unknown functions are skipped. -pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { - let mut bad = None; - expr.walk(&mut |e| { - if bad.is_none() - && let gridcraft_formula::Expr::Call(name, args) = e - && let Some(spec) = gridcraft_functions::lookup(name) - && !spec.accepts(args.len()) - { - let few = args.len() < spec.min_args; - bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); - } - }); - match bad { - Some(msg) => Err(EngineError::Other(msg)), - None => Ok(()), - } -} - -fn cell_set(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = match cell_param_on(s, p, "cell", sheet)? { - Some(c) => c, - None => s.doc()?.selection.active, - }; - // Both spellings take a string, number or boolean, as typed (#697). - let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; - let array = bool_param(p, "array").unwrap_or(false); - let d = s.doc()?; - if let Some(sh) = d.wb.sheet(sheet) - && sh.is_protected() - && d.wb.styles.get(sh.style_id(at)).protection.locked - && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) - { - return Err(EngineError::Other(m)); - } - let active_sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - if array { - // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds - // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. - let selected = cx.sel.current(); - let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; - // A table header can't hold an array's value, and a table resizes under it. - if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { - return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); - } - let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); - if let Some(f) = cell.formula.as_mut() { - std::sync::Arc::make_mut(f).array = Some(range); - } - let sh = cx.sheet_mut(sheet)?; - // The array takes the whole range: other cells in it lose their contents. - for c in range.iter().skip(1).take(1_000_000) { - if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) { - let style = old.style; - sh.set_cell(c, Cell { style, ..Cell::default() }); - } - } - sh.set_cell(range.start, cell); - cx.touch(sheet, range.start); - return Ok(Json::Null); - } - let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - Ok(Json::Null) - })?; - let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); - Ok(json!({"cell": at.a1(), "value": v.display()})) -} - -pub(crate) fn json_to_input(v: &Json) -> String { - match v { - Json::Null => String::new(), - Json::Bool(b) => { - if *b { - "TRUE".into() - } else { - "FALSE".into() - } - } - Json::Number(n) => n.to_string(), - Json::String(s) => s.clone(), - other => other.to_string(), - } -} - -fn range_set_values(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let start = target_range(s, p)?.start; - let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; - // A string is run through typed input by default, which reads it with en-US rules: a caller - // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence - // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas - // are unaffected either way. - let parse = match p.get("parse") { - None | Some(Json::Null) => true, - Some(Json::Bool(b)) => *b, - Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), - }; - if rows.len() > MAX_ROWS as usize { - return Err(bad("range.setValues", "too many rows")); - } - let mut count = 0; - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - let cols: Vec<&Json> = match row.as_array() { - Some(a) => a.iter().collect(), - None => vec![row], - }; - for (ci, v) in cols.iter().enumerate() { - let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; - let input = json_to_input(v); - let cell = match v { - Json::Number(n) => Some(Cell { - value: Value::number(n.as_f64().unwrap_or(0.0)), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - py: None, - }), - Json::Bool(b) => Some(Cell { - value: Value::Bool(*b), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - py: None, - }), - // Text as given: no date or number reading, and a leading `'` is part of the - // text rather than the marker that makes it text. A formula is still a - // formula, which is what the ticket asks for and what `=` means in a cell. - 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); - } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None }) - } - _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, - }; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - count += 1; - } - } - Ok(()) - })?; - Ok(json!({"cells": count})) -} - -fn range_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - let input = str_param(p, "input").unwrap_or("").to_string(); - let origin = s.doc()?.selection.active; - edit(s, |cx| { - // Each cell gets what typing the input there gives: the automatic number format where - // it's General, its own format otherwise, text where it's Text. That depends only on - // the cell's style, so the input is parsed once per style. - let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); - for r in &ranges { - if r.count() > 2_000_000 { - return Err(bad("range.fill", "range too large")); - } - for at in r.iter() { - let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - let base = match by_style.get(&style) { - Some(c) => c.clone(), - None => { - let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; - by_style.insert(style, c.clone()); - c - } - }; - let mut cell = base.unwrap_or_default(); - if let Some(f) = &cell.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); - cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); - } - let sh = cx.sheet_mut(sheet)?; - sh.set_cell(at, cell); - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - } - } - Ok(Json::Null) - }) -} - -// ---------------------------------------------------------------- selection - -/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. -fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { - let Some(p) = sheet.protection.as_ref() else { return true }; - if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } -} - -fn selection_set(s: &mut Session, p: &Json) -> Result { - if p.get("sheet").is_some() { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) - && let (Some(_), _) = split_sheet(r) - { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } - let ranges = target_ranges(s, p)?; - let active = cell_param(s, p, "active")?; - let d = s.doc_mut()?; - let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); - let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; - if let Some(sh) = d.wb.active() { - sel.expand_merges(sh); - if !cell_selectable(&d.wb, sh, sel.active) { - return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); - } - } - d.selection = sel; - Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) -} - -impl crate::DocState { - /// Switches the active sheet, remembering each sheet's selection. - pub fn wb_switch_sheet(&mut self, i: usize) { - if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { - return; - } - let cur = self.wb.active_sheet(); - if self.sheet_selections.len() < self.wb.sheets.len() { - self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); - } - if let Some(slot) = self.sheet_selections.get_mut(cur) { - *slot = self.selection.clone(); - } - // View state: set in place, so the workbook is neither copied nor made dirty. - self.wb.set_active_sheet(i); - self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); - } -} - -fn selection_move(s: &mut Session, p: &Json) -> Result { - let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); - let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); - let extend = bool_param(p, "extend").unwrap_or(false); - let jmp = bool_param(p, "jump").unwrap_or(false); - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let sel = &mut d.selection; - // Extension moves the far corner, not the active cell. - let from = if extend { sel.moving_end() } else { sel.active }; - // Skip hidden rows/columns and step over merged areas. - let mut to = if jmp { - jump(sheet, from, dr.signum(), dc.signum()) - } else { - let mut t = from.offset_clamped(dr, dc); - let mut guard = 0; - while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { - let n = t.offset_clamped(dr.signum(), dc.signum()); - if n == t { - break; - } - t = n; - guard += 1; - } - if !extend && let Some(m) = sheet.merge_at(from) { - // Leaving a merged cell moves from its edge. - if dr > 0 { - t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); - } else if dc > 0 { - t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); - } - } - t - }; - if let Some(m) = sheet.merge_at(to) - && !extend - { - to = m.start; - } - if !extend && !cell_selectable(&d.wb, sheet, to) { - // Step on to the next selectable cell in the same direction; stay put if there is none. - let (sr, sc) = (dr.signum(), dc.signum()); - let mut t = to; - let mut found = false; - if !jmp && (sr, sc) != (0, 0) { - for _ in 0..10_000 { - let n = t.offset_clamped(sr, sc); - if n == t { - break; - } - t = n; - if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { - found = true; - break; - } - } - } - if !found { - return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); - } - to = t; - } - if extend { - sel.extend_to(to); - sel.expand_merges(sheet); - } else { - *sel = crate::Selection::at(to); - if let Some(m) = sheet.merge_at(to) { - sel.ranges = vec![m]; - } - } - Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) -} - -fn selection_next(s: &mut Session, p: &Json) -> Result { - let fwd = bool_param(p, "forward").unwrap_or(true); - let by_row = bool_param(p, "byRow").unwrap_or(false); - let d = s.doc_mut()?; - d.selection.cycle(fwd, by_row); - Ok(json!({"active": d.selection.active.a1()})) -} - -fn select_all(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let region = current_region(sheet, d.selection.active); - let all = RangeRef::all(); - let r = if region.is_single() || d.selection.current() == region { all } else { region }; - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_region(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let r = current_region(sheet, d.selection.active); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_row(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_col(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; - Ok(json!({"selection": d.selection.a1()})) -} - -/// How many references the Go To dialog keeps for going back to. -const GO_TO_RECENT: usize = 10; -/// The most areas one Go To selects. -const GO_TO_MAX_AREAS: usize = 2000; -/// How deep a name that refers to other names is followed. -const GO_TO_NAME_DEPTH: usize = 8; - -pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "list") == Some(true) { - let d = s.doc()?; - return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent})); - } - let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; - let d = s.doc()?; - let mut target: Option = None; - let mut areas: Vec = Vec::new(); - let mut named = false; - for part in reference_parts(reference) { - let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else { - return Err(EngineError::Other("Reference isn't valid.".into())); - }; - // One selection holds the areas of one sheet. - if target.is_some_and(|t| t != sheet) { - return Err(EngineError::Other("Reference isn't valid.".into())); - } - target = Some(sheet); - named |= by_name; - areas.extend(found); - if areas.len() > GO_TO_MAX_AREAS { - return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); - } - } - let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; - let range = areas.iter().map(RangeRef::a1).collect::>().join(","); - let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; - if bool_param(p, "record") == Some(true) && !named { - let d = s.doc_mut()?; - let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); - let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); - d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); - d.go_to_recent.insert(0, entry); - d.go_to_recent.truncate(GO_TO_RECENT); - } - Ok(out) -} - -/// The parts of a Go To reference, split at the commas outside quoted sheet names and -/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller -/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. -fn reference_parts(text: &str) -> impl Iterator { - let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); - let mut chars = text.char_indices(); - std::iter::from_fn(move || { - if done { - return None; - } - for (i, c) in chars.by_ref() { - match c { - '\'' => quoted = !quoted, - '(' if !quoted => depth += 1, - ')' if !quoted => depth = depth.saturating_sub(1), - ',' if !quoted && depth == 0 => { - let part = text.get(start..i).unwrap_or("").trim(); - start = i + 1; - return Some(part); - } - _ => {} - } - } - done = true; - Some(text.get(start..).unwrap_or("").trim()) - }) -} - -/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, -/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. -/// A name counts when what it refers to is a reference; a constant or a formula is not one. -fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { - if part.is_empty() || depth > GO_TO_NAME_DEPTH { - return None; - } - let (prefix, body) = split_sheet(part); - let on = match &prefix { - Some(n) => wb.sheet_index(n)?, - None => sheet, - }; - if let Some(r) = RangeRef::parse(&body.replace('$', "")) { - return Some((on, vec![r], false)); - } - let name = match prefix { - Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)), - None => wb.name(body, sheet), - }; - if let Some(n) = name { - return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); - } - if prefix.is_none() - && let Some((si, ti)) = wb.table(body) - { - // As in Excel, a table's name stands for its data, without the header and totals rows. - let t = wb.sheet(si)?.tables.get(ti)?; - return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); - } - None -} - -/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a -/// reference. -fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { - let from = n.scope.unwrap_or(sheet); - let formula = n.formula.trim().trim_start_matches('='); - let mut target: Option = None; - let mut areas = Vec::new(); - for p in reference_parts(formula) { - let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; - if target.is_some_and(|t| t != s) { - return None; - } - target = Some(s); - areas.extend(found); - if areas.len() > GO_TO_MAX_AREAS { - return None; - } - } - target.map(|t| (t, areas)) -} - -/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and -/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, -/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that -/// do not refer to a range are left out. -pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { - let own: std::collections::HashSet = - wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); - let mut out: Vec = wb - .names - .iter() - .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) - .filter_map(|n| match n.scope { - None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None, - None => Some(n.name.clone()), - Some(s) if s == sheet => Some(n.name.clone()), - Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), - }) - .collect(); - out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n)); - out.dedup_by(|a, b| gridcraft_formula::same_name(a, b)); - out -} - -fn go_to_special(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("blanks"); - let d = s.doc()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; - if kind == "lastCell" { - let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); - return selection_set(s, &json!({"cell": c.a1()})); - } - if kind == "currentRegion" { - return select_region(s, &json!({})); - } - let mut hits: Vec = Vec::new(); - if scope.count() > 4_000_000 { - return Err(bad("edit.goToSpecial", "range too large")); - } - for c in scope.iter() { - let cell = sheet.cell(c); - let v = sheet.value(c); - let ok = match kind { - "blanks" => v.is_empty(), - "constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()), - "formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()), - "comments" | "notes" => sheet.comments.contains_key(&c), - "errors" => v.is_error(), - "numbers" => v.is_number(), - "text" => v.is_text(), - "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), - "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), - "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), - _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), - }; - if ok { - hits.push(c); - } - } - if hits.is_empty() { - return Err(EngineError::Other("No cells were found.".into())); - } - let ranges = coalesce(&hits); - let d = s.doc_mut()?; - d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; - Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) -} - /// Merges cells into vertical runs per column (a compact multi-area selection). fn coalesce(cells: &[CellRef]) -> Vec { let mut sorted = cells.to_vec(); @@ -842,690 +211,15 @@ fn redo(s: &mut Session, p: &Json) -> Result { Ok(json!({"redone": label})) } -// ---------------------------------------------------------------- clipboard - -fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { - let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - if ranges.len() > 1 && cut { - return Err(multiple()); - } - // The selection as made (untrimmed), for Insert Copied Cells. - let selected = ranges.first().copied().unwrap_or_default(); - let d = s.doc()?; - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let trim = |range: RangeRef| { - if range.is_full_cols() || range.is_full_rows() { - let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); - range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) - } else { - range - } - }; - let (wb, range, areas) = if ranges.len() > 1 { - let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); - let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; - (std::sync::Arc::new(wb), block, areas) - } else { - (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) - }; - let text = crate::display::range_text(&wb, sheet, range); - // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and - // scripts get it only when they ask for it. - let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; - s.clipboard = Some(clip); - let mut result = json!({"range": range.a1(), "text": text}); - if let Some(html) = html { - result["html"] = Json::String(html); - } - Ok(result) -} - -/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they -/// slide together side by side, left to right) or all cover the same columns (stacked, top to -/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed -/// into one block at the first area's place, and that block. Formulas travel as their values, as -/// in Excel. `None` when the areas don't line up. -fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { - let first = *areas.first()?; - let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); - let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); - let mut sorted = areas.to_vec(); - if across { - sorted.sort_by_key(|a| a.start.col); - } else if down { - sorted.sort_by_key(|a| a.start.row); - } else { - return None; - } - if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { - return None; - } - let anchor = sorted.first()?.start; - let (h, w) = if across { - (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) - } else { - (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) - }; - let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); - let src = wb.sheet(sheet)?; - let mut out = wb.clone(); - let dst = out.sheet_mut(sheet)?; - dst.take_cells(block); - dst.spill.retain(|c, _| !block.contains(*c)); - dst.spill_ranges.retain(|c, _| !block.contains(*c)); - dst.comments.retain(|c, _| !block.contains(*c)); - dst.merges.retain(|m| !m.intersects(&block)); - if across { - dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); - } else { - dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); - } - let mut next = if across { anchor.col } else { anchor.row }; - for a in &sorted { - let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; - let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); - for c in cells { - let Some(to) = c.offset(dr, dc) else { continue }; - let style = src.cell(c).map(|x| x.style).unwrap_or_default(); - dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None }); - if let Some(picture) = src.cell_pictures.get(&c) { - dst.cell_pictures.insert(to, picture.clone()); - } - } - for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { - if let Some(to) = c.offset(dr, dc) { - dst.comments.insert(to, comment.clone()); - } - } - for m in src.merges.iter().filter(|m| a.contains_range(m)) { - if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - dst.merges.push(RangeRef::new(s, e)); - } - } - // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. - if across { - for (c, info) in src.cols.range(a.start.col..=a.end.col) { - dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); - } - } else { - for (r, info) in src.rows.range(a.start.row..=a.end.row) { - dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); - } - } - next = next.saturating_add(if across { a.width() } else { a.height() }); - } - Some((out, block)) -} - -fn copy(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, false) -} -fn cut(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, true) -} -fn clear_clipboard(s: &mut Session, _: &Json) -> Result { - s.clipboard = None; - s.format_painter = None; - ok() -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum What { - All, - Formulas, - Values, - Formats, - Comments, - Validation, - AllExceptBorders, - ColumnWidths, - FormulasAndNumberFormats, - ValuesAndNumberFormats, -} - -fn paste(s: &mut Session, p: &Json) -> Result { - // External text (from the system clipboard) when it differs from ours. - if let Some(text) = str_param(p, "text") - && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) - { - return paste_text(s, p, text); - } - if s.clipboard.is_none() { - return Err(EngineError::Other("Nothing to paste.".into())); - } - paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) -} - -fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); - let rows: Vec> = parse_tsv(text); - let h = rows.len(); - let w = rows.iter().map(Vec::len).max().unwrap_or(0); - // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read - // only as far as the text's own shape, so spans in the markup can't expand without bound. - let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - for (ci, v) in row.iter().enumerate() { - let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); - if let Some(cl) = cell.as_mut() { - numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); - } - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(sheet, c); - } - } - if h > 0 && w > 0 { - let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - } - Ok(()) - })?; - Ok(json!({"rows": h, "cols": w})) -} - -/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the -/// source recorded for it in its HTML, with a format that shows the same text when one can be -/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated -/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. -fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { - let Some(hc) = html else { return }; - let Some(n) = hc.num else { return }; - if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { - return; - } - if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { - return; - } - cell.value = Value::number(n); - if wb.styles.get(cell.style).num_fmt.as_str() == "General" - && let Some(code) = crate::clip_html::format_for(field, n) - { - cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); - } -} - -/// Tab-separated text with Excel-style quoting. -pub(crate) fn parse_tsv(text: &str) -> Vec> { - let mut rows = Vec::new(); - let mut row = Vec::new(); - let mut field = String::new(); - let mut chars = text.chars().peekable(); - let mut quoted = false; - let mut at_start = true; - while let Some(c) = chars.next() { - if quoted { - if c == '"' { - if chars.peek() == Some(&'"') { - field.push('"'); - chars.next(); - } else { - quoted = false; - } - } else { - field.push(c); - } - continue; - } - match c { - '"' if at_start => { - quoted = true; - at_start = false; - } - '\t' => { - row.push(std::mem::take(&mut field)); - at_start = true; - } - '\r' => {} - '\n' => { - row.push(std::mem::take(&mut field)); - rows.push(std::mem::take(&mut row)); - at_start = true; - } - _ => { - field.push(c); - at_start = false; - } - } - } - if !field.is_empty() || !row.is_empty() { - row.push(field); - rows.push(row); - } - rows -} - -fn paste_special(s: &mut Session, p: &Json) -> Result { - let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; - let what = match str_param(p, "what").unwrap_or("all") { - "all" => What::All, - "formulas" => What::Formulas, - "values" => What::Values, - "formats" => What::Formats, - "comments" | "notes" => What::Comments, - "validation" => What::Validation, - "allExceptBorders" => What::AllExceptBorders, - "columnWidths" => What::ColumnWidths, - "formulasAndNumberFormats" => What::FormulasAndNumberFormats, - "valuesAndNumberFormats" => What::ValuesAndNumberFormats, - other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), - }; - let op = str_param(p, "operation").unwrap_or("none").to_string(); - let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); - let transpose = bool_param(p, "transpose").unwrap_or(false); - let link = bool_param(p, "link").unwrap_or(false); - if link && !clip.areas.is_empty() { - return Err(EngineError::Other("This action won't work on multiple selections.".into())); - } - let dest_sheet = target_sheet(s, p)?; - let sel = s.doc()?.selection.current(); - let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); - let src = clip.range; - let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; - // Tile the copy over a larger destination that is a multiple of its size. - let tile = sel.height() % h == 0 - && sel.width() % w == 0 - && !sel.is_single() - && cell_param_on(s, p, "at", dest_sheet)?.is_none() - && !sel.is_full_cols() - && !sel.is_full_rows() - && sel.count() <= 4_000_000; - let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; - let same_doc = s.doc()?.uid == clip.doc_uid; - let cut_move = clip.cut && same_doc && what == What::All; - let src_wb = clip.wb.clone(); - let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; - let src_sheet_name = src_sheet.name.clone(); - if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { - // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. - return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); - } - edit(s, |cx| { - // Styles from another workbook must be re-interned. - let mut style_map = std::collections::HashMap::new(); - let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { - if same_doc { - return id; - } - *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) - }; - let mut moved_formulas = Vec::new(); - let mut moved_pictures = std::collections::BTreeMap::new(); - if cut_move { - // Remove the source first (contents and formats), and fix references to it. - let source = cx.sheet_mut(clip.sheet)?; - moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); - let taken = source.take_cells(src); - // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them - // with the block: the destination is written from the clipboard snapshot taken - // before the cut, so removing them here loses nothing and leaves the old place - // clean instead of leaving one merged block and one note behind. - source.comments.retain(|c, _| !src.contains(*c)); - source.hyperlinks.retain(|c, _| !src.contains(*c)); - source.merges.retain(|m| !src.contains_range(m)); - for (c, _) in &taken { - cx.touch(clip.sheet, *c); - } - moved_formulas = taken; - } - for tr in 0..tiles_r { - for tc in 0..tiles_c { - for r in 0..src.height() { - for c in 0..src.width() { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { - moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) - } else { - src_sheet.cell(sc).cloned() - }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks - && src_picture.is_none() - && src_val.is_empty() - && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) - { - continue; - } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // The array range moves with its formula. - let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); - moved.dynamic = f.dynamic; - return Some(std::sync::Arc::new(moved)); - } - let moved = - gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - // A Python cell's code travels with it. Cut to another sheet, it keeps - // reading the cells its `xl()` references named on its original sheet, as - // a moved formula does: they are qualified with that sheet before the - // workbook-wide move adjustment below. - let moved_py = |cell: &Cell| -> Option> { - let py = cell.py.clone()?; - if !cut_move || dest_sheet == clip.sheet { - return Some(py); - } - let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { - let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }); - Some(gridcraft_formula::print(&e)) - }); - Some(match qualified { - Some(code) => std::sync::Arc::new(gridcraft_model::PyCode { code, ..(*py).clone() }), - None => py, - }) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) - }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new.py = None; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell); - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) - } else { - st - }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - new.py = moved_py(&sc_cell); - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; - new.py = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - && new.py.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); - } - let a = old.value.clone(); - let b = new.value.clone(); - // Numbers stored as text take part (multiplying by 1 turns them into - // numbers); other text and errors stay as they are. - if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) - && (a.is_number() || a.is_empty() || a.is_text()) - && (b.is_number() || b.is_empty()) - { - new.value = match op.as_str() { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" => { - if y == 0.0 { - Value::Error(gridcraft_core::CellError::Div0) - } else { - Value::number(x / y) - } - } - _ => b.clone(), - }; - } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { - new.value = a; - } - // A formula already there is kept and combined with the pasted number. - if let Some(f) = &old.formula - && let Ok(y) = b.to_number() - && (b.is_number() || b.is_empty()) - && let Some(sym) = match op.as_str() { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } - { - let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } - } - } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); - dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); - } - } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); - } - cx.sheet_mut(dest_sheet)?.set_cell(dest, new); - } - _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); - } - } - } - } - // Merged areas inside the copied block travel with it. - if matches!(what, What::All | What::Formats) && !transpose { - let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); - for m in merges { - let dr = at.row as i64 - src.start.row as i64; - let dc = at.col as i64 - src.start.col as i64; - if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - let nm = RangeRef::new(a, b); - let sh = cx.sheet_mut(dest_sheet)?; - sh.merges.retain(|x| !x.intersects(&nm)); - sh.merges.push(nm); - } - } - } - if cut_move { - // References elsewhere to the moved block now point to its new place. - let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); - let e = if dest_sheet == clip.sheet { - gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } - } else { - gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } - }; - rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); - cx.structural = true; - } - let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - Ok(()) - })?; - // Formulas and validation from an untrusted file leave the destination untrusted; plain values, - // formats (cell styles and merged areas, no conditional formats), column widths and notes - // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, - // except for column widths and notes, which write no cells at all. - let passive = - matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); - if !same_doc && !passive { - let d = s.doc_mut()?; - d.trust = d.trust.receive(clip.trust); - } - if cut_move { - s.clipboard = None; - } - Ok(json!({"pasted": s.doc()?.selection.a1()})) -} - -/// Applies a reference adjustment to every cell formula and defined name in the workbook. -pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { - use gridcraft_formula::adjust::Edit; - // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too - // (a cut moves its arrays itself). - let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); - for si in 0..wb.sheets.len() { - let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); - let keys: Vec<(CellRef, std::sync::Arc)> = - wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); - if keys.is_empty() { - continue; - } - let on_target = moves_arrays && host.eq_ignore_ascii_case(target); - let Some(sheet) = wb.sheet_mut(si) else { continue }; - for (c, f) in keys { - let Some(expr) = f.expr() else { continue }; - let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); - let text = gridcraft_formula::print(&new); - let array = match f.array { - Some(a) if on_target => super::sheet::map_range(a, e), - a => a, - }; - if (text != f.text || array != f.array) - && let Some(cell) = sheet.cells.get_mut(c) - { - let mut nf = Formula::from_expr(new); - nf.array = array; - nf.dynamic = f.dynamic; - cell.formula = Some(std::sync::Arc::new(nf)); - } - } - } - for n in wb.names.iter_mut() { - if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { - // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. - let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); - n.formula = gridcraft_formula::print(&new); - } - } - rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); -} - -/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on -/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells, -/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced -/// when the reference it names changed, and text that doesn't parse is kept. -pub(crate) fn rewrite_python_refs(wb: &mut gridcraft_model::Workbook, f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { - for si in 0..wb.sheets.len() { - let Some(sh) = wb.sheets.get(si) else { continue }; - let host = sh.name.clone(); - let mut new_code: Vec<(CellRef, String)> = Vec::new(); - for (c, cell) in sh.cells.iter() { - let Some(py) = &cell.py else { continue }; - let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| { - let expr = gridcraft_formula::parse(reference).ok()?; - let before = gridcraft_formula::print(&expr); - let after = gridcraft_formula::print(&f(&host, expr)); - (after != before).then_some(after) - }); - if let Some(code) = changed { - new_code.push((c, code)); - } - } - if new_code.is_empty() { - continue; - } - let Some(sh) = wb.sheet_mut(si) else { continue }; - for (c, code) in new_code { - if let Some(cell) = sh.cells.get_mut(c) - && let Some(py) = cell.py.as_mut() - { - std::sync::Arc::make_mut(py).code = code; - } - } - } -} - // ---------------------------------------------------------------- clear +/// The rows of `range` an applied filter hides (rows hidden by hand are not among them). +fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet { + sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect() +} + +/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides +/// are left as they are; rows hidden by hand are cleared. fn clear(s: &mut Session, p: &Json, what: &str) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; @@ -1536,7 +230,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { d.wb.sheet(sheet).is_some_and(|sh| { ranges.iter().any(|r| { r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| { - sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c)) + let skip = filtered_rows(sh, r); + sh.cells + .iter_range(r) + .any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c)) }) }) }) @@ -1550,8 +247,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }, None => continue, }; - let cells: Vec<(CellRef, Cell)> = - cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); + let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = cx + .wb + .sheet(sheet) + .map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect()) + .unwrap_or_default(); // Report every stored cell we touch, even beyond a million coordinates in a sparse // selection. Removed formula anchors must also reach calc so their spills disappear. cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); @@ -1587,16 +288,17 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } + let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { - sh.comments.retain(|c, _| !r.contains(*c)); + sh.comments.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "hyperlinks") { - sh.hyperlinks.retain(|c, _| !r.contains(*c)); + sh.hyperlinks.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "formats") { let mut unmerged = false; sh.merges.retain(|m| { - let keep = !r.contains_range(m); + let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some(); unmerged |= !keep; keep }); @@ -1618,283 +320,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) } -// ---------------------------------------------------------------- fill - -fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { - let sheet = target_sheet(s, p)?; - let mut r = target_range(s, p)?; - // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). - if r.is_full_cols() || r.is_full_rows() { - match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { - Some(u) => r = u, - None => return Ok(Json::Null), - } - } - if r.count() > crate::fill::MAX_FILL_CELLS { - return Err(bad("edit.fill", "range too large")); - } - // With one row selected, Fill Down copies from the row above (Excel behaviour). - if dr == 1 && r.height() == 1 { - r.start.row = r.start.row.saturating_sub(1); - } - if dc == 1 && r.width() == 1 { - r.start.col = r.start.col.saturating_sub(1); - } - let src = if dr == 1 { - RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) - } else if dr == -1 { - RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) - } else if dc == 1 { - RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) - } else { - RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) - }; - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; - Ok(Json::Null) - }) -} - -fn auto_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; - let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; - let mode = match str_param(p, "mode").unwrap_or("series") { - "copy" => crate::fill::FillMode::Copy, - "alternate" => crate::fill::FillMode::Alternate, - "formats" => crate::fill::FillMode::Formats, - "values" => crate::fill::FillMode::ValuesOnly, - "extend" => crate::fill::FillMode::Extend, - "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), - "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), - "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), - "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), - _ => crate::fill::FillMode::Series, - }; - if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { - return Err(bad("edit.autoFill", "range too large")); - } - // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill - // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) - // into whole columns (`A:A`). - if !crate::fill::holds_source_in_corner(src, target) { - return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); - } - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, target, mode)?; - *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; - Ok(Json::Null) - }) -} - -fn fill_series(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); - let kind = str_param(p, "type").unwrap_or("linear").to_string(); - let step = f64_param(p, "step").unwrap_or(1.0); - let stop = f64_param(p, "stop"); - let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); - edit(s, |cx| { - crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; - Ok(Json::Null) - }) -} - -fn flash_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; - if n == 0 { - return Err(EngineError::Other( - "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), - )); - } - Ok(json!({"filled": n})) -} - -// ---------------------------------------------------------------- find & replace - -fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { - let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; - let has_wild = w.contains(['*', '?']); - if has_wild { - let pat = if whole { w } else { format!("*{w}*") }; - return wildcard(&h, &pat); - } - if whole { h == w } else { h.contains(&w) } -} - -/// `*`/`?` wildcard match with `~` escapes. -pub(crate) fn wildcard(text: &str, pat: &str) -> bool { - let t: Vec = text.chars().collect(); - let mut p: Vec<(char, bool)> = Vec::new(); - let mut it = pat.chars(); - while let Some(c) = it.next() { - if c == '~' { - if let Some(n) = it.next() { - p.push((n, true)); - } - } else { - p.push((c, false)); - } - } - let (mut ti, mut pi) = (0usize, 0usize); - let (mut star, mut mark) = (None, 0usize); - while ti < t.len() { - if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { - ti += 1; - pi += 1; - } else if pi < p.len() && p[pi] == ('*', false) { - star = Some(pi); - mark = ti; - pi += 1; - } else if let Some(sp) = star { - pi = sp + 1; - mark += 1; - ti = mark; - } else { - return false; - } - } - while pi < p.len() && p[pi] == ('*', false) { - pi += 1; - } - pi == p.len() -} - -fn find(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.find", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let values = str_param(p, "lookIn") == Some("values"); - let by_cols = bool_param(p, "byColumns").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(false); - let workbook = str_param(p, "within") == Some("workbook"); - s.last_find = Some(p.clone()); - let d = s.doc()?; - let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; - let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); - for si in sheets { - let Some(sh) = d.wb.sheet(si) else { continue }; - let mut cells: Vec<(CellRef, String)> = sh - .cells - .iter() - .filter_map(|(c, cell)| { - let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { - crate::display::cell_text(&d.wb, sh, c) - } else { - cell.input_text() - }; - matches(&text, &what, case, whole).then_some((c, text)) - }) - .collect(); - if by_cols { - cells.sort_by_key(|(c, _)| (c.col, c.row)); - } - hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); - } - if hits.is_empty() { - return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); - } - if all { - let list: Vec = - hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); - return Ok(json!({"count": list.len(), "results": list})); - } - // Next hit after the active cell. - let cur = (d.wb.active_sheet(), d.selection.active); - let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; - let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); - let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; - let dd = s.doc_mut()?; - dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); - Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) -} - -fn replace(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - let with = str_param(p, "with").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.replace", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(true); - let workbook = str_param(p, "within") == Some("workbook"); - let active = s.doc()?.selection.active; - let n = edit(s, |cx| { - let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; - let mut n = 0; - for si in sheets { - let cells: Vec<(CellRef, String)> = - cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); - for (c, text) in cells { - if !all && c != active { - continue; - } - if !matches(&text, &what, case, whole) { - continue; - } - let new = if whole { - with.clone() - } else if case { - text.replace(&what, &with) - } else { - replace_ci(&text, &what, &with) - }; - let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); - let sh = cx.sheet_mut(si)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(si, c); - n += 1; - } - } - Ok(n) - })?; - Ok(json!({"replaced": n})) -} - -/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so -/// byte positions in the folded text still line up with the original's character boundaries. -fn folds_in_place(s: &str) -> bool { - s.chars().all(|c| { - let mut folded = c.to_lowercase(); - let first = folded.next(); - folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) - }) -} - -fn replace_ci(text: &str, what: &str, with: &str) -> String { - let lower = text.to_lowercase(); - let w = what.to_lowercase(); - // Slicing `text` at positions found in `lower` is only sound when folding kept every byte - // boundary: a length check alone misses foldings that keep the total length while shifting - // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used - // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. - if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { - return text.replace(what, with); - } - let mut out = String::new(); - let mut i = 0; - while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { - out.push_str(text.get(i..i + pos).unwrap_or("")); - out.push_str(with); - i += pos + w.len(); - } - out.push_str(text.get(i..).unwrap_or("")); - out -} - // ---------------------------------------------------------------- format painter fn format_painter(s: &mut Session, p: &Json) -> Result { @@ -1990,310 +415,4 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a - /// formula and a hidden name. - fn go_to_workbook() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); - s.execute("selection.set", json!({"sheet": 0})).unwrap(); - let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { - s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); - }; - define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); - define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); - define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); - define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); - define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); - define(&mut s, "Rate", "=0.07", "Workbook"); - define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); - let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); - wb.names.push(gridcraft_model::DefinedName { - name: "Secret".into(), - scope: None, - formula: "Sheet1!$Z$9".into(), - comment: String::new(), - hidden: true, - }); - s - } - - /// "The Go to popup window shows named ranges on every worksheet in your workbook." Names - /// that are not ranges, and hidden names, are not listed. - #[test] - fn go_to_lists_the_named_ranges_of_every_sheet() { - let mut s = go_to_workbook(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); - assert_eq!(list["recent"], json!([]), "{list}"); - // Seen from Sheet2, its own name is bare and Sheet1's is qualified. - s.execute("selection.set", json!({"sheet": 1})).unwrap(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); - } - - #[test] - fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { - let mut s = go_to_workbook(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); - assert_eq!(out["selection"], "C3", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); - // A name of several areas selects all of them. - let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); - assert_eq!(out["selection"], "A1,C3:C4", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); - } - - /// A name matches in any case and any script, as a formula matches it. - #[test] - fn go_to_matches_a_sheet_name_in_any_case() { - let mut s = go_to_workbook(); - s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap(); - s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap(); - assert_eq!(out["selection"], "D4", "{out}"); - // Seen from Sheet2, its own name hides the workbook name it shares. - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); - } - - /// "You can enter multiple named ranges or cell references in the Reference box. Separating - /// each with a comma ... Excel will highlight all the ranges." - #[test] - fn go_to_selects_every_reference_and_name_in_a_list() { - let mut s = go_to_workbook(); - let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); - assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); - let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); - assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); - assert_eq!(out["selection"], "A1,B2", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); - // Areas on two sheets, and a part that is not a reference, leave the selection alone. - for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { - assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); - assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); - } - } - - #[test] - fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { - let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); - assert_eq!(parts.next(), Some("A1")); - assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); - assert_eq!(parts.next(), Some("SUM(C3,D4)")); - assert_eq!(parts.next(), None); - assert_eq!(reference_parts("").collect::>(), vec![""]); - let mut s = go_to_workbook(); - let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); - assert!(err.to_string().contains("at most"), "{err}"); - let bad = format!("Total{}", ",".repeat(1_000_000)); - assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); - } - - /// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." - #[test] - fn go_to_keeps_the_references_entered_newest_first() { - let mut s = go_to_workbook(); - s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); - s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); - // Names are listed already; a call without `record` (the Name Box) is not kept. - s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); - s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); - s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); - // Going back from Sheet2 to a recent reference returns to its sheet. - s.execute("selection.set", json!({"sheet": 1})).unwrap(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); - assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); - for i in 0..GO_TO_RECENT + 2 { - s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); - } - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); - assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); - } - - #[test] - fn cell_set_takes_numbers_for_input_and_value() { - // #697: a JSON number for `input` used to clear the cell instead of entering it. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); - s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); - s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); - s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); - assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); - assert_eq!(get(&mut s, "A3")["value"], json!(true)); - assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); - assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); - } - - /// Every string in `values` went through typed input, read with en-US rules, in silence: a - /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and - /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. - #[test] - fn set_values_can_store_strings_as_the_text_they_are() { - let mut s = Session::new(); - s.new_workbook(); - let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; - let row: Vec = strings.iter().map(|v| json!(v)).collect(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - - // The default is unchanged: the dates swap, the thousands separator is a decimal point. - s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); - assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); - assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); - assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); - - // With `parse: false` each string is the text it was given, and a formula is still a - // formula. - s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); - for (i, want) in strings.iter().enumerate() { - let cell = format!("{}3", (b'A' + i as u8) as char); - let got = get(&mut s, &cell); - if *want == "=1+1" { - assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); - assert_eq!(got["formula"], "=1+1", "{got}"); - } else { - assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); - assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); - } - } - // Numbers and booleans are untouched either way. - s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); - assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); - assert_eq!(get(&mut s, "B5")["value"], json!(true)); - assert_eq!(get(&mut s, "C5")["text"], ""); - // `parse` is a flag, and anything else is a usage error rather than a quiet default. - s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); - assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); - // A line break still turns on Wrap Text. - s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); - assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); - } - - #[test] - fn legacy_array_entry() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); - s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); - s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); - s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); - // Past the result Excel shows #N/A; the old content of the range is replaced. - assert_eq!(get(&mut s, "C4")["text"], "#N/A"); - let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); - assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); - } - - #[test] - fn an_array_entered_outside_the_selection_covers_only_its_cell() { - // Found by the command-sequence fuzzer: the array took the selection's range even with - // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the - // selected cells were wiped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); - s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); - s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); - assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); - assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); - } - - #[test] - fn wildcards() { - assert!(wildcard("hello", "h*o")); - assert!(wildcard("hello", "h?llo")); - assert!(!wildcard("hello", "h?lo")); - assert!(wildcard("a*b", "a~*b")); - assert!(!wildcard("axb", "a~*b")); - assert!(wildcard("", "*")); - } - - #[test] - fn tsv() { - assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); - assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); - assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); - } - - #[test] - fn paste_special_operations_keep_text_errors_and_formulas() { - let mut s = Session::new(); - s.new_workbook(); - s.execute( - "range.setValues", - json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), - ) - .unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], "123"); - s.execute("edit.copy", json!({"range": "A2"})).unwrap(); - s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); - s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); - assert_eq!(get(&mut s, "B1")["value"], "Total"); - assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); - assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); - assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); - assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); - } - - #[test] - fn cut_paste_that_overflows_the_grid_moves_nothing() { - // #997: the source was emptied first and the cells that fell off the grid were skipped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); - // Transposed, the 2 rows become 2 columns and the second one is past XFD. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed, the second row would be past the last row. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed it still fits in column XFD. - s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); - assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); - } - - #[test] - fn replace_case_insensitive() { - assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); - } - - #[test] - fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { - // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a - // combining dot (2→3 bytes): the folded text is the same length but its byte positions - // no longer line up with the original, and the old slicing dropped the tail. - let mut s = Session::new(); - s.new_workbook(); - let text = "\u{212A}\u{0130}\u{0130}"; - s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); - s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); - } -} +mod tests; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs new file mode 100644 index 000000000..fe7d03d51 --- /dev/null +++ b/crates/engine/src/cmd/edit/cell.rs @@ -0,0 +1,300 @@ +use super::{ + Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, + locked_cell_error, str_param, target_range, target_ranges, target_sheet, +}; + +// ---------------------------------------------------------------- entry + +/// Typing `=PY(` doesn't make a Python cell; this says what does. +pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…)."; + +/// What typing `input` into a cell produces (constant or formula, plus an automatic number +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + if input.is_empty() { + let c = Cell { value: Value::Empty, formula: None, style, py: None }; + return Ok(if c.is_blank() { None } else { Some(c) }); + } + let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; + if fmt_is_text { + return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None })); + } + let is_formula = input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && gridcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + if is_formula && gridcraft_model::python::is_py_formula(input) { + return Err(EngineError::Other(PY_FORMULA.into())); + } + if is_formula { + let body = input.strip_prefix('=').unwrap_or(input); + let expr = wb.name_call_case(parse_typed_formula(body)?); + check_arg_counts(&expr)?; + let mut cell = Cell::formula(Formula::from_expr(expr)); + cell.style = style; + // Formulas whose result is a date/time get a format from functions like TODAY(). + if wb.styles.get(style).num_fmt.as_str() == "General" { + let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); + let auto = if f.starts_with("TODAY(") + || f.starts_with("DATE(") + || f.starts_with("EDATE(") + || f.starts_with("EOMONTH(") + || f.starts_with("WORKDAY(") + { + Some("m/d/yyyy") + } else if f.starts_with("NOW(") { + Some("m/d/yyyy h:mm") + } else if f.starts_with("TIME(") { + Some("h:mm AM/PM") + } else { + None + }; + if let Some(code) = auto { + cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + return Ok(Some(cell)); + } + let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already + // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. + if parsed.format.is_none() + && !input.contains('%') + && wb.styles.get(style).num_fmt.as_str().ends_with('%') + && let Value::Number(n) = parsed.value + && n.abs() >= 1.0 + { + parsed.value = Value::Number(n / 100.0); + } + let mut style = style; + if let Some(code) = parsed.format { + let cur = wb.styles.get(style).num_fmt.as_str().to_string(); + if cur == "General" { + style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + // Wrap text automatically when the input has line breaks (Alt+Enter). + if input.contains('\n') && !wb.styles.get(style).align.wrap { + style = wb.styles.derive(style, |s| s.align.wrap = true); + } + Ok(Some(Cell { value: parsed.value, formula: None, style, py: None })) +} + +/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, +/// and refuses a formula that still doesn't parse. +pub(crate) fn parse_typed_formula(body: &str) -> Result { + // Excel closes missing parentheses for you. + let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); + parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) +} + +/// Like Excel, a formula that calls a built-in function with too few or too many arguments is +/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't +/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and +/// unknown functions are skipped. +pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { + let mut bad = None; + expr.walk(&mut |e| { + if bad.is_none() + && let gridcraft_formula::Expr::Call(name, args) = e + && let Some(spec) = gridcraft_functions::lookup(name) + && !spec.accepts(args.len()) + { + let few = args.len() < spec.min_args; + bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); + } + }); + match bad { + Some(msg) => Err(EngineError::Other(msg)), + None => Ok(()), + } +} + +pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = match cell_param_on(s, p, "cell", sheet)? { + Some(c) => c, + None => s.doc()?.selection.active, + }; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + let array = bool_param(p, "array").unwrap_or(false); + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet) + && sh.is_protected() + && d.wb.styles.get(sh.style_id(at)).protection.locked + && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + let active_sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + if array { + // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds + // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. + let selected = cx.sel.current(); + let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; + // A table header can't hold an array's value, and a table resizes under it. + if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { + return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); + } + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); + if let Some(f) = cell.formula.as_mut() { + std::sync::Arc::make_mut(f).array = Some(range); + } + let sh = cx.sheet_mut(sheet)?; + // The array takes the whole range: other cells in it lose their contents. + for c in range.iter().skip(1).take(1_000_000) { + if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) { + let style = old.style; + sh.set_cell(c, Cell { style, ..Cell::default() }); + } + } + sh.set_cell(range.start, cell); + cx.touch(sheet, range.start); + return Ok(Json::Null); + } + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + Ok(Json::Null) + })?; + let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); + Ok(json!({"cell": at.a1(), "value": v.display()})) +} + +pub(crate) fn json_to_input(v: &Json) -> String { + match v { + Json::Null => String::new(), + Json::Bool(b) => { + if *b { + "TRUE".into() + } else { + "FALSE".into() + } + } + Json::Number(n) => n.to_string(), + Json::String(s) => s.clone(), + other => other.to_string(), + } +} + +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; + let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; + if rows.len() > MAX_ROWS as usize { + return Err(bad("range.setValues", "too many rows")); + } + let mut count = 0; + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + let cols: Vec<&Json> = match row.as_array() { + Some(a) => a.iter().collect(), + None => vec![row], + }; + for (ci, v) in cols.iter().enumerate() { + let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; + let input = json_to_input(v); + let cell = match v { + Json::Number(n) => Some(Cell { + value: Value::number(n.as_f64().unwrap_or(0.0)), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + py: None, + }), + Json::Bool(b) => Some(Cell { + value: Value::Bool(*b), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + py: None, + }), + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + 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); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, + }; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + count += 1; + } + } + Ok(()) + })?; + Ok(json!({"cells": count})) +} + +pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + let input = str_param(p, "input").unwrap_or("").to_string(); + let origin = s.doc()?.selection.active; + edit(s, |cx| { + // Each cell gets what typing the input there gives: the automatic number format where + // it's General, its own format otherwise, text where it's Text. That depends only on + // the cell's style, so the input is parsed once per style. + let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); + for r in &ranges { + if r.count() > 2_000_000 { + return Err(bad("range.fill", "range too large")); + } + for at in r.iter() { + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + let base = match by_style.get(&style) { + Some(c) => c.clone(), + None => { + let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; + by_style.insert(style, c.clone()); + c + } + }; + let mut cell = base.unwrap_or_default(); + if let Some(f) = &cell.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); + cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); + } + let sh = cx.sheet_mut(sheet)?; + sh.set_cell(at, cell); + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + } + } + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 000000000..3a1cd0739 --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,271 @@ +use super::cell::input_to_cell; +use super::paste_special::paste_special; +use super::{ + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, +}; + +// ---------------------------------------------------------------- clipboard + +fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { + let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + if ranges.len() > 1 && cut { + return Err(multiple()); + } + // The selection as made (untrimmed), for Insert Copied Cells. + let selected = ranges.first().copied().unwrap_or_default(); + let d = s.doc()?; + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let trim = |range: RangeRef| { + if range.is_full_cols() || range.is_full_rows() { + let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); + range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) + } else { + range + } + }; + let (wb, range, areas) = if ranges.len() > 1 { + let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); + let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; + (std::sync::Arc::new(wb), block, areas) + } else { + (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) + }; + let text = crate::display::range_text(&wb, sheet, range); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; + s.clipboard = Some(clip); + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) +} + +/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they +/// slide together side by side, left to right) or all cover the same columns (stacked, top to +/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed +/// into one block at the first area's place, and that block. Formulas travel as their values, as +/// in Excel. `None` when the areas don't line up. +fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { + let first = *areas.first()?; + let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); + let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); + let mut sorted = areas.to_vec(); + if across { + sorted.sort_by_key(|a| a.start.col); + } else if down { + sorted.sort_by_key(|a| a.start.row); + } else { + return None; + } + if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { + return None; + } + let anchor = sorted.first()?.start; + let (h, w) = if across { + (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) + } else { + (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) + }; + let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); + let src = wb.sheet(sheet)?; + let mut out = wb.clone(); + let dst = out.sheet_mut(sheet)?; + dst.take_cells(block); + dst.spill.retain(|c, _| !block.contains(*c)); + dst.spill_ranges.retain(|c, _| !block.contains(*c)); + dst.comments.retain(|c, _| !block.contains(*c)); + dst.merges.retain(|m| !m.intersects(&block)); + if across { + dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); + } else { + dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); + } + let mut next = if across { anchor.col } else { anchor.row }; + for a in &sorted { + let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; + let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); + for c in cells { + let Some(to) = c.offset(dr, dc) else { continue }; + let style = src.cell(c).map(|x| x.style).unwrap_or_default(); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None }); + if let Some(picture) = src.cell_pictures.get(&c) { + dst.cell_pictures.insert(to, picture.clone()); + } + } + for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { + if let Some(to) = c.offset(dr, dc) { + dst.comments.insert(to, comment.clone()); + } + } + for m in src.merges.iter().filter(|m| a.contains_range(m)) { + if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + dst.merges.push(RangeRef::new(s, e)); + } + } + // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. + if across { + for (c, info) in src.cols.range(a.start.col..=a.end.col) { + dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); + } + } else { + for (r, info) in src.rows.range(a.start.row..=a.end.row) { + dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); + } + } + next = next.saturating_add(if across { a.width() } else { a.height() }); + } + Some((out, block)) +} + +pub(super) fn copy(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, false) +} + +pub(super) fn cut(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, true) +} + +pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result { + s.clipboard = None; + s.format_painter = None; + ok() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum What { + All, + Formulas, + Values, + Formats, + Comments, + Validation, + AllExceptBorders, + ColumnWidths, + FormulasAndNumberFormats, + ValuesAndNumberFormats, +} + +pub(super) fn paste(s: &mut Session, p: &Json) -> Result { + // External text (from the system clipboard) when it differs from ours. + if let Some(text) = str_param(p, "text") + && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) + { + return paste_text(s, p, text); + } + if s.clipboard.is_none() { + return Err(EngineError::Other("Nothing to paste.".into())); + } + paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) +} + +fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); + let rows: Vec> = parse_tsv(text); + let h = rows.len(); + let w = rows.iter().map(Vec::len).max().unwrap_or(0); + // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read + // only as far as the text's own shape, so spans in the markup can't expand without bound. + let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + for (ci, v) in row.iter().enumerate() { + let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; + let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + if let Some(cl) = cell.as_mut() { + numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); + } + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(sheet, c); + } + } + if h > 0 && w > 0 { + let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + } + Ok(()) + })?; + Ok(json!({"rows": h, "cols": w})) +} + +/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the +/// source recorded for it in its HTML, with a format that shows the same text when one can be +/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated +/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. +fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { + let Some(hc) = html else { return }; + let Some(n) = hc.num else { return }; + if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { + return; + } + if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { + return; + } + cell.value = Value::number(n); + if wb.styles.get(cell.style).num_fmt.as_str() == "General" + && let Some(code) = crate::clip_html::format_for(field, n) + { + cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); + } +} + +/// Tab-separated text with Excel-style quoting. +pub(crate) fn parse_tsv(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut chars = text.chars().peekable(); + let mut quoted = false; + let mut at_start = true; + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + field.push('"'); + chars.next(); + } else { + quoted = false; + } + } else { + field.push(c); + } + continue; + } + match c { + '"' if at_start => { + quoted = true; + at_start = false; + } + '\t' => { + row.push(std::mem::take(&mut field)); + at_start = true; + } + '\r' => {} + '\n' => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + at_start = true; + } + _ => { + field.push(c); + at_start = false; + } + } + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + rows +} diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs new file mode 100644 index 000000000..8cd1e3d88 --- /dev/null +++ b/crates/engine/src/cmd/edit/fill.rs @@ -0,0 +1,96 @@ +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet}; + +// ---------------------------------------------------------------- fill + +pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { + let sheet = target_sheet(s, p)?; + let mut r = target_range(s, p)?; + // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). + if r.is_full_cols() || r.is_full_rows() { + match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { + Some(u) => r = u, + None => return Ok(Json::Null), + } + } + if r.count() > crate::fill::MAX_FILL_CELLS { + return Err(bad("edit.fill", "range too large")); + } + // With one row selected, Fill Down copies from the row above (Excel behaviour). + if dr == 1 && r.height() == 1 { + r.start.row = r.start.row.saturating_sub(1); + } + if dc == 1 && r.width() == 1 { + r.start.col = r.start.col.saturating_sub(1); + } + let src = if dr == 1 { + RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) + } else if dr == -1 { + RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) + } else if dc == 1 { + RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) + } else { + RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) + }; + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; + Ok(Json::Null) + }) +} + +pub(super) fn auto_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; + let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; + let mode = match str_param(p, "mode").unwrap_or("series") { + "copy" => crate::fill::FillMode::Copy, + "alternate" => crate::fill::FillMode::Alternate, + "formats" => crate::fill::FillMode::Formats, + "values" => crate::fill::FillMode::ValuesOnly, + "extend" => crate::fill::FillMode::Extend, + "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), + "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), + "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), + "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), + _ => crate::fill::FillMode::Series, + }; + if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { + return Err(bad("edit.autoFill", "range too large")); + } + // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill + // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) + // into whole columns (`A:A`). + if !crate::fill::holds_source_in_corner(src, target) { + return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); + } + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, target, mode)?; + *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; + Ok(Json::Null) + }) +} + +pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); + let kind = str_param(p, "type").unwrap_or("linear").to_string(); + let step = f64_param(p, "step").unwrap_or(1.0); + let stop = f64_param(p, "stop"); + let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); + edit(s, |cx| { + crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?; + Ok(Json::Null) + }) +} + +pub(super) fn flash_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; + if n == 0 { + return Err(EngineError::Other( + "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), + )); + } + Ok(json!({"filled": n})) +} diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs new file mode 100644 index 000000000..bce0ba728 --- /dev/null +++ b/crates/engine/src/cmd/edit/find.rs @@ -0,0 +1,184 @@ +use super::cell::input_to_cell; +use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param}; + +// ---------------------------------------------------------------- find & replace + +fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { + let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; + let has_wild = w.contains(['*', '?']); + if has_wild { + let pat = if whole { w } else { format!("*{w}*") }; + return wildcard(&h, &pat); + } + if whole { h == w } else { h.contains(&w) } +} + +/// `*`/`?` wildcard match with `~` escapes. +pub(crate) fn wildcard(text: &str, pat: &str) -> bool { + let t: Vec = text.chars().collect(); + let mut p: Vec<(char, bool)> = Vec::new(); + let mut it = pat.chars(); + while let Some(c) = it.next() { + if c == '~' { + if let Some(n) = it.next() { + p.push((n, true)); + } + } else { + p.push((c, false)); + } + } + let (mut ti, mut pi) = (0usize, 0usize); + let (mut star, mut mark) = (None, 0usize); + while ti < t.len() { + if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { + ti += 1; + pi += 1; + } else if pi < p.len() && p[pi] == ('*', false) { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == ('*', false) { + pi += 1; + } + pi == p.len() +} + +pub(super) fn find(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.find", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let values = str_param(p, "lookIn") == Some("values"); + let by_cols = bool_param(p, "byColumns").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(false); + let workbook = str_param(p, "within") == Some("workbook"); + s.last_find = Some(p.clone()); + let d = s.doc()?; + let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; + let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); + for si in sheets { + let Some(sh) = d.wb.sheet(si) else { continue }; + let mut cells: Vec<(CellRef, String)> = sh + .cells + .iter() + .filter_map(|(c, cell)| { + let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { + crate::display::cell_text(&d.wb, sh, c) + } else { + cell.input_text() + }; + matches(&text, &what, case, whole).then_some((c, text)) + }) + .collect(); + if by_cols { + cells.sort_by_key(|(c, _)| (c.col, c.row)); + } + hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); + } + if hits.is_empty() { + return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); + } + if all { + let list: Vec = + hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); + return Ok(json!({"count": list.len(), "results": list})); + } + // Next hit after the active cell. + let cur = (d.wb.active_sheet(), d.selection.active); + let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; + let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); + let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; + let dd = s.doc_mut()?; + dd.wb_switch_sheet(si); + dd.selection = crate::Selection::at(c); + Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) +} + +pub(super) fn replace(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + let with = str_param(p, "with").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.replace", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(true); + let workbook = str_param(p, "within") == Some("workbook"); + let active = s.doc()?.selection.active; + let n = edit(s, |cx| { + let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; + let mut n = 0; + for si in sheets { + let cells: Vec<(CellRef, String)> = + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); + for (c, text) in cells { + if !all && c != active { + continue; + } + if !matches(&text, &what, case, whole) { + continue; + } + let new = if whole { + with.clone() + } else if case { + text.replace(&what, &with) + } else { + replace_ci(&text, &what, &with) + }; + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let sh = cx.sheet_mut(si)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(si, c); + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"replaced": n})) +} + +/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so +/// byte positions in the folded text still line up with the original's character boundaries. +fn folds_in_place(s: &str) -> bool { + s.chars().all(|c| { + let mut folded = c.to_lowercase(); + let first = folded.next(); + folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) + }) +} + +pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String { + let lower = text.to_lowercase(); + let w = what.to_lowercase(); + // Slicing `text` at positions found in `lower` is only sound when folding kept every byte + // boundary: a length check alone misses foldings that keep the total length while shifting + // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used + // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. + if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { + return text.replace(what, with); + } + let mut out = String::new(); + let mut i = 0; + while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { + out.push_str(text.get(i..i + pos).unwrap_or("")); + out.push_str(with); + i += pos + w.len(); + } + out.push_str(text.get(i..).unwrap_or("")); + out +} diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs new file mode 100644 index 000000000..0cd7a3de9 --- /dev/null +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -0,0 +1,199 @@ +use super::selection::{select_region, selection_set}; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param}; + +/// How many references the Go To dialog keeps for going back to. +pub(super) const GO_TO_RECENT: usize = 10; + +/// The most areas one Go To selects. +pub(super) const GO_TO_MAX_AREAS: usize = 2000; + +/// How deep a name that refers to other names is followed. +const GO_TO_NAME_DEPTH: usize = 8; + +pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "list") == Some(true) { + let d = s.doc()?; + return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent})); + } + let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; + let d = s.doc()?; + let mut target: Option = None; + let mut areas: Vec = Vec::new(); + let mut named = false; + for part in reference_parts(reference) { + let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else { + return Err(EngineError::Other("Reference isn't valid.".into())); + }; + // One selection holds the areas of one sheet. + if target.is_some_and(|t| t != sheet) { + return Err(EngineError::Other("Reference isn't valid.".into())); + } + target = Some(sheet); + named |= by_name; + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); + } + } + let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; + let range = areas.iter().map(RangeRef::a1).collect::>().join(","); + let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; + if bool_param(p, "record") == Some(true) && !named { + let d = s.doc_mut()?; + let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); + let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); + d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); + d.go_to_recent.insert(0, entry); + d.go_to_recent.truncate(GO_TO_RECENT); + } + Ok(out) +} + +/// The parts of a Go To reference, split at the commas outside quoted sheet names and +/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller +/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. +pub(super) fn reference_parts(text: &str) -> impl Iterator { + let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); + let mut chars = text.char_indices(); + std::iter::from_fn(move || { + if done { + return None; + } + for (i, c) in chars.by_ref() { + match c { + '\'' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => depth = depth.saturating_sub(1), + ',' if !quoted && depth == 0 => { + let part = text.get(start..i).unwrap_or("").trim(); + start = i + 1; + return Some(part); + } + _ => {} + } + } + done = true; + Some(text.get(start..).unwrap_or("").trim()) + }) +} + +/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, +/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. +/// A name counts when what it refers to is a reference; a constant or a formula is not one. +fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { + if part.is_empty() || depth > GO_TO_NAME_DEPTH { + return None; + } + let (prefix, body) = split_sheet(part); + let on = match &prefix { + Some(n) => wb.sheet_index(n)?, + None => sheet, + }; + if let Some(r) = RangeRef::parse(&body.replace('$', "")) { + return Some((on, vec![r], false)); + } + let name = match prefix { + Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)), + None => wb.name(body, sheet), + }; + if let Some(n) = name { + return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); + } + if prefix.is_none() + && let Some((si, ti)) = wb.table(body) + { + // As in Excel, a table's name stands for its data, without the header and totals rows. + let t = wb.sheet(si)?.tables.get(ti)?; + return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); + } + None +} + +/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a +/// reference. +fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = None; + let mut areas = Vec::new(); + for p in reference_parts(formula) { + let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; + if target.is_some_and(|t| t != s) { + return None; + } + target = Some(s); + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return None; + } + } + target.map(|t| (t, areas)) +} + +/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and +/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, +/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that +/// do not refer to a range are left out. +pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { + let own: std::collections::HashSet = + wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); + let mut out: Vec = wb + .names + .iter() + .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) + .filter_map(|n| match n.scope { + None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None, + None => Some(n.name.clone()), + Some(s) if s == sheet => Some(n.name.clone()), + Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), + }) + .collect(); + out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n)); + out.dedup_by(|a, b| gridcraft_formula::same_name(a, b)); + out +} + +pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("blanks"); + let d = s.doc()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; + if kind == "lastCell" { + let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); + return selection_set(s, &json!({"cell": c.a1()})); + } + if kind == "currentRegion" { + return select_region(s, &json!({})); + } + let mut hits: Vec = Vec::new(); + if scope.count() > 4_000_000 { + return Err(bad("edit.goToSpecial", "range too large")); + } + for c in scope.iter() { + let cell = sheet.cell(c); + let v = sheet.value(c); + let ok = match kind { + "blanks" => v.is_empty(), + "constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()), + "formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()), + "comments" | "notes" => sheet.comments.contains_key(&c), + "errors" => v.is_error(), + "numbers" => v.is_number(), + "text" => v.is_text(), + "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), + "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), + "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), + _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), + }; + if ok { + hits.push(c); + } + } + if hits.is_empty() { + return Err(EngineError::Other("No cells were found.".into())); + } + let ranges = coalesce(&hits); + let d = s.doc_mut()?; + d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; + Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs new file mode 100644 index 000000000..f5cc2bf5c --- /dev/null +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -0,0 +1,425 @@ +use super::clipboard::What; +use super::{ + Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param, + target_sheet, +}; + +fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator + Clone { + (0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c))))) +} + +fn apply_operation(op: &str, old: &Cell, new: &mut Cell) { + let operand = new.value.clone(); + if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number()) + && (old.value.is_number() || old.value.is_empty() || old.value.is_text()) + && (operand.is_number() || operand.is_empty()) + { + new.value = match op { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0), + "divide" => Value::number(x / y), + _ => operand.clone(), + }; + } else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) { + new.value = old.value.clone(); + } + if let Some(formula) = &old.formula + && let Ok(y) = operand.to_number() + && (operand.is_number() || operand.is_empty()) + && let Some(symbol) = operation_symbol(op) + { + let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } +} + +fn operation_symbol(op: &str) -> Option { + match op { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } +} + +fn copy_merges( + cx: &mut crate::cmd::Ctx, + dest_sheet: usize, + src_sheet: &gridcraft_model::Sheet, + src: RangeRef, + at: CellRef, + what: What, + transpose: bool, +) -> Result<()> { + if !matches!(what, What::All | What::Formats) || transpose { + return Ok(()); + } + let merges: Vec = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect(); + for merge in merges { + let row_offset = at.row as i64 - src.start.row as i64; + let col_offset = at.col as i64 - src.start.col as i64; + if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) { + let moved = RangeRef::new(start, end); + let sheet = cx.sheet_mut(dest_sheet)?; + sheet.merges.retain(|existing| !existing.intersects(&moved)); + sheet.merges.push(moved); + } + } + Ok(()) +} + +pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let what = match str_param(p, "what").unwrap_or("all") { + "all" => What::All, + "formulas" => What::Formulas, + "values" => What::Values, + "formats" => What::Formats, + "comments" | "notes" => What::Comments, + "validation" => What::Validation, + "allExceptBorders" => What::AllExceptBorders, + "columnWidths" => What::ColumnWidths, + "formulasAndNumberFormats" => What::FormulasAndNumberFormats, + "valuesAndNumberFormats" => What::ValuesAndNumberFormats, + other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), + }; + let op = str_param(p, "operation").unwrap_or("none").to_string(); + let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); + let transpose = bool_param(p, "transpose").unwrap_or(false); + let link = bool_param(p, "link").unwrap_or(false); + if link && !clip.areas.is_empty() { + return Err(EngineError::Other("This action won't work on multiple selections.".into())); + } + let dest_sheet = target_sheet(s, p)?; + let sel = s.doc()?.selection.current(); + let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); + let src = clip.range; + let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; + // Tile the copy over a larger destination that is a multiple of its size. + let tile = sel.height() % h == 0 + && sel.width() % w == 0 + && !sel.is_single() + && cell_param_on(s, p, "at", dest_sheet)?.is_none() + && !sel.is_full_cols() + && !sel.is_full_rows() + && sel.count() <= 4_000_000; + let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; + let same_doc = s.doc()?.uid == clip.doc_uid; + let cut_move = clip.cut && same_doc && what == What::All; + let src_wb = clip.wb.clone(); + let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; + let src_sheet_name = src_sheet.name.clone(); + if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { + // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. + return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); + } + edit(s, |cx| { + // Styles from another workbook must be re-interned. + let mut style_map = std::collections::HashMap::new(); + let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { + if same_doc { + return id; + } + *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) + }; + let mut moved_formulas = Vec::new(); + let mut moved_pictures = std::collections::BTreeMap::new(); + if cut_move { + // Remove the source first (contents and formats), and fix references to it. + let source = cx.sheet_mut(clip.sheet)?; + moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); + let taken = source.take_cells(src); + // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them + // with the block: the destination is written from the clipboard snapshot taken + // before the cut, so removing them here loses nothing and leaves the old place + // clean instead of leaving one merged block and one note behind. + source.comments.retain(|c, _| !src.contains(*c)); + source.hyperlinks.retain(|c, _| !src.contains(*c)); + source.merges.retain(|m| !src.contains_range(m)); + for (c, _) in &taken { + cx.touch(clip.sheet, *c); + } + moved_formulas = taken; + } + for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // The array range moves with its formula. + let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; + return Some(std::sync::Arc::new(moved)); + } + let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + // A Python cell's code travels with it. Cut to another sheet, it keeps reading the + // cells its `xl()` references named on its original sheet, as a moved formula does: + // they are qualified with that sheet before the workbook-wide move adjustment below. + let moved_py = |cell: &Cell| -> Option> { + let py = cell.py.clone()?; + if !cut_move || dest_sheet == clip.sheet { + return Some(py); + } + let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }); + Some(gridcraft_formula::print(&e)) + }); + Some(match qualified { + Some(code) => std::sync::Arc::new(gridcraft_model::PyCode { code, ..(*py).clone() }), + None => py, + }) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + }; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; + new.py = None; + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell); + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) + } else { + st + }; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + new.py = moved_py(&sc_cell); + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.py = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + && new.py.is_none() + { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + // Numbers stored as text participate; other text and errors stay as they are. + apply_operation(&op, &old, &mut new); + } + } + // The copied cell's validation rule replaces whatever rule the destination had. + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); + let sh = cx.sheet_mut(dest_sheet)?; + crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); + if let Some(mut dv) = rule { + // Relative references move with the rule, as pasted formulas do. + let first = dv.ranges.first().map_or(sc, |r| r.start); + dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); + dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); + dv.ranges = vec![RangeRef::new(dest, dest)]; + sh.validations.push(dv); + } + } + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + } + What::ColumnWidths => { + if let Some(info) = src_sheet.cols.get(&sc.col).copied() { + cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); + } + } + What::All => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); + } + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), + } + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); + } + // Merged areas inside the copied block travel with it. + copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?; + if cut_move { + // References elsewhere to the moved block now point to its new place. + let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); + let e = if dest_sheet == clip.sheet { + gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } + } else { + gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } + }; + rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); + cx.structural = true; + } + let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + Ok(()) + })?; + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. + let passive = + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } + if cut_move { + s.clipboard = None; + } + Ok(json!({"pasted": s.doc()?.selection.a1()})) +} + +/// Applies a reference adjustment to every cell formula and defined name in the workbook. +pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { + use gridcraft_formula::adjust::Edit; + // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too + // (a cut moves its arrays itself). + let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); + for si in 0..wb.sheets.len() { + let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); + let keys: Vec<(CellRef, std::sync::Arc)> = + wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); + if keys.is_empty() { + continue; + } + let on_target = moves_arrays && host.eq_ignore_ascii_case(target); + let Some(sheet) = wb.sheet_mut(si) else { continue }; + for (c, f) in keys { + let Some(expr) = f.expr() else { continue }; + let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); + let text = gridcraft_formula::print(&new); + let array = match f.array { + Some(a) if on_target => crate::cmd::sheet::map_range(a, e), + a => a, + }; + if (text != f.text || array != f.array) + && let Some(cell) = sheet.cells.get_mut(c) + { + let mut nf = Formula::from_expr(new); + nf.array = array; + nf.dynamic = f.dynamic; + cell.formula = Some(std::sync::Arc::new(nf)); + } + } + } + for n in wb.names.iter_mut() { + if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { + // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. + let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); + n.formula = gridcraft_formula::print(&new); + } + } + rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); +} + +/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on +/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells, +/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced +/// when the reference it names changed, and text that doesn't parse is kept. +pub(crate) fn rewrite_python_refs(wb: &mut gridcraft_model::Workbook, f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { + for si in 0..wb.sheets.len() { + let Some(sh) = wb.sheets.get(si) else { continue }; + let host = sh.name.clone(); + let mut new_code: Vec<(CellRef, String)> = Vec::new(); + for (c, cell) in sh.cells.iter() { + let Some(py) = &cell.py else { continue }; + let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| { + let expr = gridcraft_formula::parse(reference).ok()?; + let before = gridcraft_formula::print(&expr); + let after = gridcraft_formula::print(&f(&host, expr)); + (after != before).then_some(after) + }); + if let Some(code) = changed { + new_code.push((c, code)); + } + } + if new_code.is_empty() { + continue; + } + let Some(sh) = wb.sheet_mut(si) else { continue }; + for (c, code) in new_code { + if let Some(cell) = sh.cells.get_mut(c) + && let Some(py) = cell.py.as_mut() + { + std::sync::Arc::make_mut(py).code = code; + } + } + } +} diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs new file mode 100644 index 000000000..ede66a34d --- /dev/null +++ b/crates/engine/src/cmd/edit/selection.rs @@ -0,0 +1,174 @@ +use super::{ + CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges, + target_sheet, +}; + +// ---------------------------------------------------------------- selection + +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + +pub(super) fn selection_set(s: &mut Session, p: &Json) -> Result { + if p.get("sheet").is_some() { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) + && let (Some(_), _) = split_sheet(r) + { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } + let ranges = target_ranges(s, p)?; + let active = cell_param(s, p, "active")?; + let d = s.doc_mut()?; + let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); + let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; + if let Some(sh) = d.wb.active() { + sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } + } + d.selection = sel; + Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) +} + +impl crate::DocState { + /// Switches the active sheet, remembering each sheet's selection. + pub fn wb_switch_sheet(&mut self, i: usize) { + if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { + return; + } + let cur = self.wb.active_sheet(); + if self.sheet_selections.len() < self.wb.sheets.len() { + self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); + } + if let Some(slot) = self.sheet_selections.get_mut(cur) { + *slot = self.selection.clone(); + } + // View state: set in place, so the workbook is neither copied nor made dirty. + self.wb.set_active_sheet(i); + self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); + } +} + +pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result { + let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); + let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); + let extend = bool_param(p, "extend").unwrap_or(false); + let jmp = bool_param(p, "jump").unwrap_or(false); + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let sel = &mut d.selection; + // Extension moves the far corner, not the active cell. + let from = if extend { sel.moving_end() } else { sel.active }; + // Skip hidden rows/columns and step over merged areas. + let mut to = if jmp { + jump(sheet, from, dr.signum(), dc.signum()) + } else { + let mut t = from.offset_clamped(dr, dc); + let mut guard = 0; + while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { + let n = t.offset_clamped(dr.signum(), dc.signum()); + if n == t { + break; + } + t = n; + guard += 1; + } + if !extend && let Some(m) = sheet.merge_at(from) { + // Leaving a merged cell moves from its edge. + if dr > 0 { + t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); + } else if dc > 0 { + t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); + } + } + t + }; + if let Some(m) = sheet.merge_at(to) + && !extend + { + to = m.start; + } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } + if extend { + sel.extend_to(to); + sel.expand_merges(sheet); + } else { + *sel = crate::Selection::at(to); + if let Some(m) = sheet.merge_at(to) { + sel.ranges = vec![m]; + } + } + Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) +} + +pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { + let fwd = bool_param(p, "forward").unwrap_or(true); + let by_row = bool_param(p, "byRow").unwrap_or(false); + let d = s.doc_mut()?; + d.selection.cycle(fwd, by_row); + Ok(json!({"active": d.selection.active.a1()})) +} + +pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let region = current_region(sheet, d.selection.active); + let all = RangeRef::all(); + let r = if region.is_single() || d.selection.current() == region { all } else { region }; + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_region(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let r = current_region(sheet, d.selection.active); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_row(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_col(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; + Ok(json!({"selection": d.selection.a1()})) +} diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs new file mode 100644 index 000000000..c9e1e7a93 --- /dev/null +++ b/crates/engine/src/cmd/edit/tests.rs @@ -0,0 +1,305 @@ +use super::*; + +/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a +/// formula and a hidden name. +fn go_to_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("selection.set", json!({"sheet": 0})).unwrap(); + let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + }; + define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); + define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); + define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); + define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); + define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); + define(&mut s, "Rate", "=0.07", "Workbook"); + define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + wb.names.push(gridcraft_model::DefinedName { + name: "Secret".into(), + scope: None, + formula: "Sheet1!$Z$9".into(), + comment: String::new(), + hidden: true, + }); + s +} + +/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names +/// that are not ranges, and hidden names, are not listed. +#[test] +fn go_to_lists_the_named_ranges_of_every_sheet() { + let mut s = go_to_workbook(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); + assert_eq!(list["recent"], json!([]), "{list}"); + // Seen from Sheet2, its own name is bare and Sheet1's is qualified. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); +} + +#[test] +fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); + assert_eq!(out["selection"], "C3", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); + // A name of several areas selects all of them. + let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); + assert_eq!(out["selection"], "A1,C3:C4", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); +} + +/// A name matches in any case and any script, as a formula matches it. +#[test] +fn go_to_matches_a_sheet_name_in_any_case() { + let mut s = go_to_workbook(); + s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap(); + assert_eq!(out["selection"], "D4", "{out}"); + // Seen from Sheet2, its own name hides the workbook name it shares. + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); +} + +/// "You can enter multiple named ranges or cell references in the Reference box. Separating +/// each with a comma ... Excel will highlight all the ranges." +#[test] +fn go_to_selects_every_reference_and_name_in_a_list() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); + assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); + assert_eq!(out["selection"], "A1,B2", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); + // Areas on two sheets, and a part that is not a reference, leave the selection alone. + for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); + } +} + +#[test] +fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { + let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); + assert_eq!(parts.next(), Some("A1")); + assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); + assert_eq!(parts.next(), Some("SUM(C3,D4)")); + assert_eq!(parts.next(), None); + assert_eq!(reference_parts("").collect::>(), vec![""]); + let mut s = go_to_workbook(); + let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); + assert!(err.to_string().contains("at most"), "{err}"); + let bad = format!("Total{}", ",".repeat(1_000_000)); + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); +} + +/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." +#[test] +fn go_to_keeps_the_references_entered_newest_first() { + let mut s = go_to_workbook(); + s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); + // Names are listed already; a call without `record` (the Name Box) is not kept. + s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); + // Going back from Sheet2 to a recent reference returns to its sheet. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + for i in 0..GO_TO_RECENT + 2 { + s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); + } + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); + assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); +} + +#[test] +fn cell_set_takes_numbers_for_input_and_value() { + // #697: a JSON number for `input` used to clear the cell instead of entering it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); +} + +/// Every string in `values` went through typed input, read with en-US rules, in silence: a +/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and +/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. +#[test] +fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); +} + +#[test] +fn legacy_array_entry() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); + s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); + // Past the result Excel shows #N/A; the old content of the range is replaced. + assert_eq!(get(&mut s, "C4")["text"], "#N/A"); + let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); + assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); +} + +#[test] +fn an_array_entered_outside_the_selection_covers_only_its_cell() { + // Found by the command-sequence fuzzer: the array took the selection's range even with + // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the + // selected cells were wiped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); + s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); + s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); + assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); + assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); +} + +#[test] +fn wildcards() { + assert!(wildcard("hello", "h*o")); + assert!(wildcard("hello", "h?llo")); + assert!(!wildcard("hello", "h?lo")); + assert!(wildcard("a*b", "a~*b")); + assert!(!wildcard("axb", "a~*b")); + assert!(wildcard("", "*")); +} + +#[test] +fn tsv() { + assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); + assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); + assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); +} + +#[test] +fn paste_special_operations_keep_text_errors_and_formulas() { + let mut s = Session::new(); + s.new_workbook(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), + ) + .unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], "123"); + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); + assert_eq!(get(&mut s, "B1")["value"], "Total"); + assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); + assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); + assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); + assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); +} + +#[test] +fn cut_paste_that_overflows_the_grid_moves_nothing() { + // #997: the source was emptied first and the cells that fell off the grid were skipped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + // Transposed, the 2 rows become 2 columns and the second one is past XFD. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed, the second row would be past the last row. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed it still fits in column XFD. + s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); + assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); +} + +#[test] +fn replace_case_insensitive() { + assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); +} + +#[test] +fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { + // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a + // combining dot (2→3 bytes): the folded text is the same length but its byte positions + // no longer line up with the original, and the old slicing dropped the tail. + let mut s = Session::new(); + s.new_workbook(); + let text = "\u{212A}\u{0130}\u{0130}"; + s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); + s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 364b81ed7..c9a1582a2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -2,10 +2,33 @@ //! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind, + Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, + ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; + +mod cells; +mod chart; +mod data; +mod objects; +mod scripts; +mod window; + +pub(crate) use cells::is_checkbox_cell; +use cells::{background, checkbox, draw_border, new_cell_style, recently_used, theme_colors, theme_fonts, toggle_checkbox, use_in_formula}; +use chart::{add_element, change_colors, format_series, move_chart, quick_layout, select_data}; +use data::{from_csv, from_table_range, queries, table_resize}; +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 fn specs() -> Vec { vec![ @@ -308,430 +331,6 @@ pub fn specs() -> Vec { ] } -fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let idx = match u32_param(p, "chart") { - Some(id) => sh.charts.iter().position(|c| c.id == id), - None => sh.charts.len().checked_sub(1), - }; - idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) -} - -fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { - let (si, ci) = find_chart(s, p)?; - edit(s, |cx| { - let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; - f(c)?; - Ok(json!({"chart": c.id})) - }) -} - -fn add_element(s: &mut Session, p: &Json) -> Result { - let el = str_param(p, "element").unwrap_or("title").to_string(); - let on = bool_param(p, "on").unwrap_or(true); - let pos = str_param(p, "position").map(str::to_string); - with_chart(s, p, |c| { - match el.as_str() { - "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, - "legend" => { - c.legend = if !on { - LegendPos::None - } else { - match pos.as_deref() { - Some("top") => LegendPos::Top, - Some("left") => LegendPos::Left, - Some("right") => LegendPos::Right, - _ => LegendPos::Bottom, - } - } - } - "dataLabels" => c.data_labels = on, - "gridlines" => c.gridlines = on, - "axisTitles" => { - c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); - c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); - } - "smooth" => { - for s in &mut c.series { - s.smooth = on; - } - } - "trendline" => { - let only = u32_param(p, "series").map(|i| i as usize); - if only.is_some_and(|i| i >= c.series.len()) { - return Err(bad("chart.addElement", "no such series")); - } - let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); - if !on { - for i in targets { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.clear(); - } - } - return Ok(()); - } - let t = trendline_param(p)?; - let eligible: Vec = - targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); - if eligible.is_empty() { - return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); - } - for i in eligible { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.push(t.clone()); - } - } - } - other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), - } - Ok(()) - }) -} - -/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart -/// Element ▸ Trendline choices, plus the Format Trendline options). -fn trendline_param(p: &Json) -> Result { - let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); - let mut t = Trendline { - forward: num("forward"), - backward: num("backward"), - display_equation: bool_param(p, "displayEquation").unwrap_or(false), - display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), - ..Trendline::default() - }; - t.kind = match str_param(p, "type").unwrap_or("linear") { - "linear" => TrendlineKind::Linear, - // Excel's Linear Forecast: a linear trendline projected two periods ahead. - "linearForecast" => { - if t.forward == 0.0 { - t.forward = 2.0; - } - TrendlineKind::Linear - } - "exponential" => TrendlineKind::Exponential, - "logarithmic" => TrendlineKind::Logarithmic, - "power" => TrendlineKind::Power, - "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), - "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), - other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), - }; - Ok(t) -} - -fn quick_layout(s: &mut Session, p: &Json) -> Result { - let n = u32_param(p, "layout").unwrap_or(1); - with_chart(s, p, |c| { - let (legend, labels, grid, axes) = match n { - 2 => (LegendPos::Top, true, false, false), - 3 => (LegendPos::Bottom, false, true, false), - 4 => (LegendPos::Bottom, true, false, false), - 5 => (LegendPos::None, false, true, true), - 6 => (LegendPos::Right, false, true, true), - _ => (LegendPos::Right, false, true, false), - }; - c.legend = legend; - c.data_labels = labels; - c.gridlines = grid; - if axes { - c.x_title.get_or_insert_with(|| "Axis Title".into()); - c.y_title.get_or_insert_with(|| "Axis Title".into()); - } else { - c.x_title = None; - c.y_title = None; - } - Ok(()) - }) -} - -fn change_colors(s: &mut Session, p: &Json) -> Result { - let explicit: Option> = - p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); - let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); - with_chart(s, p, |c| { - for (i, se) in c.series.iter_mut().enumerate() { - se.color = match &explicit { - Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), - _ => match palette.as_str() { - "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), - p if p.starts_with("accent") => { - let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); - Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) - } - _ => None, - }, - }; - } - Ok(()) - }) -} - -fn select_data(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. - let rebuilt = match str_param(p, "range") { - Some(range) => { - let range = range.trim_start_matches('='); - let (sheet_name, body) = split_sheet(range); - let src_si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; - if r.count() > 1_000_000 { - return Err(bad("chart.selectData", "range too large for a chart")); - } - let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; - let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); - let series = super::insert::series_from_range(src, r, by_rows); - Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) - } - None => None, - }; - // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). - let categories = match str_param(p, "categories") { - None => None, - Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), - Some(t) => { - let t = t.trim().trim_start_matches('='); - let (sheet_name, body) = split_sheet(t); - let si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; - let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; - let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); - Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) - } - }; - let mut remove: Vec = p - .get("removeSeries") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) - .unwrap_or_default(); - remove.sort_unstable(); - remove.dedup(); - if rebuilt.is_none() && categories.is_none() && remove.is_empty() { - return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); - } - with_chart(s, p, move |c| { - if let Some((series, source, by_rows)) = rebuilt { - c.series = series; - super::insert::scatter_x_from_first(c.kind, &mut c.series); - c.source = Some(source); - c.by_rows = by_rows; - } - for i in remove.iter().rev() { - if *i < c.series.len() && c.series.len() > 1 { - c.series.remove(*i); - } - } - if let Some(cats) = categories { - for se in &mut c.series { - se.categories = cats.clone(); - } - } - Ok(()) - }) -} - -fn move_chart(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - let target = match p.get("sheet").and_then(Json::as_str) { - Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), - None => None, - }; - let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; - edit(s, |cx| { - let mut chart = cx.sheet_mut(si)?.charts.remove(ci); - if let Some(a) = at { - chart.anchor.cell = a; - } - let dest = target.unwrap_or(si); - cx.sheet_mut(dest)?.charts.push(chart); - Ok(Json::Null) - }) -} - -fn format_series(s: &mut Session, p: &Json) -> Result { - let idx = u32_param(p, "series").unwrap_or(0) as usize; - let color = str_param(p, "color").and_then(Color::from_hex); - with_chart(s, p, |c| { - if let Some(se) = c.series.get_mut(idx) { - se.color = color; - } - Ok(()) - }) -} - -fn shape_fill(s: &mut Session, p: &Json) -> Result { - if str_param(p, "kind") == Some("shape") { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - return with_shape(s, p, |sh| sh.fill = color); - } - format_series(s, p) -} - -fn shape_outline(s: &mut Session, p: &Json) -> Result { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - with_shape(s, p, |sh| sh.line = color) -} - -fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { - let si = s.doc()?.wb.active_sheet(); - let id = u32_param(p, "id"); - edit(s, |cx| { - let shapes = &mut cx.sheet_mut(si)?.shapes; - let sh = match id { - Some(id) => shapes.iter_mut().find(|x| x.id == id), - None => shapes.last_mut(), - } - .ok_or_else(|| bad("shape", "no such shape"))?; - f(sh); - Ok(Json::Null) - }) -} - -fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); - let si = s.doc()?.wb.active_sheet(); - fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { - if i >= v.len() { - return; - } - let item = v.remove(i); - let j = match (forward, extreme) { - (true, true) => v.len(), - (false, true) => 0, - (true, false) => (i + 1).min(v.len()), - (false, false) => i.saturating_sub(1), - }; - v.insert(j, item); - } - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - match kind.as_str() { - "chart" => { - if let Some(i) = sh.charts.iter().position(|c| c.id == id) { - reorder(&mut sh.charts, i, forward, extreme); - } - } - "image" => { - if let Some(i) = sh.images.iter().position(|c| c.id == id) { - reorder(&mut sh.images, i, forward, extreme); - } - } - _ => { - if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { - reorder(&mut sh.shapes, i, forward, extreme); - } - } - } - Ok(Json::Null) - }) -} - -fn selection_pane(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let mut v = Vec::new(); - for c in &sh.shapes { - v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.images { - v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.charts { - v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - Ok(Json::Array(v)) -} - -fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { - match kind { - "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - } -} - -fn align(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let ids: Vec = - p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); - let how = str_param(p, "align").unwrap_or("left").to_string(); - let si = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let geo: Vec<(u32, f64, f64, f32, f32)> = { - let shc = sheet.clone(); - anchors_of(sheet, &kind) - .into_iter() - .filter(|(id, _)| ids.contains(id)) - .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) - .collect() - }; - if geo.len() < 2 { - return Ok(Json::Null); - } - let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); - let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); - let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); - let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); - let shc = sheet.clone(); - for (id, a) in anchors_of(sheet, &kind) { - let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; - let (mut x, mut y) = (g.1, g.2); - match how.as_str() { - "left" => x = left, - "right" => x = right - g.3 as f64, - "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, - "top" => y = top, - "bottom" => y = bottom - g.4 as f64, - _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, - } - let c = CellRef::new(shc.row_at(y), shc.col_at(x)); - a.cell = c; - a.dx = (x - shc.col_left(c.col)) as f32; - a.dy = (y - shc.row_top(c.row)) as f32; - } - Ok(Json::Null) - }) -} - -fn rotate(s: &mut Session, p: &Json) -> Result { - if p.get("flip").is_some_and(|f| !f.is_null()) { - return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); - } - with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) -} - -/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it -/// neither records an undo step nor marks a clean workbook as changed. -fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc_mut()?; - let dirty = d.is_dirty(); - let wb = std::sync::Arc::make_mut(&mut d.wb); - if let Some(sh) = wb.sheet_mut(si) { - sh.view = mode; - } - if !dirty { - d.saved = d.wb.clone(); - } - Ok(json!({"view": mode.as_str()})) -} - -fn ruler(s: &mut Session, p: &Json) -> Result { - let on = bool_param(p, "on").unwrap_or(s.hide_ruler); - s.hide_ruler = !on; - Ok(json!({"on": on})) -} - const VIEWS_NAME: &str = "_sc_custom_views"; const SCRIPTS_NAME: &str = "_sc_scripts"; const WATCH_NAME: &str = "_sc_watch"; @@ -759,593 +358,10 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true }); } -/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current -/// list when they swap in another snapshot of the workbook. -pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { - let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); - let keep = find(from); - if keep == find(to) { - return; - } - let wb = std::sync::Arc::make_mut(to); - wb.names.retain(|n| n.name != WATCH_NAME); - wb.names.extend(keep); -} - -fn custom_views(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let mut views = stash_map(&d.wb, VIEWS_NAME); - if let Some(name) = str_param(p, "save") { - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } else if let Some(name) = str_param(p, "show") { - let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; - if let Some(si) = v["sheet"].as_u64() { - s.execute("sheet.activate", json!({"sheet": si}))?; - } - if let Some(sel) = v["selection"].as_str() { - s.execute("selection.set", json!({"range": sel}))?; - } - if let Some(z) = v["zoom"].as_u64() { - s.execute("view.zoom", json!({"percent": z}))?; - } - if let Some(f) = v.get("freeze") { - let freeze = match (f[0].as_u64(), f[1].as_u64()) { - (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), - _ => None, - }; - edit(s, |cx| { - let si = cx.sheet_index(); - cx.sheet_mut(si)?.freeze = freeze; - Ok(()) - })?; - } - } else if let Some(name) = str_param(p, "delete") { - if let Some(o) = views.as_object_mut() { - o.remove(name); - } - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } - Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) -} - -fn split(s: &mut Session, _: &Json) -> Result { - let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); - if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } -} - -fn new_window(s: &mut Session, _: &Json) -> Result { - let id = s.new_window()?; - let doc = s.window(id).map(|w| w.doc_uid); - Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) -} - -/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most -/// recent window), else the active window. -pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { - let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; - s.target_window(t).map_err(|e| bad(cmd, e.to_string())) -} - -/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, -/// and the interface moves its native windows ([`Session::window_list`] order, active first). -fn arrange_all(s: &mut Session, p: &Json) -> Result { - let arrange = match str_param(p, "arrange").unwrap_or("tiled") { - a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, - other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), - }; - let active = s.active_window(); - let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); - let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); - let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); - if let Some(at) = windows.iter().position(|w| Some(*w) == active) - && let Some(first) = windows.get_mut(..=at) - { - first.rotate_right(1); - } - Ok(json!({"arrange": arrange, "windows": windows})) -} - -fn hide_window(s: &mut Session, p: &Json) -> Result { - let id = window_param(s, "view.hideWindow", p)?; - s.hide_window(id); - ok() -} - -fn unhide_window(s: &mut Session, p: &Json) -> Result { - let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - Some(window_param(s, "view.unhideWindow", p)?) - } else if let Some(i) = u32_param(p, "index") { - // A workbook's position in the document list. - let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; - s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) - } else { - s.last_hidden_window() - }; - if let Some(id) = id { - s.unhide_window(id); - } - ok() -} - -fn switch_windows(s: &mut Session, p: &Json) -> Result { - if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - let id = window_param(s, "view.switchWindows", p)?; - s.unhide_window(id); - } - Ok(s.window_list()) -} - -fn table_resize(s: &mut Session, p: &Json) -> Result { - let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let ti = match str_param(p, "table") { - Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), - None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), - } - .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); - if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { - return Err(EngineError::Other("A table can't overlap another table.".into())); - } - let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; - if r.start.row != t.range.start.row || !r.intersects(&t.range) { - return Err(EngineError::Other( - "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), - )); - } - let mut cols = Vec::new(); - for (i, h) in headers.iter().enumerate() { - let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; - let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); - cols.push(existing.unwrap_or(TableColumn { - name, - totals: TotalsFn::None, - totals_label: None, - formula: None, - unique_name: None, - xml: None, - })); - } - t.columns = cols; - t.range = r; - cx.structural = true; - Ok(Json::Null) - }) -} - -fn from_csv(s: &mut Session, p: &Json) -> Result { - let bytes = if let Some(t) = str_param(p, "text") { - t.as_bytes().to_vec() - } else if let Some(path) = str_param(p, "path") { - crate::io::read_file(path)? - } else { - s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); - return ok(); - }; - let text = String::from_utf8_lossy(&bytes); - // JSON array of objects → rows. - let rows: Vec> = - if text.trim_start().starts_with('[') { - let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; - let mut keys: Vec = Vec::new(); - for o in &arr { - if let Some(m) = o.as_object() { - for k in m.keys() { - if !keys.contains(k) { - keys.push(k.clone()); - } - } - } - } - let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; - for o in &arr { - out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); - } - out - } else { - let delim = str_param(p, "delimiter") - .and_then(|d| d.chars().next()) - .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); - let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; - let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; - let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; - (u.start.row..=u.end.row) - .map(|r| { - (u.start.col..=u.end.col) - .map(|c| match sh.value(CellRef::new(r, c)) { - Value::Number(n) => json!(n), - Value::Bool(b) => json!(b), - Value::Empty => Json::Null, - v => json!(v.display()), - }) - .collect() - }) - .collect() - }; - if rows.is_empty() || rows.len() > 1_048_576 { - return Err(EngineError::Other("Nothing to import.".into())); - } - let name = str_param(p, "sheet").map(str::to_string).or_else(|| { - str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) - }); - let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; - let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); - s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; - let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); - let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); - let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); - Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) -} - -fn from_table_range(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); - from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) -} - -fn queries(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - Ok(Json::Array( - d.wb.sheets - .iter() - .flat_map(|sh| { - sh.tables - .iter() - .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) - }) - .collect(), - )) -} - -fn record(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { - let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; - let cmds: Vec = - s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); - s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; - return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); - } - s.recording = Some(s.journal.len()); - Ok(json!({"recording": true})) -} - -fn new_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); - let cmds = p.get("commands").cloned().unwrap_or(json!([])); - if !cmds.is_array() { - return Err(bad("automate.newScript", "`commands` must be an array")); - } - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - all[name.as_str()] = cmds.clone(); - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(json!({"name": name})) - }) -} - -fn all_scripts(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let (wb, trusted) = (&d.wb, d.scripts_trusted); - let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = all - .as_object() - .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) - .unwrap_or_default(); - // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on - // save but never executed here, so it has no step count. - if let Some(bytes) = &wb.vba_project { - list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); - } - Ok(Json::Array(list)) -} - -/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). -pub(crate) fn has_scripts(wb: &Workbook) -> bool { - stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) -} - -/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the -/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, -/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by -/// command id and parameter name, so it is only the up-front refusal: the steps also run inside -/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and -/// [`run_script`] drops any `OpenUrl` request a step raises. -fn denied_script_step(id: &str, params: &Json) -> Option { - if id.starts_with("file.") || id.starts_with("link.") { - return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); - } - // Scripts are scoped to their workbook: a step that switches the active window would - // point every later step at another open document, so those are refused too. - if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { - return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); - } - if params.get("path").is_some() { - return Some(format!("scripts can't pass a file `path` to `{id}`")); - } - None -} - -fn trust_scripts(s: &mut Session, p: &Json) -> Result { - // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. - let trusted = match p.get("trusted") { - None => true, - Some(Json::Bool(b)) => *b, - Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), - }; - s.doc_mut()?.scripts_trusted = trusted; - Ok(json!({"trusted": trusted})) -} - -/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is -/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that -/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. -fn run_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let d = s.doc()?; - let all = stash_get(&d.wb, SCRIPTS_NAME); - let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; - if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { - return Err(bad( - "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", - )); - } - let steps: Vec = steps.into_iter().take(100_000).collect(); - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if let Some(why) = denied_script_step(id, &st["params"]) { - return Err(bad("automate.runScript", why)); - } - } - let _sandbox = crate::io::ScriptSandbox::enter(); - let mut n = 0; - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if id.starts_with("automate.") { - continue; - } - let before = s.ui_requests.len(); - let ran = s.execute(id, st["params"].clone()); - // A step that asks the host to open a URL leaves the app: drop the request and stop. - if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { - let mut i = 0; - s.ui_requests.retain(|r| { - i += 1; - i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) - }); - return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); - } - ran?; - n += 1; - } - Ok(json!({"ran": n})) -} - -/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and -/// Redo work there again. -fn stop_action(s: &mut Session, _: &Json) -> Result { - let uid = s.doc()?.uid; - Ok(json!({"stopped": s.abort_groups(uid)})) -} - -fn delete_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - if let Some(o) = all.as_object_mut() { - o.remove(&name); - } - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(Json::Null) - }) -} - /// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes). pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\""; -/// Whether `c` has checkbox formatting. -pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { - wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" -} - -fn checkbox(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let si = target_sheet(s, p)?; - edit(s, |cx| { - for r in &ranges { - if r.count() > 100_000 { - return Err(bad("insert.checkbox", "range too large")); - } - for c in r.iter() { - let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); - let new = cx.wb.styles.derive(st, |s| { - s.num_fmt = NumFmt::new("checkbox"); - s.align.h = HAlign::Center; - }); - let sh = cx.sheet_mut(si)?; - // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty - // cell starts unchecked. - let mut cell = sh.cell(c).cloned().unwrap_or_default(); - if cell.value.is_empty() && cell.formula.is_none() { - cell.value = Value::Bool(false); - } - cell.style = new; - sh.set_cell(c, cell); - cx.touch(si, c); - } - } - Ok(Json::Null) - }) -} - -fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc()?; - let cells: Vec = match cell_param_on(s, p, "cell", si)? { - Some(c) => vec![c], - None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), - }; - edit(s, |cx| { - for c in cells { - let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); - let sh = cx.sheet_mut(si)?; - if let Some(cell) = sh.cells.get_mut(c) - && cell.formula.is_none() - && cell.py.is_none() - { - // An empty checkbox cell counts as FALSE. - let next = match cell.value { - Value::Bool(b) if checkbox => Value::Bool(!b), - Value::Empty if checkbox => Value::Bool(true), - _ => continue, - }; - cell.value = next; - cx.changed.push((si, c)); - } - } - Ok(Json::Null) - }) -} - /// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). fn object_params(p: &Json) -> Json { Json::Object(p.as_object().cloned().unwrap_or_default()) } - -fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = object_params(p); - if params.get("name").is_none() { - params["name"] = json!(s.doc()?.wb.theme.name.clone()); - } - s.execute("pageLayout.theme", params) -} - -fn theme_fonts(s: &mut Session, p: &Json) -> Result { - let name = s.doc()?.wb.theme.name.clone(); - let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); - s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) -} - -fn background(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "clear").unwrap_or(false) { - let si = s.doc()?.wb.active_sheet(); - return edit(s, |cx| { - cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); - Ok(Json::Null) - }); - } - let mut params = object_params(p); - params["alt"] = json!("Sheet background"); - params["at"] = json!("A1"); - s.execute("insert.picture", params) -} - -fn draw_border(s: &mut Session, p: &Json) -> Result { - let edge = str_param(p, "edge").unwrap_or("grid"); - let preset = match edge { - "top" => "top", - "bottom" => "bottom", - "left" => "left", - "right" => "right", - "outline" => "outside", - _ => "all", - }; - let mut params = object_params(p); - params["preset"] = json!(preset); - s.execute("home.borders", params) -} - -fn new_cell_style(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); - let style = d.wb.styles.get(sh.style_id(c)).clone(); - edit(s, |cx| { - cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); - cx.wb.cell_styles.push((name.clone(), style.clone())); - Ok(json!({"name": name})) - }) -} - -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)) -} - -fn use_in_formula(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); - ok() -} - -fn watch_window(s: &mut Session, p: &Json) -> Result { - let mut list: Vec = - stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let mut changed = false; - if let Some(a) = str_param(p, "add") { - if !list.iter().any(|x| x == a) { - list.push(a.to_string()); - } - changed = true; - } - if let Some(r) = str_param(p, "remove") { - list.retain(|x| x != r); - changed = true; - } - if changed { - let v = json!(list.clone()); - let d = s.doc_mut()?; - // The watch list is view state: don't add an undo step. - let wb = std::sync::Arc::make_mut(&mut d.wb); - stash_set(wb, WATCH_NAME, &v); - } - let d = s.doc()?; - let rows: Vec = list - .iter() - .map(|w| { - let (sheet, body) = split_sheet(w); - let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); - let c = CellRef::parse(body).unwrap_or_default(); - let sh = d.wb.sheet(si); - json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) - }) - .collect(); - Ok(Json::Array(rows)) -} diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs new file mode 100644 index 000000000..ae1ec8cbb --- /dev/null +++ b/crates/engine/src/cmd/extra/cells.rs @@ -0,0 +1,151 @@ +use super::{ + CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok, + str_param, target_ranges, target_sheet, +}; + +/// Whether `c` has checkbox formatting. +pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { + wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" +} + +pub(super) fn checkbox(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let si = target_sheet(s, p)?; + edit(s, |cx| { + for r in &ranges { + if r.count() > 100_000 { + return Err(bad("insert.checkbox", "range too large")); + } + for c in r.iter() { + let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); + let new = cx.wb.styles.derive(st, |s| { + s.num_fmt = NumFmt::new("checkbox"); + s.align.h = HAlign::Center; + }); + let sh = cx.sheet_mut(si)?; + // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty + // cell starts unchecked. + let mut cell = sh.cell(c).cloned().unwrap_or_default(); + if cell.value.is_empty() && cell.formula.is_none() { + cell.value = Value::Bool(false); + } + cell.style = new; + sh.set_cell(c, cell); + cx.touch(si, c); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc()?; + let cells: Vec = match cell_param_on(s, p, "cell", si)? { + Some(c) => vec![c], + None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), + }; + edit(s, |cx| { + for c in cells { + let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); + let sh = cx.sheet_mut(si)?; + if let Some(cell) = sh.cells.get_mut(c) + && cell.formula.is_none() + && cell.py.is_none() + { + // An empty checkbox cell counts as FALSE. + let next = match cell.value { + Value::Bool(b) if checkbox => Value::Bool(!b), + Value::Empty if checkbox => Value::Bool(true), + _ => continue, + }; + cell.value = next; + cx.changed.push((si, c)); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn theme_colors(s: &mut Session, p: &Json) -> Result { + let mut params = object_params(p); + if params.get("name").is_none() { + params["name"] = json!(s.doc()?.wb.theme.name.clone()); + } + s.execute("pageLayout.theme", params) +} + +pub(super) fn theme_fonts(s: &mut Session, p: &Json) -> Result { + let name = s.doc()?.wb.theme.name.clone(); + let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); + s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) +} + +pub(super) fn background(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "clear").unwrap_or(false) { + let si = s.doc()?.wb.active_sheet(); + return edit(s, |cx| { + cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); + Ok(Json::Null) + }); + } + let mut params = object_params(p); + params["alt"] = json!("Sheet background"); + params["at"] = json!("A1"); + s.execute("insert.picture", params) +} + +pub(super) fn draw_border(s: &mut Session, p: &Json) -> Result { + let edge = str_param(p, "edge").unwrap_or("grid"); + let preset = match edge { + "top" => "top", + "bottom" => "bottom", + "left" => "left", + "right" => "right", + "outline" => "outside", + _ => "all", + }; + let mut params = object_params(p); + params["preset"] = json!(preset); + s.execute("home.borders", params) +} + +pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); + let style = d.wb.styles.get(sh.style_id(c)).clone(); + edit(s, |cx| { + cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); + cx.wb.cell_styles.push((name.clone(), style.clone())); + Ok(json!({"name": name})) + }) +} + +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)) +} + +pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); + ok() +} diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs new file mode 100644 index 000000000..b6761f1a9 --- /dev/null +++ b/crates/engine/src/cmd/extra/chart.rs @@ -0,0 +1,262 @@ +use super::{ + CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit, + f64_param, json, split_sheet, str_param, u32_param, +}; + +fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "chart") { + Some(id) => sh.charts.iter().position(|c| c.id == id), + None => sh.charts.len().checked_sub(1), + }; + idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) +} + +fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { + let (si, ci) = find_chart(s, p)?; + edit(s, |cx| { + let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; + f(c)?; + Ok(json!({"chart": c.id})) + }) +} + +pub(super) fn add_element(s: &mut Session, p: &Json) -> Result { + let el = str_param(p, "element").unwrap_or("title").to_string(); + let on = bool_param(p, "on").unwrap_or(true); + let pos = str_param(p, "position").map(str::to_string); + with_chart(s, p, |c| { + match el.as_str() { + "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, + "legend" => { + c.legend = if !on { + LegendPos::None + } else { + match pos.as_deref() { + Some("top") => LegendPos::Top, + Some("left") => LegendPos::Left, + Some("right") => LegendPos::Right, + _ => LegendPos::Bottom, + } + } + } + "dataLabels" => c.data_labels = on, + "gridlines" => c.gridlines = on, + "axisTitles" => { + c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); + c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); + } + "smooth" => { + for s in &mut c.series { + s.smooth = on; + } + } + "trendline" => { + let only = u32_param(p, "series").map(|i| i as usize); + if only.is_some_and(|i| i >= c.series.len()) { + return Err(bad("chart.addElement", "no such series")); + } + let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); + if !on { + for i in targets { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.clear(); + } + } + return Ok(()); + } + let t = trendline_param(p)?; + let eligible: Vec = + targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); + if eligible.is_empty() { + return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); + } + for i in eligible { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.push(t.clone()); + } + } + } + other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), + } + Ok(()) + }) +} + +/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart +/// Element ▸ Trendline choices, plus the Format Trendline options). +fn trendline_param(p: &Json) -> Result { + let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); + let mut t = Trendline { + forward: num("forward"), + backward: num("backward"), + display_equation: bool_param(p, "displayEquation").unwrap_or(false), + display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), + ..Trendline::default() + }; + t.kind = match str_param(p, "type").unwrap_or("linear") { + "linear" => TrendlineKind::Linear, + // Excel's Linear Forecast: a linear trendline projected two periods ahead. + "linearForecast" => { + if t.forward == 0.0 { + t.forward = 2.0; + } + TrendlineKind::Linear + } + "exponential" => TrendlineKind::Exponential, + "logarithmic" => TrendlineKind::Logarithmic, + "power" => TrendlineKind::Power, + "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), + "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), + other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), + }; + Ok(t) +} + +pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result { + let n = u32_param(p, "layout").unwrap_or(1); + with_chart(s, p, |c| { + let (legend, labels, grid, axes) = match n { + 2 => (LegendPos::Top, true, false, false), + 3 => (LegendPos::Bottom, false, true, false), + 4 => (LegendPos::Bottom, true, false, false), + 5 => (LegendPos::None, false, true, true), + 6 => (LegendPos::Right, false, true, true), + _ => (LegendPos::Right, false, true, false), + }; + c.legend = legend; + c.data_labels = labels; + c.gridlines = grid; + if axes { + c.x_title.get_or_insert_with(|| "Axis Title".into()); + c.y_title.get_or_insert_with(|| "Axis Title".into()); + } else { + c.x_title = None; + c.y_title = None; + } + Ok(()) + }) +} + +pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result { + let explicit: Option> = + p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); + let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); + with_chart(s, p, |c| { + for (i, se) in c.series.iter_mut().enumerate() { + se.color = match &explicit { + Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), + _ => match palette.as_str() { + "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), + p if p.starts_with("accent") => { + let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); + Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) + } + _ => None, + }, + }; + } + Ok(()) + }) +} + +pub(super) fn select_data(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. + let rebuilt = match str_param(p, "range") { + Some(range) => { + let range = range.trim_start_matches('='); + let (sheet_name, body) = split_sheet(range); + let src_si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; + if r.count() > 1_000_000 { + return Err(bad("chart.selectData", "range too large for a chart")); + } + let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; + let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); + let series = crate::cmd::insert::series_from_range(src, r, by_rows); + Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) + } + None => None, + }; + // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). + let categories = match str_param(p, "categories") { + None => None, + Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), + Some(t) => { + let t = t.trim().trim_start_matches('='); + let (sheet_name, body) = split_sheet(t); + let si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; + let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); + Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) + } + }; + let mut remove: Vec = p + .get("removeSeries") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) + .unwrap_or_default(); + remove.sort_unstable(); + remove.dedup(); + if rebuilt.is_none() && categories.is_none() && remove.is_empty() { + return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); + } + with_chart(s, p, move |c| { + if let Some((series, source, by_rows)) = rebuilt { + c.series = series; + crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series); + c.source = Some(source); + c.by_rows = by_rows; + } + for i in remove.iter().rev() { + if *i < c.series.len() && c.series.len() > 1 { + c.series.remove(*i); + } + } + if let Some(cats) = categories { + for se in &mut c.series { + se.categories = cats.clone(); + } + } + Ok(()) + }) +} + +pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + let target = match p.get("sheet").and_then(Json::as_str) { + Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), + None => None, + }; + let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; + edit(s, |cx| { + let mut chart = cx.sheet_mut(si)?.charts.remove(ci); + if let Some(a) = at { + chart.anchor.cell = a; + } + let dest = target.unwrap_or(si); + cx.sheet_mut(dest)?.charts.push(chart); + Ok(Json::Null) + }) +} + +pub(super) fn format_series(s: &mut Session, p: &Json) -> Result { + let idx = u32_param(p, "series").unwrap_or(0) as usize; + let color = str_param(p, "color").and_then(Color::from_hex); + with_chart(s, p, |c| { + if let Some(se) = c.series.get_mut(idx) { + se.color = color; + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs new file mode 100644 index 000000000..9a1ae7417 --- /dev/null +++ b/crates/engine/src/cmd/extra/data.rs @@ -0,0 +1,131 @@ +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range}; + +pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result { + let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let ti = match str_param(p, "table") { + Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), + None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), + } + .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); + if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { + return Err(EngineError::Other("A table can't overlap another table.".into())); + } + let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; + if r.start.row != t.range.start.row || !r.intersects(&t.range) { + return Err(EngineError::Other( + "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), + )); + } + let mut cols = Vec::new(); + for (i, h) in headers.iter().enumerate() { + let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; + let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); + cols.push(existing.unwrap_or(TableColumn { + name, + totals: TotalsFn::None, + totals_label: None, + formula: None, + unique_name: None, + xml: None, + })); + } + t.columns = cols; + t.range = r; + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { + let bytes = if let Some(t) = str_param(p, "text") { + t.as_bytes().to_vec() + } else if let Some(path) = str_param(p, "path") { + crate::io::read_file(path)? + } else { + s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); + return ok(); + }; + let text = String::from_utf8_lossy(&bytes); + // JSON array of objects → rows. + let rows: Vec> = + if text.trim_start().starts_with('[') { + let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; + let mut keys: Vec = Vec::new(); + for o in &arr { + if let Some(m) = o.as_object() { + for k in m.keys() { + if !keys.contains(k) { + keys.push(k.clone()); + } + } + } + } + let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; + for o in &arr { + out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); + } + out + } else { + let delim = str_param(p, "delimiter") + .and_then(|d| d.chars().next()) + .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); + let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; + let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; + let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; + (u.start.row..=u.end.row) + .map(|r| { + (u.start.col..=u.end.col) + .map(|c| match sh.value(CellRef::new(r, c)) { + Value::Number(n) => json!(n), + Value::Bool(b) => json!(b), + Value::Empty => Json::Null, + v => json!(v.display()), + }) + .collect() + }) + .collect() + }; + if rows.is_empty() || rows.len() > 1_048_576 { + return Err(EngineError::Other("Nothing to import.".into())); + } + let name = str_param(p, "sheet").map(str::to_string).or_else(|| { + str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) + }); + let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; + let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); + let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); + let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); + Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) +} + +pub(super) fn from_table_range(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; + let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); + from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) +} + +pub(super) fn queries(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + Ok(Json::Array( + d.wb.sheets + .iter() + .flat_map(|sh| { + sh.tables + .iter() + .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) + }) + .collect(), + )) +} diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs new file mode 100644 index 000000000..30725f81e --- /dev/null +++ b/crates/engine/src/cmd/extra/objects.rs @@ -0,0 +1,148 @@ +use super::chart::format_series; +use super::{ + Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param, +}; + +pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { + if str_param(p, "kind") == Some("shape") { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + return with_shape(s, p, |sh| sh.fill = color); + } + format_series(s, p) +} + +pub(super) fn shape_outline(s: &mut Session, p: &Json) -> Result { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + with_shape(s, p, |sh| sh.line = color) +} + +fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { + let si = s.doc()?.wb.active_sheet(); + let id = u32_param(p, "id"); + edit(s, |cx| { + let shapes = &mut cx.sheet_mut(si)?.shapes; + let sh = match id { + Some(id) => shapes.iter_mut().find(|x| x.id == id), + None => shapes.last_mut(), + } + .ok_or_else(|| bad("shape", "no such shape"))?; + f(sh); + Ok(Json::Null) + }) +} + +pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); + let si = s.doc()?.wb.active_sheet(); + fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { + if i >= v.len() { + return; + } + let item = v.remove(i); + let j = match (forward, extreme) { + (true, true) => v.len(), + (false, true) => 0, + (true, false) => (i + 1).min(v.len()), + (false, false) => i.saturating_sub(1), + }; + v.insert(j, item); + } + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + match kind.as_str() { + "chart" => { + if let Some(i) = sh.charts.iter().position(|c| c.id == id) { + reorder(&mut sh.charts, i, forward, extreme); + } + } + "image" => { + if let Some(i) = sh.images.iter().position(|c| c.id == id) { + reorder(&mut sh.images, i, forward, extreme); + } + } + _ => { + if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { + reorder(&mut sh.shapes, i, forward, extreme); + } + } + } + Ok(Json::Null) + }) +} + +pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let mut v = Vec::new(); + for c in &sh.shapes { + v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.images { + v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.charts { + v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + Ok(Json::Array(v)) +} + +fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { + match kind { + "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + } +} + +pub(super) fn align(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let ids: Vec = + p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); + let how = str_param(p, "align").unwrap_or("left").to_string(); + let si = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let geo: Vec<(u32, f64, f64, f32, f32)> = { + let shc = sheet.clone(); + anchors_of(sheet, &kind) + .into_iter() + .filter(|(id, _)| ids.contains(id)) + .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) + .collect() + }; + if geo.len() < 2 { + return Ok(Json::Null); + } + let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); + let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); + let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); + let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); + let shc = sheet.clone(); + for (id, a) in anchors_of(sheet, &kind) { + let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; + let (mut x, mut y) = (g.1, g.2); + match how.as_str() { + "left" => x = left, + "right" => x = right - g.3 as f64, + "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, + "top" => y = top, + "bottom" => y = bottom - g.4 as f64, + _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, + } + let c = CellRef::new(shc.row_at(y), shc.col_at(x)); + a.cell = c; + a.dx = (x - shc.col_left(c.col)) as f32; + a.dy = (y - shc.row_top(c.row)) as f32; + } + Ok(Json::Null) + }) +} + +pub(super) fn rotate(s: &mut Session, p: &Json) -> Result { + if p.get("flip").is_some_and(|f| !f.is_null()) { + return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); + } + with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) +} diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs new file mode 100644 index 000000000..b14fbdb23 --- /dev/null +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -0,0 +1,181 @@ +use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param}; + +pub(super) fn record(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { + let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; + let cmds: Vec = s + .journal + .iter() + .skip(start) + .filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str())) + .map(|(id, p)| json!({"command": id, "params": p})) + .collect(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); + s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; + return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); + } + s.recording = Some(s.journal.len()); + Ok(json!({"recording": true})) +} + +pub(super) fn new_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); + let cmds = p.get("commands").cloned().unwrap_or(json!([])); + if !cmds.is_array() { + return Err(bad("automate.newScript", "`commands` must be an array")); + } + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + all[name.as_str()] = cmds.clone(); + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(json!({"name": name})) + }) +} + +pub(super) fn all_scripts(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) +} + +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. +fn denied_script_step(id: &str, params: &Json) -> Option { + if id.starts_with("file.") || id.starts_with("link.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + // Scripts are scoped to their workbook: a step that switches the active window would + // point every later step at another open document, so those are refused too. + if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + if params.get("path").is_some() { + return Some(format!("scripts can't pass a file `path` to `{id}`")); + } + None +} + +pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result { + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. +pub(super) fn run_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); + let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } + let _sandbox = crate::io::ScriptSandbox::enter(); + let mut n = 0; + // What ran, for a recorder in progress: the recording keeps these steps with the run, so + // editing or deleting the script before the recording stops does not change what it saved. + let mut ran_steps: Vec<(String, Json)> = Vec::new(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if id.starts_with("automate.") { + continue; + } + let before = s.ui_requests.len(); + let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone())); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + let result = ran?; + n += 1; + if s.recorder.is_some() { + // The recorder's rule for each step, as for a command run on its own: a menu-style + // step that only opened a dialog is left out (playing it back would stop at the + // dialog), and a toggle keeps the state it set. + let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..)))); + let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb))); + if !opened_dialog || changed { + ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result))); + } + } + } + if s.recorder.is_some() { + s.script_ran = Some(ran_steps); + } + Ok(json!({"ran": n})) +} + +/// Redo work there again. A macro being debugged on that workbook is stopped with them. +pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result { + // Inside another command the open groups are that command's own (an action in progress): + // a step of it that stopped them would pull the workbook from under the rest of its run. + if s.depth() > 1 { + return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into())); + } + let uid = s.doc()?.uid; + let mut out = json!({"stopped": s.abort_groups(uid)}); + // A macro being debugged here had its group among them: its run is over. + if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) { + out["macro"] = json!(name); + out["message"] = json!(format!( + "Macro '{name}', which was being debugged, was stopped and rolled back. {}", + crate::macros::run::rolled_back(&wrote_files) + )); + } + Ok(out) +} + +pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + if let Some(o) = all.as_object_mut() { + o.remove(&name); + } + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs new file mode 100644 index 000000000..b20a00a61 --- /dev/null +++ b/crates/engine/src/cmd/extra/window.rs @@ -0,0 +1,187 @@ +use super::{ + CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet, + stash_get, stash_map, stash_set, str_param, target_sheet, u32_param, +}; + +/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it +/// neither records an undo step nor marks a clean workbook as changed. +pub(super) fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc_mut()?; + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(si) { + sh.view = mode; + } + if !dirty { + d.saved = d.wb.clone(); + } + Ok(json!({"view": mode.as_str()})) +} + +pub(super) fn ruler(s: &mut Session, p: &Json) -> Result { + let on = bool_param(p, "on").unwrap_or(s.hide_ruler); + s.hide_ruler = !on; + Ok(json!({"on": on})) +} + +/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current +/// list when they swap in another snapshot of the workbook. +pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { + let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); + let keep = find(from); + if keep == find(to) { + return; + } + let wb = std::sync::Arc::make_mut(to); + wb.names.retain(|n| n.name != WATCH_NAME); + wb.names.extend(keep); +} + +pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let mut views = stash_map(&d.wb, VIEWS_NAME); + if let Some(name) = str_param(p, "save") { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } else if let Some(name) = str_param(p, "show") { + let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; + if let Some(si) = v["sheet"].as_u64() { + s.execute("sheet.activate", json!({"sheet": si}))?; + } + if let Some(sel) = v["selection"].as_str() { + s.execute("selection.set", json!({"range": sel}))?; + } + if let Some(z) = v["zoom"].as_u64() { + s.execute("view.zoom", json!({"percent": z}))?; + } + if let Some(f) = v.get("freeze") { + let freeze = match (f[0].as_u64(), f[1].as_u64()) { + (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), + _ => None, + }; + edit(s, |cx| { + let si = cx.sheet_index(); + cx.sheet_mut(si)?.freeze = freeze; + Ok(()) + })?; + } + } else if let Some(name) = str_param(p, "delete") { + if let Some(o) = views.as_object_mut() { + o.remove(name); + } + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } + Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) +} + +pub(super) fn split(s: &mut Session, _: &Json) -> Result { + let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); + if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } +} + +pub(super) fn new_window(s: &mut Session, _: &Json) -> Result { + let id = s.new_window()?; + let doc = s.window(id).map(|w| w.doc_uid); + Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) +} + +/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most +/// recent window), else the active window. +pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { + let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; + s.target_window(t).map_err(|e| bad(cmd, e.to_string())) +} + +/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, +/// and the interface moves its native windows ([`Session::window_list`] order, active first). +pub(super) fn arrange_all(s: &mut Session, p: &Json) -> Result { + let arrange = match str_param(p, "arrange").unwrap_or("tiled") { + a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, + other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), + }; + let active = s.active_window(); + let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); + let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); + let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); + if let Some(at) = windows.iter().position(|w| Some(*w) == active) + && let Some(first) = windows.get_mut(..=at) + { + first.rotate_right(1); + } + Ok(json!({"arrange": arrange, "windows": windows})) +} + +pub(super) fn hide_window(s: &mut Session, p: &Json) -> Result { + let id = window_param(s, "view.hideWindow", p)?; + s.hide_window(id); + ok() +} + +pub(super) fn unhide_window(s: &mut Session, p: &Json) -> Result { + let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + Some(window_param(s, "view.unhideWindow", p)?) + } else if let Some(i) = u32_param(p, "index") { + // A workbook's position in the document list. + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; + s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) + } else { + s.last_hidden_window() + }; + if let Some(id) = id { + s.unhide_window(id); + } + ok() +} + +pub(super) fn switch_windows(s: &mut Session, p: &Json) -> Result { + if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + let id = window_param(s, "view.switchWindows", p)?; + s.unhide_window(id); + } + Ok(s.window_list()) +} + +pub(super) fn watch_window(s: &mut Session, p: &Json) -> Result { + let mut list: Vec = + stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let mut changed = false; + if let Some(a) = str_param(p, "add") { + if !list.iter().any(|x| x == a) { + list.push(a.to_string()); + } + changed = true; + } + if let Some(r) = str_param(p, "remove") { + list.retain(|x| x != r); + changed = true; + } + if changed { + let v = json!(list.clone()); + let d = s.doc_mut()?; + // The watch list is view state: don't add an undo step. + let wb = std::sync::Arc::make_mut(&mut d.wb); + stash_set(wb, WATCH_NAME, &v); + } + let d = s.doc()?; + let rows: Vec = list + .iter() + .map(|w| { + let (sheet, body) = split_sheet(w); + let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); + let c = CellRef::parse(body).unwrap_or_default(); + let sh = d.wb.sheet(si); + json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) + }) + .collect(); + Ok(Json::Array(rows)) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9af85b9dd..b3b515c2e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -2,7 +2,7 @@ use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param}; use crate::DocState; pub fn specs() -> Vec { @@ -13,7 +13,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close), - cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), + cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), cmd!( @@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result { wb.set_active_sheet(target_sheet(s, p)?); } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") }; - let bytes = crate::io::save_bytes(&wb, &target)?; + let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) { + crate::io::csv_values_bytes(&wb)? + } else { + crate::io::save_bytes(&wb, &target)? + }; crate::io::write_file(&target, &bytes)?; Ok(json!({"path": target, "bytes": bytes.len()})) } diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index c45c1179f..ad6973b23 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -11,7 +11,7 @@ use gridcraft_functions::Timeline; use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param}; use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 30efbf5a6..19c004a72 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1,10 +1,13 @@ //! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format. use gridcraft_core::{CellRef, RangeRef}; -use gridcraft_model::*; +use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param, + target_ranges, target_sheet, +}; pub fn specs() -> Vec { vec![ @@ -801,445 +804,15 @@ fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result { set_fmt(s, p, &new) } -/// Built-in cell styles (our own palette). -pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option\"a"), - "{html}" - ); - assert!(html.contains("
"); - } - - #[test] - fn html_export_places_images_at_their_anchor_using_sheet_geometry() { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); - sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); - sh.images.push(gridcraft_model::Image { - id: 1, - anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, - data: vec![1].into(), - mime: "image/png".into(), - alt: String::new(), - }); - sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); - let html = to_html(&wb, 0).unwrap(); - // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); - } - - fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); - sh.images.push(gridcraft_model::Image { - id: 1, - anchor: gridcraft_model::Anchor { - cell: CellRef::parse(anchor).unwrap(), - dx: 0.0, - dy: 0.0, - width: 10.0, - height: 10.0, - mode: Default::default(), - }, - data: vec![1].into(), - mime: "image/png".into(), - alt: String::new(), - }); - wb - } - - #[test] - fn html_export_places_images_past_hidden_columns_and_rows() { - let mut wb = book_with_picture("C3", (0, 0)); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); - sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); - sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); - let html = to_html(&wb, 0).unwrap(); - // Column B and row 2 take no room: C3 is one default column and row from A1. - assert_eq!(html.matches(""), "{html}"); - assert!(html.contains(""), "Latin text keeps its own direction: {html}"); - assert!( - html.contains(""), - "numbers mirror to the left and keep their sign in front: {html}" - ); - let clip = copied(&wb, "A1:B1"); - assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); - - let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); - assert!(to_html(<r, 0).unwrap().contains("
")); - assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); - assert!(html.contains(""), "explicit alignment stays: {html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); - - let clip = copied(&wb, "A1:D1"); - let cells: Vec<&str> = clip.split("") && html.contains(">far")); - } - - #[test] - fn macros_are_saved_only_into_xlsm() { - let mut wb = Workbook::new(); - wb.vba_project = Some(b"vba".to_vec().into()); - let has_macros = |path: &str| { - let bytes = save_bytes(&wb, path).unwrap(); - gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() - }; - assert!(has_macros("book.xlsm")); - assert!(has_macros("BOOK.XLSM")); - assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); - assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); - assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); - assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); - } - - #[test] - fn csv_named_history_does_not_get_the_reserved_sheet_name() { - for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { - let (wb, _) = open_bytes(file, b"1\n").unwrap(); - let name = wb.sheets[0].name.clone(); - assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); - } - let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); - assert_eq!(wb.sheets[0].name, "Sales"); - } - - /// 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"))] - #[test] - fn failed_save_leaves_no_temporary_file() { - let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let blocked = dir.join("Book1.xlsx"); - std::fs::create_dir(&blocked).unwrap(); - assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); - assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); - // An ordinary save still works and leaves only the workbook. - let ok = dir.join("Book2.xlsx"); - write_file(ok.to_str().unwrap(), b"workbook").unwrap(); - assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); - assert!(!dir.join("Book2.xlsx.sctmp").exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// While a script runs (#2178), file reads and writes are refused whatever command or - /// parameter asked for them; nesting and dropping the sandbox restore access. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn script_sandbox_refuses_file_access_until_dropped() { - let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let existing = dir.join("in.csv"); - std::fs::write(&existing, b"a,b\n").unwrap(); - let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); - { - let _outer = ScriptSandbox::enter(); - { - let _inner = ScriptSandbox::enter(); - assert!(read_file(&existing).is_err()); - } - assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); - assert!(write_file(&out, b"leak").is_err()); - assert!(!std::path::Path::new(&out).exists()); - } - assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); - write_file(&out, b"ok").unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a - /// root entry and the given stream names. - fn cfb(streams: &[&str]) -> Vec { - let mut b = vec![0u8; 512 * 3]; - let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); - put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); - put(30, &9u16.to_le_bytes()); - put(44, &1u32.to_le_bytes()); - put(48, &1u32.to_le_bytes()); - put(68, &0xFFFF_FFFEu32.to_le_bytes()); - for i in 1..109 { - put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); - } - put(512, &0xFFFF_FFFDu32.to_le_bytes()); - put(516, &0xFFFF_FFFEu32.to_le_bytes()); - for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { - let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); - put(1024 + k * 128, &units); - put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); - } - b - } - - /// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last - /// two columns, a part name containing a comma. - const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ - 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; - - #[test] - fn csv_save_keeps_the_separator_it_was_opened_with() { - let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - assert_eq!(wb.csv_delimiter, Some(b';')); - assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); - // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only - // fields that contain the separator in use). - assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); - let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; - let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); - // A tab-separated file named .csv round-trips with tabs. - let tabs = "a\tb\r\n1\t2\r\n"; - let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); - } - - #[test] - fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { - let mut wb = Workbook::new(); - if let Some(sh) = wb.sheet_mut(0) { - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); - sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); - } - assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); - // Through .xlsx the CSV origin is gone, as in Excel. - let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); - assert_eq!(reopened.csv_delimiter, None); - assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); - // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. - let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); - assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); - assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); - } - - #[test] - fn encrypted_workbook_is_reported_not_misread() { - let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); - let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); - // A compound file naming the streams but unreadable is damage: no password is asked for, - // and none helps. - let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); - for password in [None, Some("pw")] { - let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); - assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - } - } - - #[test] - fn encryption_no_password_opens_is_reported_without_asking_for_one() { - let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES - let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); - assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); - } - - #[test] - fn other_compound_files_are_not_called_encrypted() { - let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("not an Excel workbook"), "got: {msg}"); - assert!(!msg.contains("password"), "got: {msg}"); - } - - /// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, - /// whole values in narrow columns (never `#####`), quoting unchanged. - #[test] - fn csv_writes_displayed_text() { - use gridcraft_core::{CellError, Value}; - use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; - let mut wb = Workbook::new(); - let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); - let (date, money, pct, frac, text, fixed, thousands) = - (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); - let sh = wb.sheet_mut(0).unwrap(); - let cells = [ - ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }), - ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }), - ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }), - ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }), - ("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }), - ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }), - ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }), - ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), - ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }), - ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }), - ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }), - ]; - for (a, c) in cells { - sh.set_cell(CellRef::parse(a).unwrap(), c); - } - // Column B is far too narrow for its numbers: the grid shows #####, the file must not. - sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); - sh.show_formulas = true; - let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); - assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); - let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); - assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); - } - - #[test] - fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { - use gridcraft_core::Value; - use gridcraft_model::{Cell, NumFmt, StyleId}; - let mut workbook = Workbook::new(); - let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); - let sheet = workbook.sheet_mut(0).unwrap(); - sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); - for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { - let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); - assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); - } - } - - #[test] - fn template_extension_saves_template_content_type() { - let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut types = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); - assert!( - types.contains( - r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""# - ), - "{types}" - ); - } - - /// Where `part`'s entry starts in the central directory of `package`. - fn central_entry(package: &[u8], part: &str) -> usize { - let mut at = 0; - while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { - let entry = at + i; - let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; - if &package[entry + 46..entry + 46 + len] == part.as_bytes() { - return entry; - } - at = entry + 46; - } - panic!("{part} is not in the package"); - } - - /// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the - /// declared size, so no real 512 MB part has to be built. - fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { - let at = central_entry(&package, part); - package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); - package - } - - #[test] - fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); - let package = save_bytes(&wb, "big.xlsx").unwrap(); - // The size reported in storytold/gridcraft#1242. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); - assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); - assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("bytes"), "{msg}"); - // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("several sheets"), "{msg}"); - // Just over the limit never reads as a size equal to it. - let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); - assert!(msg.contains("more than 512 MB"), "{msg}"); - // A package that fails for another reason keeps its own error, oversized part or not. - let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); - let at = central_entry(&broken, "xl/workbook.xml"); - broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; - let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); - } - - #[test] - fn content_wins_over_a_mismatched_extension() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); - let package = save_bytes(&wb, "book.xlsx").unwrap(); - for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { - let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); - } - for name in ["data.xlsx", "data.csv"] { - let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); - } - } -} +mod tests; diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs new file mode 100644 index 000000000..775c59eed --- /dev/null +++ b/crates/engine/src/io/tests.rs @@ -0,0 +1,563 @@ +use super::*; + +fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook { + let mut wb = Workbook::new(); + let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect(); + let sh = wb.sheet_mut(0).unwrap(); + sh.right_to_left = rtl; + for ((at, value, _), id) in cells.iter().zip(ids) { + let c = CellRef::parse(at).unwrap(); + sh.set_value(c, value.clone()); + sh.set_style(c, id); + } + wb +} + +fn copied(wb: &Workbook, range: &str) -> String { + let r = RangeRef::parse(range).unwrap(); + range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] +#[test] +fn save_fails_upfront_when_the_temp_name_is_taken() { + // Another local user who can write the directory must not be able to point the + // save's predictable temporary name at their own file: creation is exclusive. + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"original").unwrap(); + let dest = dir.join("book.xlsx"); + std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap(); + let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err(); + assert_eq!(std::fs::read(&victim).unwrap(), b"original"); + assert!(!dir.join("book.xlsx").exists(), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] +#[test] +fn save_keeps_the_destination_access_restrictions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("private.xlsx"); + std::fs::write(&dest, b"v1").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file(dest.to_str().unwrap(), b"v2").unwrap(); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + // And a brand-new file is created with the restrictive, not permissive, default. + let fresh = dir.join("new.xlsx"); + write_file(fresh.to_str().unwrap(), b"v").unwrap(); + let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777; + assert!(mode & 0o022 == 0, "mode {mode:o}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_refuses_directories_and_unknowable_lengths() { + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); + assert!(read_file(dir.join("missing").to_str().unwrap()).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { + // #2387: the used rectangle alone bounds the output (every row writes the whole + // column span), so a sparse sheet with one cell at A1 and another far right + // 300 rows down is 4.9M cells → refused before a byte of `"), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); +} + +fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { + cell: CellRef::parse(anchor).unwrap(), + dx: 0.0, + dy: 0.0, + width: 10.0, + height: 10.0, + mode: Default::default(), + }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + wb +} + +#[test] +fn html_export_places_images_past_hidden_columns_and_rows() { + let mut wb = book_with_picture("C3", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); + sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + let html = to_html(&wb, 0).unwrap(); + // Column B and row 2 take no room: C3 is one default column and row from A1. + assert_eq!(html.matches("
"), HAlign::General), - ("B2", Value::Number(3.0), HAlign::General), - ("A3", Value::text("Total"), HAlign::Right), - ], - ); - wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); - assert_eq!( - to_html(&wb, 0).unwrap(), - "Sheet1\ - \ - \ - \ -
NameQty
Apples & <pears>3
Total
" - ); - let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; - assert_eq!( - copied(&wb, "A1:B2"), - format!( - "\ - \ -
NameQty
Apples & <pears>3
" - ) - ); - } - - #[test] - fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); - sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); - assert!(save_bytes(&wb, "book.html").is_err()); - let sh = wb.sheet_mut(0).unwrap(); - sh.remove_cell(CellRef::new(149_999, 0)); - sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); - let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); - assert!(html.contains(">near
` is written. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into())); + sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into())); + let err = to_html(&wb, 0).unwrap_err(); + assert!(err.to_string().contains("too tall × wide"), "{err}"); + // A normal sheet is fine. + let mut ok = Workbook::new(); + ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into())); + assert!(to_html(&ok, 0).is_ok()); +} + +#[test] +fn html_export_embeds_images_as_data_uris() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, + data: vec![1, 2, 3].into(), + mime: "image/png".into(), + alt: "a \"logo\"".into(), + }); + let html = to_html(&wb, 0).unwrap(); + assert!( + html.contains("\"a"), + "{html}" + ); + assert!(html.contains("
"); +} + +#[test] +fn html_export_places_images_at_their_anchor_using_sheet_geometry() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); + let html = to_html(&wb, 0).unwrap(); + // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. + assert!(html.contains("
"), "{html}"); + assert!(html.contains(""), "Latin text keeps its own direction: {html}"); + assert!(html.contains(""), "numbers mirror to the left and keep their sign in front: {html}"); + let clip = copied(&wb, "A1:B1"); + assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); + + let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); + assert!(to_html(<r, 0).unwrap().contains("
")); + assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); + assert!(html.contains(""), "explicit alignment stays: {html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); + + let clip = copied(&wb, "A1:D1"); + let cells: Vec<&str> = clip.split("") && html.contains(">far")); +} + +#[test] +fn macros_are_saved_only_into_xlsm() { + let mut wb = Workbook::new(); + wb.vba_project = Some(b"vba".to_vec().into()); + let has_macros = |path: &str| { + let bytes = save_bytes(&wb, path).unwrap(); + gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() + }; + assert!(has_macros("book.xlsm")); + assert!(has_macros("BOOK.XLSM")); + assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); + assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); + assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); +} + +#[test] +fn csv_named_history_does_not_get_the_reserved_sheet_name() { + for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { + let (wb, _) = open_bytes(file, b"1\n").unwrap(); + let name = wb.sheets[0].name.clone(); + assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); + } + let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); + assert_eq!(wb.sheets[0].name, "Sales"); +} + +/// 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"))] +#[test] +fn failed_save_leaves_no_temporary_file() { + let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let blocked = dir.join("Book1.xlsx"); + std::fs::create_dir(&blocked).unwrap(); + assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); + assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); + // An ordinary save still works and leaves only the workbook. + let ok = dir.join("Book2.xlsx"); + write_file(ok.to_str().unwrap(), b"workbook").unwrap(); + assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); + assert!(!dir.join("Book2.xlsx.sctmp").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// While a script runs (#2178), file reads and writes are refused whatever command or +/// parameter asked for them; nesting and dropping the sandbox restore access. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a +/// root entry and the given stream names. +fn cfb(streams: &[&str]) -> Vec { + let mut b = vec![0u8; 512 * 3]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(30, &9u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + put(512, &0xFFFF_FFFDu32.to_le_bytes()); + put(516, &0xFFFF_FFFEu32.to_le_bytes()); + for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(1024 + k * 128, &units); + put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); + } + b +} + +/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last +/// two columns, a part name containing a comma. +const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ + 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; + +#[test] +fn csv_save_keeps_the_separator_it_was_opened_with() { + let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + assert_eq!(wb.csv_delimiter, Some(b';')); + assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); + // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only + // fields that contain the separator in use). + assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); + let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; + let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); + // A tab-separated file named .csv round-trips with tabs. + let tabs = "a\tb\r\n1\t2\r\n"; + let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); +} + +#[test] +fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); + sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); + } + assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); + // Through .xlsx the CSV origin is gone, as in Excel. + let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); + assert_eq!(reopened.csv_delimiter, None); + assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); + // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. + let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); + assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); + assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); +} + +#[test] +fn encrypted_workbook_is_reported_not_misread() { + let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); + let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); + // A compound file naming the streams but unreadable is damage: no password is asked for, + // and none helps. + let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); + for password in [None, Some("pw")] { + let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); + assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + } +} + +#[test] +fn encryption_no_password_opens_is_reported_without_asking_for_one() { + let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES + let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); + assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); +} + +#[test] +fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); + assert!(!msg.contains("password"), "got: {msg}"); +} + +/// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, +/// whole values in narrow columns (never `#####`), quoting unchanged. +#[test] +fn csv_writes_displayed_text() { + use gridcraft_core::{CellError, Value}; + use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; + let mut wb = Workbook::new(); + let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let (date, money, pct, frac, text, fixed, thousands) = + (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); + let sh = wb.sheet_mut(0).unwrap(); + let cells = [ + ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }), + ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }), + ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }), + ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }), + ("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }), + ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }), + ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }), + ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), + ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }), + ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }), + ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }), + ]; + for (a, c) in cells { + sh.set_cell(CellRef::parse(a).unwrap(), c); + } + // Column B is far too narrow for its numbers: the grid shows #####, the file must not. + sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); + sh.show_formulas = true; + let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); + assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); + let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); + assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); +} + +#[test] +fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { + use gridcraft_core::Value; + use gridcraft_model::{Cell, NumFmt, StyleId}; + let mut workbook = Workbook::new(); + let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); + let sheet = workbook.sheet_mut(0).unwrap(); + sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); + for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { + let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); + assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); + } +} + +#[test] +fn template_extension_saves_template_content_type() { + let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut types = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); + assert!( + types.contains(r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""#), + "{types}" + ); +} + +/// Where `part`'s entry starts in the central directory of `package`. +fn central_entry(package: &[u8], part: &str) -> usize { + let mut at = 0; + while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { + let entry = at + i; + let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; + if &package[entry + 46..entry + 46 + len] == part.as_bytes() { + return entry; + } + at = entry + 46; + } + panic!("{part} is not in the package"); +} + +/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the +/// declared size, so no real 512 MB part has to be built. +fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { + let at = central_entry(&package, part); + package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); + package +} + +#[test] +fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); + let package = save_bytes(&wb, "big.xlsx").unwrap(); + // The size reported in storytold/gridcraft#1242. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); + assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); + assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("bytes"), "{msg}"); + // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("several sheets"), "{msg}"); + // Just over the limit never reads as a size equal to it. + let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); + assert!(msg.contains("more than 512 MB"), "{msg}"); + // A package that fails for another reason keeps its own error, oversized part or not. + let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); + let at = central_entry(&broken, "xl/workbook.xml"); + broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; + let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); +} + +#[test] +fn content_wins_over_a_mismatched_extension() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); + let package = save_bytes(&wb, "book.xlsx").unwrap(); + for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { + let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); + } + for name in ["data.xlsx", "data.csv"] { + let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f0676c772..3ae4ac95a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod display; pub mod fill; pub mod io; pub mod links; +pub mod macros; pub mod outline; pub mod paper; pub mod pivot; @@ -54,6 +55,12 @@ pub enum EngineError { NoDocument, #[error("{0}")] Other(String), + /// The command would replace something; run it again with `overwrite: true` to go ahead. + #[error("confirm: {0} (run again with `overwrite: true`)")] + Confirm(String), + /// Active content (a macro) asks to run; run it again with `approve: true` to allow it. + #[error("approve: {0} (run again with `approve: true`)")] + Approve(String), #[error("internal error in `{0}` (the workbook was kept as it was): {1}")] Internal(String, String), } @@ -97,6 +104,9 @@ struct OpenGroup { selection: Selection, /// The redo stack when the group began (a rollback puts it back). redo: Vec, + /// Where the recording was when the group began (`None` when nothing was recording): a + /// rollback takes back the steps recorded since, as it takes back their edits. + recorded: Option<(u64, u64)>, } /// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the @@ -203,6 +213,12 @@ pub struct DocState { /// workbook is opened from a file, carried by content pasted from such a workbook, and never /// changed by a trust decision. pub trust: Trust, + /// While a macro's footprint is measured: every cell a command wrote, by sheet name, even + /// where it wrote what was already there. + pub touched: Option>, + /// While a macro step runs: the structural edits its command made (the sheet's name, the + /// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do. + pub edits: Option>, /// The references `edit.goTo` with `record: true` went to in this document, newest first, /// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`). pub go_to_recent: Vec, @@ -231,6 +247,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize { let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells); bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next)); } + // Snapshots share the macro list until a change to it; a snapshot with its own list keeps it. + if !older.macros.shares(&newer.macros) { + bytes = bytes.saturating_add(older.macros.estimated_bytes()); + } bytes } @@ -303,6 +323,8 @@ impl DocState { content_kind: None, scripts_trusted, trust, + touched: None, + edits: None, go_to_recent: vec![], } } @@ -446,6 +468,9 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, + /// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the + /// dry run is put back, and journaling it would evict the person's own oldest entries. + pub(crate) journal_paused: u32, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -460,6 +485,17 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, + /// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was + /// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the + /// recording hook. + pub(crate) script_ran: Option>, + /// Macros the person allowed to run in this session: (document uid, the macro's content as + /// canonical JSON, from [`macros::fingerprint`]). + pub macro_approvals: std::collections::HashSet<(u64, String)>, + /// The macro being debugged, between debugger actions. + pub macro_debug: Option, + /// Where the macro recorder started (beside `recorder`, its buffer). + pub macro_recording: Option, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. pub draw_tool: String, pub draw_color: String, @@ -535,6 +571,10 @@ impl Session { if i < self.docs.len() { let d = self.docs.remove(i); self.groups.retain(|g| g.doc != d.uid); + // A macro being debugged in this workbook ends with it; its group just went. + if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) { + self.macro_debug = None; + } self.windows.retain(|w| w.doc_uid != d.uid); if self.active_window.is_some_and(|w| self.window(w).is_none()) { self.activate_recent(); @@ -547,6 +587,7 @@ impl Session { pub fn new_workbook(&mut self) -> usize { let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1); let mut wb = Workbook::new(); + wb.props.author = self.prefs.user_name.clone(); for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); @@ -586,6 +627,16 @@ impl Session { } } + /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. + pub(crate) fn truncate_journal(&mut self, keep: usize) { + if self.journal_sizes.len() == self.journal.len() { + self.journal_sizes.truncate(keep); + self.journal_bytes = self.journal_sizes.iter().sum(); + } + self.journal.truncate(keep); + self.recording = self.recording.map(|r| r.min(self.journal.len())); + } + pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } @@ -598,7 +649,25 @@ impl Session { if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } + // A debugger whose undo group something else ended locks nothing any more (`macro.debug` + // itself says that the run had ended). + if id != "macro.debug" { + macros::debug::drop_if_ended(self); + } + let query = !spec.journal && !spec.undoable; + // While a macro is being debugged its undo group is open, and its workbook is locked: + // between steps only the debugger, queries and commands that look around run there + // (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view + // modes, stopping Record Actions, which saves a script), also when another command runs + // it. Only the run's own steps go ahead. Other workbooks stay editable. + let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping())); + if locked && !macros::debug::runs_while_locked(id, query) { + return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into())); + } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); + // A dry run inside this command that a panic cuts short leaves journaling paused and its + // touch log open; the panic branch puts both back. + let paused_before = self.journal_paused; let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let (first_group, group_epoch) = (self.next_group, self.group_epoch); // Whether a group was open on the document before the command, and how long its history was. @@ -608,7 +677,6 @@ impl Session { // because a nested `edit.undo` removes entries. A query runs nothing that changes the // history (`every_query_leaves_the_history_alone` runs each one), so it needs no // copy; front ends run queries all the time. - let query = !spec.journal && !spec.undoable; let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { #[cfg(test)] tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); @@ -669,7 +737,16 @@ impl Session { } d.calc.rebuild(&d.wb); } + if self.journal_paused != paused_before { + self.journal_paused = paused_before; + for d in self.docs.iter_mut() { + d.touched = None; + } + } if self.depth == 0 { + // Only the `macro.debug` call itself can lose the run's place: a command nested + // in a step that panics is an error of that step, which the run handles. + macros::debug::recover(self); self.trim_history(); self.group_step = None; } @@ -687,13 +764,33 @@ impl Session { // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no // file, `home.formatCells` with no style) is not recorded either: the dialog's own // call is, so playing the recording back runs the command once, with its parameters. + // A call that opened a dialog and changed the workbook too (a script with an edit before + // a menu-style step) is recorded: the edit happened, and the dialog's confirming call + // is a step of its own. let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); - if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) + let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id)) && let Some(rec) = self.recorder.as_mut() { - rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); + // A toggle (Bold) is recorded with the state it set, as Excel records + // `Bold = True`: playing it back sets that state rather than flipping it again. + let params = match &result { + Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r), + _ => cmd::journal_params(¶ms), + }; + let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); + // A script or macro run is kept with the steps it ran, so editing or deleting the + // script or macro before the recording stops does not change what was recorded. + if let Some(ran) = self.script_ran.take() + && kept + && matches!(id, "automate.runScript" | "macro.run") + { + rec.attach_ran(ran); + } + } + if self.journal_paused == 0 { + self.push_journal(id, cmd::journal_params(¶ms)); } - self.push_journal(id, cmd::journal_params(¶ms)); } // Inside an undo group the group owns the step. A command that ended a group open before // it leaves the group its step and makes one of its own for what it changed after the end. @@ -808,6 +905,7 @@ impl Session { wb: d.wb.clone(), selection: d.selection.clone(), redo: d.redo.clone(), + recorded: self.recorder.as_ref().map(record::Recorder::mark), }; let token = GroupToken { id: g.id, doc: g.doc }; self.next_group = self.next_group.saturating_add(1); @@ -904,6 +1002,11 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; + // The steps recorded while the group was open go with their edits (all of them when the + // recording started inside the group). + if let Some(rec) = self.recorder.as_mut() { + rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc); + } // The record of the last group step stays when the document is back exactly where that // step left it (this group began there); otherwise the rollback is where the group ended. let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb)); @@ -935,6 +1038,21 @@ impl Session { self.depth } + /// Runs a command as a command in progress would (one level deeper), for tests of what a + /// nested call may do. + #[cfg(test)] + pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result { + self.depth = self.depth.saturating_add(1); + let r = self.execute(id, params); + self.depth = self.depth.saturating_sub(1); + r + } + + /// Whether the token's undo group is still open (nothing has ended or rolled it back). + pub fn group_is_open(&self, token: &GroupToken) -> bool { + self.groups.iter().any(|g| g.id == token.id) + } + /// Whether an undo group is open on the document with this uid. pub fn in_group(&self, doc: u64) -> bool { self.groups.iter().any(|g| g.doc == doc) diff --git a/crates/engine/src/macros/debug.rs b/crates/engine/src/macros/debug.rs new file mode 100644 index 000000000..7811ecb3f --- /dev/null +++ b/crates/engine/src/macros/debug.rs @@ -0,0 +1,694 @@ +//! The macro debugger: a run that stops between steps. +//! +//! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next +//! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one +//! action per call) inside an undo group that spans them; Stop keeps the changes as one undo step +//! or rolls them back. While a macro is being debugged its workbook is locked: only the +//! debugger, queries and the commands in [`LOOKS_AROUND`] run there, and everything else, saving +//! included, is refused ("Finish or stop the macro first"). Other workbooks stay editable. The +//! debugger runs on its own, never as a step of a script or macro, and is never recorded. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gridcraft_model::Workbook; +use serde_json::{Value as Json, json}; + +use super::run::{Machine, StepError, rolled_back}; +use crate::{EngineError, GroupToken, Result, Selection, Session}; + +/// The most breakpoints one call sets. +pub const MAX_BREAKPOINTS: usize = 10_000; +/// The most entries `changed` lists (ranges and sheet notes), as the footprint lists ranges. +const MAX_LISTED: usize = 500; + +/// Commands that run on the debugged workbook between steps, besides `macro.debug` and queries. +/// They only look around (the selection, the active sheet, Go To, Find, zoom, copying), switch to +/// another window, close the workbook (which ends the run) or stop the run (Stop Action). The +/// debugger puts the run's active sheet and selection back before its next step runs. +pub const LOOKS_AROUND: &[&str] = &[ + "selection.set", + "selection.move", + "selection.next", + "selection.row", + "selection.column", + "selection.currentRegion", + "edit.selectAll", + "sheet.activate", + "sheet.next", + "sheet.previous", + "edit.goTo", + "edit.goToSpecial", + "edit.find", + "edit.copy", + "view.zoom", + "view.zoom100", + "view.zoomToSelection", + "window.activate", + "file.close", + "automate.stopAction", +]; + +/// Whether command `id` (a query when `query`) runs on a workbook the debugger locks. +pub fn runs_while_locked(id: &str, query: bool) -> bool { + // `macro.check` is a query, but its dry run runs the macro's steps, which the lock refuses: it + // waits too, rather than report the lock as the failure of a step. + (query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id) +} + +/// A macro being debugged. +pub struct Debugger { + pub name: String, + pub doc: u64, + /// Out of the session only while a step runs. + machine: Option, + token: Option, + /// Breakpoints set for this run (step paths), on top of the steps' own. + pub breakpoints: BTreeSet, + /// Only `breakpoints` stop the run; the steps' own flags are ignored (an editor that shows + /// its own breakpoints sends them all, so clearing one takes effect at once). + pub only_listed: bool, + /// The workbook when the run started, for "changed so far". + start: Arc, + /// Every step path in the macro, for checking `runTo` targets and breakpoints. + paths: BTreeSet, + /// The workbooks the run has touched (by uid). Steps run on the macro's own workbook and the + /// policy refuses window and file commands, so this is the macro's workbook unless a command + /// reaches another one some other way; edits of these wait until the run ends. + touched: BTreeSet, + /// Steps the footprint's dry runs executed before the run (they count towards the run's + /// budget, not towards `executed`). + base: u64, + /// The conflict rule that shifted the data at the start (`shiftDown`, `shiftRight`). + shifted: Option<&'static str>, + /// Where the run left its workbook after the last action. + view: Option, +} + +/// The active sheet and the selections of the debugged workbook where the run left them. +#[derive(Clone)] +struct View { + sheet: usize, + selection: Selection, + sheet_selections: Vec, +} + +impl Debugger { + /// Whether edits of the workbook with this uid wait for the run to end. + pub fn locks(&self, doc: u64) -> bool { + self.touched.contains(&doc) + } + + /// Whether one of the run's steps is running (its machine is out of the session). + pub fn stepping(&self) -> bool { + self.machine.is_none() + } + + /// The run's own steps so far. + fn executed(&self) -> u64 { + self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base)) + } +} + +/// Each open workbook's uid and its current version, to see which ones a step changes. +fn versions(s: &Session) -> Vec<(u64, Arc)> { + s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect() +} + +/// Adds to the run's touched log the workbooks that changed since `before`. +fn note_touched(s: &mut Session, before: &[(u64, Arc)]) { + let changed: Vec = + s.docs.iter().filter(|d| before.iter().any(|(uid, wb)| *uid == d.uid && !Arc::ptr_eq(wb, &d.wb))).map(|d| d.uid).collect(); + if let Some(dbg) = s.macro_debug.as_mut() { + dbg.touched.extend(changed); + } +} + +/// Remembers where the run left its workbook: the active sheet and the selections. +fn remember_view(s: &mut Session) { + let Some(uid) = s.macro_debug.as_ref().map(|d| d.doc) else { return }; + let view = s.docs.iter().find(|d| d.uid == uid).map(|d| View { + sheet: d.wb.active_sheet(), + selection: d.selection.clone(), + sheet_selections: d.sheet_selections.clone(), + }); + if let Some(dbg) = s.macro_debug.as_mut() { + dbg.view = view; + } +} + +/// Puts back the active sheet and the selections where the run left them, so the next step runs +/// where `macro.run` would run it, however the person looked around meanwhile. +fn put_back_view(s: &mut Session) { + let Some((uid, Some(view))) = s.macro_debug.as_ref().map(|d| (d.doc, d.view.clone())) else { return }; + let Some(d) = s.docs.iter_mut().find(|d| d.uid == uid) else { return }; + if view.sheet < d.wb.sheets.len() { + // The active sheet is view state: setting it doesn't make the workbook dirty. + d.wb.set_active_sheet(view.sheet); + } + d.selection = view.selection; + d.sheet_selections = view.sheet_selections; +} + +/// The message for edits refused while a macro is being debugged. +pub const BUSY: &str = "Finish or stop the macro first"; + +/// Ends a debugger whose undo group is no longer open (something other than the debugger ended +/// or rolled it back): its workbook is not locked any more. Returns the macro's name when it did. +pub(crate) fn drop_if_ended(s: &mut Session) -> Option { + let d = s.macro_debug.as_ref()?; + if d.stepping() || d.token.as_ref().is_some_and(|t| s.group_is_open(t)) { + return None; + } + s.macro_debug.take().map(|d| d.name) +} + +/// After Stop Action rolled back the undo groups of the workbook with uid `doc`: ends the +/// debugger if its run was there. Returns the macro's name and the files the run wrote, which stay. +pub(crate) fn end_stopped(s: &mut Session, doc: u64) -> Option<(String, Vec)> { + if !s.macro_debug.as_ref().is_some_and(|d| d.doc == doc && !d.stepping()) { + return None; + } + let d = s.macro_debug.take()?; + Some((d.name, d.machine.map(|m| m.wrote_files).unwrap_or_default())) +} + +/// Why an action finds the run already ended: its undo group was closed outside the debugger. +fn already_ended(name: &str) -> String { + format!("Macro '{name}' was no longer being debugged: its undo group had been closed outside the debugger, so nothing was kept or rolled back.") +} + +enum Mode { + Step, + Continue, + RunTo(String), +} + +const ACTIONS: [&str; 6] = ["start", "step", "continue", "runTo", "stop", "state"]; + +/// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?, +/// breakpoints?, breakpointsOnly?}`. +pub fn debug(s: &mut Session, p: &Json) -> Result { + // A step of a script or macro that started the debugger would leave it behind when the + // script fails and rolls back its groups, the debugger's among them. + if s.depth() > 1 { + return Err(EngineError::Disabled("macro.debug".into(), "The macro debugger runs on its own, not as a step of a script or macro.".into())); + } + let ended = drop_if_ended(s); + let action = match p.get("action") { + None | Some(Json::Null) if s.macro_debug.is_some() || ended.is_some() => "state".to_string(), + None | Some(Json::Null) => "start".to_string(), + Some(Json::String(a)) => a.clone(), + // `{"action": {"runTo": {"step": "3"}}}` + Some(Json::Object(o)) if o.len() == 1 => o.keys().next().cloned().unwrap_or_default(), + Some(_) => return Err(bad("`action` must be start, step, continue, runTo, stop or state")), + }; + if !ACTIONS.contains(&action.as_str()) { + return Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{action}'"))); + } + if let Some(name) = ended + && action != "start" + { + return Err(EngineError::Other(already_ended(&name))); + } + let only = bool_param(p, "breakpointsOnly")?; + if action == "start" { + return start(s, p, only.unwrap_or(false)); + } + let keep = bool_param(p, "keep")?; + let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; + let target = if action == "runTo" { + let target = p + .get("step") + .or_else(|| p.pointer("/action/runTo/step")) + .and_then(path_of) + .ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?; + if !d.paths.contains(&target) { + return Err(bad(format!("macro '{}' has no step {target}", d.name))); + } + Some(target) + } else { + None + }; + let breakpoints = p.get("breakpoints").filter(|v| !v.is_null()).map(|v| paths_param(v, &d.paths, &d.name)).transpose()?; + if let Some(d) = s.macro_debug.as_mut() { + if let Some(bps) = breakpoints { + d.breakpoints = bps; + } + if let Some(only) = only { + d.only_listed = only; + } + } + match (action.as_str(), target) { + ("step", _) => advance(s, Mode::Step), + ("continue", _) => advance(s, Mode::Continue), + ("runTo", Some(target)) => advance(s, Mode::RunTo(target)), + ("stop", _) => stop(s, keep.unwrap_or(false)), + _ => state(s), + } +} + +fn bad(msg: impl Into) -> EngineError { + crate::cmd::bad("macro.debug", msg) +} + +fn not_debugging() -> EngineError { + EngineError::Other("No macro is being debugged. Start one with action: start.".into()) +} + +/// `true`, `false`, or not given (`null` counts as not given); anything else is refused. +fn bool_param(p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(format!("`{key}` must be true or false"))), + } +} + +/// A step path from JSON: `"2.1"` or `3`. +fn path_of(v: &Json) -> Option { + match v { + Json::String(t) if !t.trim().is_empty() => Some(t.trim().to_string()), + Json::Number(n) => n.as_u64().map(|n| n.to_string()), + _ => None, + } +} + +/// The `breakpoints` list: step paths of macro `name` (one of `paths`), at most +/// [`MAX_BREAKPOINTS`]. +fn paths_param(v: &Json, paths: &BTreeSet, name: &str) -> Result> { + let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?; + if list.len() > MAX_BREAKPOINTS { + return Err(bad(format!("`breakpoints` lists {} steps; at most {MAX_BREAKPOINTS} can be set", list.len()))); + } + let mut out = BTreeSet::new(); + for x in list { + let path = path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))?; + if !paths.contains(&path) { + return Err(bad(format!("macro '{name}' has no step {path} for a breakpoint"))); + } + out.insert(path); + } + Ok(out) +} + +fn start(s: &mut Session, p: &Json, only_listed: bool) -> Result { + if let Some(d) = &s.macro_debug { + return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name))); + } + let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?; + let b = super::run::begin(s, name, p)?; + let paths = all_paths(&b.mac.steps, ""); + let breakpoints = match p.get("breakpoints").filter(|v| !v.is_null()) { + Some(v) => paths_param(v, &paths, &b.mac.name)?, + None => BTreeSet::new(), + }; + let start_wb = s.doc()?.wb.clone(); + let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?; + // The run's machine: unless the rule is overwrite, it checks each step for writes over data. + let (mut machine, shifted) = match super::run::make_way(s, &b) { + Ok(made) => made, + Err(e) => { + let _ = s.group_rollback(token); + return Err(e); + } + }; + // Each action has the time limit to itself; the dry runs just had theirs. + machine.restart_clock(); + let base = machine.executed; + s.macro_debug = Some(Debugger { + name: b.mac.name.clone(), + doc: b.prepared.doc, + machine: Some(machine), + token: Some(token), + breakpoints, + only_listed, + start: start_wb, + paths, + touched: BTreeSet::from([b.prepared.doc]), + base, + shifted: shifted.then(|| b.rule.name()), + view: None, + }); + if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) { + return finish(s, None); + } + remember_view(s); + state(s) +} + +fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for (i, st) in steps.iter().enumerate() { + let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) }; + match &st.kind { + gridcraft_model::StepKind::Repeat { body, .. } => out.extend(all_paths(body, &path)), + gridcraft_model::StepKind::If { then, otherwise, .. } => { + out.extend(all_paths(then, &format!("{path}.then"))); + out.extend(all_paths(otherwise, &format!("{path}.else"))); + } + _ => {} + } + out.insert(path); + } + out +} + +/// Runs one step (`Step`), or steps until a breakpoint, the target step or the end. +fn advance(s: &mut Session, mode: Mode) -> Result { + put_back_view(s); + let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?; + let (name, base) = (d.name.clone(), d.base); + let breakpoints = d.breakpoints.clone(); + let only_listed = d.only_listed; + let mut machine = d.machine.take().ok_or_else(not_debugging)?; + machine.restart_clock(); + let before = versions(s); + let mut first = true; + let mut hit = false; + let mut outcome = Ok(()); + while !machine.is_done() { + if !first { + let Some((path, st)) = machine.current() else { break }; + if matches!(mode, Mode::Step) { + break; + } + if (st.breakpoint && !only_listed) || breakpoints.contains(&path) { + hit = true; + break; + } + if let Mode::RunTo(target) = &mode + && *target == path + { + break; + } + } + first = false; + outcome = machine.step(s); + if outcome.is_err() { + break; + } + } + let done = machine.is_done(); + match s.macro_debug.as_mut() { + Some(d) => d.machine = Some(machine), + // The macro.debug call panicked and `recover` has already rolled the run back. + None => { + return match outcome { + Err(e) => Ok(failed(&name, machine.executed.saturating_sub(base), &e)), + Ok(()) => Err(not_debugging()), + }; + } + } + note_touched(s, &before); + if let Err(e) = outcome { + return finish(s, Some(e)); + } + if done { + return finish(s, None); + } + remember_view(s); + let mut out = state(s)?; + out["atBreakpoint"] = json!(hit); + Ok(out) +} + +/// What every answer about the run's end or its state adds: the data shift at the start and the +/// files the run wrote (they stay, whatever happens to the workbook). +fn add_notes(out: &mut Json, d: &Debugger, wrote_files: &[String]) { + if let Some(rule) = d.shifted { + out["shifted"] = json!(rule); + } + if !wrote_files.is_empty() { + out["wroteFiles"] = json!(wrote_files); + } +} + +/// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a +/// rollback) or a failed step (rolled back). +fn finish(s: &mut Session, error: Option) -> Result { + let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; + let (changed, cells) = changed(s, &d); + let executed = d.executed(); + let machine = d.machine.take(); + let stopped = machine.as_ref().and_then(|m| m.stopped.clone()); + let wrote = machine.map(|m| m.wrote_files).unwrap_or_default(); + let rollback = error.is_some() || matches!(stopped, Some((_, true))); + let ended = match d.token.take() { + Some(token) if rollback => s.group_rollback(token).is_ok(), + Some(token) => s.group_end(token).is_ok(), + None => false, + }; + if !ended { + let step = error.map(|e| format!("{} ", e.describe())).unwrap_or_default(); + return Err(EngineError::Other(format!("{step}{}", already_ended(&d.name)))); + } + if let Some(e) = &error { + return Ok(failed(&d.name, executed, e)); + } + let mut out = json!({"macro": d.name, "executed": executed}); + match &stopped { + Some((msg, rolled)) => { + out["state"] = json!("stopped"); + out["stopped"] = json!(msg); + out["kept"] = json!(!rolled); + if *rolled { + out["message"] = json!(rolled_back(&wrote)); + } + } + None => out["state"] = json!("finished"), + } + if !rollback { + out["changed"] = json!(changed); + out["changedCells"] = json!(cells); + } + add_notes(&mut out, &d, &wrote); + Ok(out) +} + +/// The result of a run that a failed step rolled back. +fn failed(name: &str, executed: u64, e: &StepError) -> Json { + let mut out = json!({ + "macro": name, + "executed": executed, + "state": "failed", + "error": serde_json::to_value(e).unwrap_or(Json::Null), + "message": format!("{} {}", e.describe(), rolled_back(&e.wrote_files)), + }); + if !e.wrote_files.is_empty() { + out["wroteFiles"] = json!(e.wrote_files); + } + out +} + +/// Stops the run: keeps what it did as one undo step, or rolls it back. +fn stop(s: &mut Session, keep: bool) -> Result { + let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) }; + let (changed, cells) = changed(s, &d); + let ended = match d.token.take() { + Some(token) if keep => s.group_end(token).is_ok(), + Some(token) => s.group_rollback(token).is_ok(), + None => false, + }; + if !ended { + return Err(EngineError::Other(already_ended(&d.name))); + } + let wrote = d.machine.as_ref().map(|m| m.wrote_files.clone()).unwrap_or_default(); + let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": d.executed()}); + if keep { + out["changed"] = json!(changed); + out["changedCells"] = json!(cells); + } else { + out["message"] = json!(rolled_back(&wrote)); + } + add_notes(&mut out, &d, &wrote); + Ok(out) +} + +/// Where the run is: the next step with its parameters as they would run, the step that just +/// ran, and what the run has changed in its workbook so far. +fn state(s: &mut Session) -> Result { + let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?; + let machine = d.machine.as_ref().ok_or_else(not_debugging)?; + let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?; + let (changed, cells) = changed(s, d); + let mut out = json!({ + "state": "paused", + "macro": d.name, + "current": machine.current_json(&wb), + "lastParams": machine.last_params, + "executed": d.executed(), + "breakpoints": d.breakpoints, + "changed": changed, + "changedCells": cells, + }); + add_notes(&mut out, d, &machine.wrote_files); + Ok(out) +} + +/// What the run changed in its workbook since it started: cells as `Sheet!A1:B2` ranges and the +/// sheets it added, deleted or renamed (at most [`MAX_LISTED`] entries), and how many cells +/// changed on the sheets the workbook had at the start. +fn changed(s: &Session, d: &Debugger) -> (Vec, usize) { + let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return (vec![], 0) }; + let (pairs, added) = super::run::pair_sheets(&d.start, &now); + let mut out = Vec::new(); + let mut count = 0usize; + for (bi, ai) in pairs { + let Some(before) = d.start.sheets.get(bi) else { continue }; + let Some(after) = ai.and_then(|a| now.sheets.get(a)) else { + out.push(format!("{} (deleted sheet)", before.name)); + continue; + }; + if before.name != after.name { + out.push(format!("{} (renamed from {})", after.name, before.name)); + } + if Arc::ptr_eq(before, after) { + continue; + } + let mut cells = before.cells.diff(&after.cells); + count = count.saturating_add(cells.len()); + if out.len() < MAX_LISTED { + let q = super::resolve::quote_sheet(&after.name); + out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}"))); + } + } + out.extend(added.into_iter().filter_map(|a| now.sheets.get(a)).map(|sh| format!("{} (new sheet)", sh.name))); + out.truncate(MAX_LISTED); + (out, count) +} + +/// After the `macro.debug` call itself panicked: if the run lost its place, roll it back so its +/// undo group doesn't stay open. +pub(crate) fn recover(s: &mut Session) { + if s.macro_debug.as_ref().is_some_and(Debugger::stepping) + && let Some(mut d) = s.macro_debug.take() + && let Some(token) = d.token.take() + { + let _ = s.group_rollback(token); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_run_that_lost_its_place_is_rolled_back() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + let start = s.doc().unwrap().wb.clone(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + // As if a step had panicked with the machine out of the session. + s.macro_debug.as_mut().unwrap().machine = None; + super::recover(&mut s); + assert!(s.macro_debug.is_none()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert!(!s.in_group(s.doc().unwrap().uid)); + s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); + } + + #[test] + fn a_step_that_panics_reports_the_step_failure() { + crate::register_test_command("test.debugStepPanics", |_, _| panic!("boom")); + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "test.debugStepPanics", "params": {}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + let start = s.doc().unwrap().wb.clone(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + let r = s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(r["state"], "failed", "{r}"); + assert_eq!(r["macro"], "M"); + assert_eq!(r["error"]["step"], "2"); + assert!(r["message"].as_str().unwrap().contains("boom"), "{r}"); + assert!(s.macro_debug.is_none()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert!(!s.in_group(s.doc().unwrap().uid)); + } + + #[test] + fn a_workbook_in_the_touched_log_waits_until_the_run_ends() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + s.new_workbook(); + let other = s.doc().unwrap().uid; + s.set_active(0); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + assert!(!s.macro_debug.as_ref().unwrap().locks(other)); + // As if a step had changed the other workbook too. + let before = super::versions(&s); + let d = s.docs.iter_mut().find(|d| d.uid == other).unwrap(); + let mut wb = (*d.wb).clone(); + wb.props.title = "changed by the macro".into(); + d.wb = std::sync::Arc::new(wb); + super::note_touched(&mut s, &before); + assert!(s.macro_debug.as_ref().unwrap().locks(other)); + s.set_active(1); + assert!(s.execute("cell.set", json!({"cell": "B1", "input": "no"})).is_err()); + s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap(); + } + + #[test] + fn edits_run_by_another_command_wait_too() { + let mut s = Session::new(); + s.new_workbook(); + let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "A2", "input": "y"}} + ]})) + .unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.push(m); + d.wb = std::sync::Arc::new(wb); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap(); + s.execute("macro.debug", json!({"name": "M"})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + // Record Actions changes nothing itself; stopping it saves the script with a nested + // command, which would land in the run's undo group. + let e = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap_err().to_string(); + assert!(e.contains(super::BUSY), "{e}"); + assert!(s.execute("automate.allScripts", json!({})).unwrap().as_array().unwrap().is_empty()); + // The run's own steps still go ahead. + let r = s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(r["executed"], 2, "{r}"); + assert!(s.macro_debug.is_none()); + // The recording was kept: stopping now saves it. + let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap(); + assert_eq!(saved["saved"], "S", "{saved}"); + let scripts = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(scripts[0]["name"], "S", "{scripts}"); + } +} diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs new file mode 100644 index 000000000..ecbe6f674 --- /dev/null +++ b/crates/engine/src/macros/mod.rs @@ -0,0 +1,515 @@ +//! GridCraft macros: typed, declarative command lists, not VBA. The model is plain data in +//! `gridcraft_model::Macro`; this module reads and writes it and keeps it within its limits. +//! +//! Storage: the JSON format keeps `Workbook::macros` as a field. In xlsx each macro is one item of +//! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro__`, written when the +//! file is saved and taken back out of the names when it is opened, so the names never linger in +//! the open document. Scripts made with Automate › New Script or Record Actions are a separate, +//! older feature with their own storage; macros neither read nor change it. A recording keeps the +//! steps a script run ran while it recorded, as the run reported them. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use gridcraft_model::{DefinedName, Macro, Step, StepKind, Workbook}; +use serde_json::{Value as Json, json}; + +use crate::cmd::stash; + +pub mod debug; +pub mod policy; +pub mod recorder; +pub mod resolve; +pub mod run; + +/// The hidden-data key prefix of a stored macro (`macro_`). +const MACRO_KEY: &str = "macro_"; + +/// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the +/// short form `{"command": "cell.set", "params": {…}}`. +pub fn parse_macro(v: &Json) -> Result { + // A newer format is refused before its steps are read, so the message says why. + if let Some(found) = v.get("v").and_then(Json::as_u64) { + gridcraft_model::check_version(found)?; + } + let mut v = v.clone(); + if let Some(steps) = v.get_mut("steps") { + normalize_steps(steps, 1)?; + } + if v.get("v").is_none() + && let Some(o) = v.as_object_mut() + { + o.insert("v".into(), json!(gridcraft_model::MACRO_VERSION)); + } + let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?; + m.name = m.name.trim().to_string(); + check(&m)?; + Ok(m) +} + +/// Every check a macro must pass before it is kept in a workbook: the model's limits +/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro, +/// from JSON, from a file or built in code, goes through this one check. +pub fn check(m: &Macro) -> Result<(), String> { + m.check_limits()?; + check_size(m) +} + +/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of +/// JSON). Every command that adds or changes a macro calls [`check`], so the size of a macro kept +/// in the workbook doesn't make a save fail. (A clashing visible name doesn't either: +/// [`with_names`] stores the macro under another index.) +fn check_size(m: &Macro) -> Result<(), String> { + if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS { + return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS)); + } + Ok(()) +} + +/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and +/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's +/// JSON is measured once. +pub fn fit_size(m: &mut Macro) -> usize { + let chars = |v: &Json| v.to_string().chars().count(); + let steps = std::mem::take(&mut m.steps); + // The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the + // first. + let mut size = chars(¯o_json(m)); + let mut kept = 0usize; + for (i, st) in steps.iter().enumerate() { + let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0)); + if size.saturating_add(add) > stash::MAX_ITEM_CHARS { + break; + } + size = size.saturating_add(add); + kept = i.saturating_add(1); + } + let cut = steps.len().saturating_sub(kept); + m.steps = steps; + m.steps.truncate(kept); + cut +} + +/// How many macros the workbook holds: the readable ones and the ones kept in a newer format +/// (`Workbook::macros_unread`), which a save writes back and the next open counts too. +pub fn count(wb: &Workbook) -> usize { + wb.macros.len().saturating_add(wb.macros_unread.len()) +} + +/// Refuses one more macro in a workbook that holds [`gridcraft_model::MAX_MACROS`] already, +/// counting the kept ones, so a save never holds more than the next open keeps. +pub fn check_room(wb: &Workbook) -> Result<(), String> { + let max = gridcraft_model::MAX_MACROS; + if count(wb) < max { + return Ok(()); + } + Err(match wb.macros_unread.len() { + 0 => format!("A workbook can hold at most {max} macros."), + kept => format!( + "A workbook can hold at most {max} macros, and {kept} of the {} in this one {} made by a newer version of GridCraft and kept unchanged.", + count(wb), + if kept == 1 { "was" } else { "were" } + ), + }) +} + +/// The name of the macro kept in a newer format called `name` (compared as [`find`] compares), +/// as the file held it. Its name is taken: a new or renamed macro with it would clash when the +/// newer version reads the file. +pub fn kept_name(wb: &Workbook, name: &str) -> Option { + let name = name.trim(); + wb.macros_unread + .iter() + .filter_map(|v| v.get("name").and_then(Json::as_str)) + .map(str::trim) + .find(|k| k.eq_ignore_ascii_case(name)) + .map(str::to_string) +} + +/// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit +/// before the steps are deserialized. +fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { + let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) }; + for st in list { + let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) }; + if !o.contains_key("type") + && let Some(id) = o.remove("command") + { + o.insert("type".into(), json!("command")); + o.insert("id".into(), id); + } + // A step with a body is a Repeat or an If: one at the top is nested 1 deep. + let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key)); + if nested && level > gridcraft_model::MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); + } + for key in ["body", "then", "else"] { + if let Some(body) = o.get_mut(key) { + normalize_steps(body, level + 1)?; + } + } + } + Ok(()) +} + +/// A macro as JSON (the shape `macro.get` returns and `macro.save` takes). +pub fn macro_json(m: &Macro) -> Json { + serde_json::to_value(m).unwrap_or(Json::Null) +} + +/// The index of the macro called `name` (case-insensitive). +pub fn find(wb: &Workbook, name: &str) -> Option { + let name = name.trim(); + wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) +} + +/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds +/// until the macro changes. It is the text itself rather than a hash, so no two different macros +/// share it. +pub fn fingerprint(m: &Macro) -> String { + macro_json(m).to_string() +} + +/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro__`) instead of the +/// field, or `None` when there is nothing to change. The macros keep their order; the index `n` +/// is this build's choice, so it steps around any a person's own visible name already uses. +pub fn with_names(wb: &Workbook) -> crate::Result> { + // Only the numbered items are this build's: a hidden `_gc_macro__` name with some + // other key stays, as `load` leaves it. + let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some()); + if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) { + return Ok(None); + } + // A macro that skipped `check` (built in code) fails the save, as the JSON save does, rather + // than being written and dropped the next time the file opens. + for m in wb.macros.iter() { + check(m).map_err(crate::EngineError::Other)?; + } + let mut out = wb.clone(); + // One pass drops the items of the last save, and one writes the macros (a file can hold a + // thousand macros and many thousands of names). + out.names.retain(|n| !stored(n)); + // A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't + // write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a + // person defined after the file opened) blocks its item's key, in any chunk, so the macros + // take the free indices from 0 up. The keys compare as the stash reads them, so this skips + // exactly what `set_many` would refuse, and no arithmetic depends on a number from the file. + let blocked: HashSet = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect(); + let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key)); + // Macros in a newer format follow the readable ones, written back as the file held them. + let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string)); + let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect(); + stash::set_many(&mut out, &items)?; + // An empty list of its own, not a copy of the shared one cleared. + out.macros = gridcraft_model::Macros::default(); + out.macros_unread = std::sync::Arc::default(); + Ok(Some(out)) +} + +/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into +/// the `macros` list as the file held them. +pub fn json_bytes(wb: &Workbook) -> crate::Result> { + // Every path that adds a macro checks it, so this refuses only one that skipped the check, as + // the xlsx save does, instead of writing a macro the next open would drop. + for m in &wb.macros { + check(m).map_err(crate::EngineError::Other)?; + } + let bytes = if wb.macros_unread.is_empty() { + serde_json::to_vec_pretty(wb) + } else { + serde_json::to_value(wb).and_then(|mut v| { + if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) { + list.extend(wb.macros_unread.iter().cloned()); + } + serde_json::to_vec_pretty(&v) + }) + }; + bytes.map_err(|e| crate::EngineError::Other(e.to_string())) +} + +/// Whether `v` is a macro in a newer format than this build reads. +fn is_newer(v: &Json) -> bool { + v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION)) +} + +/// Keeps a macro in a newer format unread, if it is small enough to save back +/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write). +fn keep_unread(unread: &mut Vec, v: Json, bad: &mut usize) { + if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS { + unread.push(v); + } else { + *bad = bad.saturating_add(1); + } +} + +/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps +/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged +/// or over the limits. Returns a warning for what was kept unread and one for what was dropped. +pub fn load(wb: &mut Workbook) -> Vec { + let mut warnings = Vec::new(); + let mut bad = 0usize; + let mut unread = Vec::new(); + // Entries of the JSON format's field, each read on its own so a damaged one drops alone. + let read = std::mem::take(&mut wb.macros_read); + bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS)); + for v in read.into_iter().take(gridcraft_model::MAX_MACROS) { + if is_newer(&v) { + keep_unread(&mut unread, v, &mut bad); + continue; + } + match parse_macro(&v) { + Ok(m) => wb.macros.push(m), + Err(_) => bad += 1, + } + } + // One pass takes the hidden macro names out of the workbook, grouped by macro in saved order. + let mut stored: BTreeMap<(u64, String), Vec> = BTreeMap::new(); + let mut kept = Vec::new(); + for n in std::mem::take(&mut wb.names) { + match macro_item(&n) { + None => kept.push(n), + Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n), + // A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is, + // unread, and a save keeps it. + Some((_, None)) => kept.push(n), + } + } + wb.names = kept; + // Past the macro limit, the rest are counted without being read. + bad = bad.saturating_add(stored.len().saturating_sub(gridcraft_model::MAX_MACROS)); + let mut found = Vec::new(); + let mut item = Workbook::new(); + for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) { + item.names = names; + let v = stash::get_json(&item, &key); + if let Some(v) = v.as_ref().filter(|v| is_newer(v)) { + keep_unread(&mut unread, v.clone(), &mut bad); + continue; + } + match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) { + Ok(m) => found.push(m), + Err(_) => bad += 1, + } + } + // All of them go through the same limits, and a clashing name gets a suffix. + let mut all = std::mem::take(&mut wb.macros); + all.extend(found); + let mut taken = Taken::default(); + for m in all { + if wb.macros.len() >= gridcraft_model::MAX_MACROS { + bad += 1; + continue; + } + let mut m = m; + let checked = m.check_limits().and_then(|()| { + m.name = taken.unique(m.name.trim()); + // The macro as it is kept, renamed, must still pass, or the next save would lose it. + check(&m) + }); + match checked { + Ok(()) => { + taken.insert(&m.name); + wb.macros.push(m); + } + Err(_) => bad += 1, + } + } + // Readable and unread macros together stay within the limit, the readable ones first. + let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len()); + bad = bad.saturating_add(unread.len().saturating_sub(room)); + unread.truncate(room); + if !unread.is_empty() { + let one = unread.len() == 1; + warnings.push(format!( + "{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.", + unread.len(), + if one { "" } else { "s" }, + if one { "was" } else { "were" }, + if one { "it" } else { "them" }, + if one { "it" } else { "them" }, + )); + } + wb.macros_unread = std::sync::Arc::new(unread); + if bad > 0 { + warnings.push(format!( + "{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).", + if bad == 1 { "" } else { "s" }, + if bad == 1 { "it is" } else { "they are" }, + gridcraft_model::MAX_MACROS, + gridcraft_model::MAX_MACRO_STEPS, + gridcraft_model::MAX_MACRO_DEPTH + )); + } + warnings +} + +/// The item key (`macro_`, lower-cased) of a hidden macro name, and `n` when it is a number. +fn macro_item(n: &DefinedName) -> Option<(String, Option)> { + // Only a hidden workbook-scope name is macro storage: a person's own visible name that + // happens to look like one stays a name. + let key = stash::item_key(&n.name).filter(|k| n.hidden && n.scope.is_none() && k.starts_with(MACRO_KEY))?; + let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok()); + Some((key, i)) +} + +/// `name`, or `name (2)`, `name (3)`… if a macro already has it, a kept one too. A free name comes back as it is; +/// a renamed one is shortened to make room for the suffix, so it stays within +/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`]. +pub fn unique_name(wb: &Workbook, name: &str) -> String { + let mut taken = Taken::default(); + for m in &wb.macros { + taken.insert(&m.name); + } + for kept in wb.macros_unread.iter().filter_map(|v| v.get("name").and_then(Json::as_str)) { + taken.insert(kept); + } + taken.unique(name) +} + +/// Macro names in use, compared as [`find`] compares them, and the next suffix to try for each +/// shortened name and suffix width, so naming many clashing macros takes near-linear work. +#[derive(Default)] +struct Taken { + names: HashSet, + next: HashMap<(String, usize), u64>, + /// Name lookups made, for tests. + probes: usize, +} + +impl Taken { + fn key(name: &str) -> String { + name.trim().to_ascii_lowercase() + } + + fn insert(&mut self, name: &str) { + self.names.insert(Taken::key(name)); + } + + fn contains(&mut self, name: &str) -> bool { + self.probes = self.probes.saturating_add(1); + self.names.contains(&Taken::key(name)) + } + + /// The first of `name`, `name (2)`, `name (3)`… not in use. + fn unique(&mut self, name: &str) -> String { + let name = if name.is_empty() { "Macro" } else { name }; + if !self.contains(name) { + return name.to_string(); + } + let mut n: u64 = 2; + loop { + let suffix = format!(" ({n})"); + let keep = gridcraft_model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count()); + let base: String = name.chars().take(keep).collect(); + let base = base.trim_end(); + // Every candidate for this base and suffix width below `next` is in use already + // (names are only added), so the search goes on from there. + let key = (Taken::key(base), suffix.len()); + let next = self.next.get(&key).copied().unwrap_or(n); + if next > n { + n = next; + continue; + } + let Some(after) = n.checked_add(1) else { return name.to_string() }; + self.next.insert(key, after); + let candidate = format!("{base}{suffix}"); + if !self.contains(&candidate) { + return candidate; + } + n = after; + } + } +} + +/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a +/// macro cannot take. +pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[ + ('F', "Format Cells (Font) or Filter"), + ('G', "Workbook Statistics"), + ('H', "Replace"), + ('J', "Ungroup"), + ('K', "Group"), + ('L', "Filter"), + ('R', "Sort"), + ('S', "Save As"), + ('T', "AutoSum"), + ('Z', "Redo"), +]; + +/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give +/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift): +/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift. +pub fn parse_shortcut(s: &str) -> Option { + let letter = |t: &str| { + let mut chars = t.trim().chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), + _ => None, + } + }; + let t = s.trim().to_ascii_lowercase(); + ["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter) +} + +/// How a shortcut is stored and shown: `Ctrl+Shift+M`. +pub fn shortcut_text(letter: char) -> String { + format!("Ctrl+Shift+{}", letter.to_ascii_uppercase()) +} + +/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it. +pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option { + let letter = letter.to_ascii_uppercase(); + if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) { + return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter))); + } + wb.macros + .iter() + .find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)) + .map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name)) +} + +/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long, +/// holds line breaks or another macro has it. +pub fn check_new_name(wb: &Workbook, name: &str) -> Result { + let name = name.trim(); + let probe = Macro { name: name.to_string(), ..Macro::default() }; + probe.check_limits()?; + if name.chars().any(char::is_control) { + return Err("A macro name cannot hold line breaks or other control characters.".into()); + } + if find(wb, name).is_some() { + return Err(format!("A macro called “{name}” already exists. Choose another name.")); + } + if let Some(kept) = kept_name(wb, name) { + return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name.")); + } + Ok(name.to_string()) +} + +/// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]). +pub fn next_name(wb: &Workbook) -> String { + (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) +} + +/// Every command step of `steps` (inside Repeat and If too), with its path. +pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> { + fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) { + for (i, st) in steps.iter().enumerate() { + let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) }; + match &st.kind { + StepKind::Command { id, params } => out.push((path, id.as_str(), params)), + StepKind::Repeat { body, .. } => walk(body, &path, out), + StepKind::If { then, otherwise, .. } => { + walk(then, &format!("{path}.then"), out); + walk(otherwise, &format!("{path}.else"), out); + } + StepKind::Stop { .. } => {} + } + } + } + let mut out = Vec::new(); + walk(steps, "", &mut out); + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs new file mode 100644 index 000000000..a84097986 --- /dev/null +++ b/crates/engine/src/macros/policy.rs @@ -0,0 +1,148 @@ +//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or +//! closing files, driving the app, running other macros, or reading and writing files without the +//! person knowing. It goes by command id and the `path` parameter; the runner also runs every +//! step except an approved file step inside the scripts' file sandbox, so a command that touches a +//! file under another parameter can't either. + +use serde_json::Value as Json; + +use crate::Trust; + +/// Whether a command may run as a macro step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Policy { + Allow, + /// Allowed once the person approves the macro; the text says why it asks ("came with a + /// workbook you opened"). + Ask(String), + /// Reads or writes files: the macro must list the command in its permissions, and the person + /// approves the macro before it runs. The text says what it does ("reads a file"). + NeedsPermission(String), + /// Never runs in a macro; the text says why. + Deny(String), +} + +/// Prefixes of commands that never run in a macro, with the reason. +const DENIED_PREFIXES: &[(&str, &str)] = &[ + ("file.", "macros don't open, save, close or create workbooks"), + ("app.", "macros don't change the app"), + ("window.", "macros don't switch windows"), + ("automate.", "macros don't record or run scripts or other macros"), + ("macro.", "macros don't record or run other macros"), +]; + +/// Single commands that never run in a macro. +const DENIED: &[(&str, &str)] = &[ + ("edit.undo", "a macro is undone as one step after it runs"), + ("edit.redo", "a macro is undone as one step after it runs"), + ("view.newWindow", "macros don't open, hide or show windows"), + ("view.hideWindow", "macros don't open, hide or show windows"), + ("view.unhideWindow", "macros don't open, hide or show windows"), +]; + +/// Commands under a denied prefix that only change the workbook. +const ALLOWED: &[&str] = &["file.properties"]; + +/// Commands that read a file when given a `path`. +const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"]; + +/// File commands that only produce bytes unless given a `path` (then they write a file). +const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"]; + +/// Whether command `id` with `params` writes a file (an export or print given a `path`). +pub fn writes_file(id: &str, params: &Json) -> bool { + EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null()) +} + +/// The policy for running command `id` with `params` as a macro step in a document with `trust`. +/// +/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}` +/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The +/// runner checks the parameters both as written and as resolved. +pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { + let has_path = params.get("path").is_some_and(|p| !p.is_null()); + let base = if EXPORTS.contains(&id) { + if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow } + } else if ALLOWED.contains(&id) { + Policy::Allow + } else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) { + Policy::Deny((*why).into()) + } else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) { + Policy::Deny((*why).into()) + } else if READS_FILES.contains(&id) && has_path { + Policy::NeedsPermission("reads a file".into()) + } else { + Policy::Allow + }; + match base { + Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()), + other => other, + } +} + +/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops +/// before it and reports the footprint as partial. +pub fn previewable(id: &str) -> bool { + id != "data.solver" +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_deny_and_permission_lists() { + let none = json!({}); + let path = json!({"path": "/tmp/x"}); + for id in [ + "file.saveAs", + "file.save", + "file.open", + "file.close", + "file.new", + "app.commands", + "window.activate", + "automate.runScript", + "automate.recordActions", + "macro.run", + "macro.record", + "edit.undo", + "edit.redo", + "view.newWindow", + "view.hideWindow", + "view.unhideWindow", + ] { + assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}"); + } + for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] { + assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}"); + } + // Without a path they touch no file. + for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}"); + } + // A path that is blank or a token still touches a file once the step runs. + for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] { + for id in ["file.exportCsv", "data.fromTextCsv"] { + let p = json!({ "path": path }); + assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}"); + } + } + for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}"); + } + assert!(!previewable("data.solver") && previewable("cell.set")); + } + + #[test] + fn every_listed_command_exists() { + for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) { + assert!(crate::find_command(id).is_some(), "{id}"); + } + } +} diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs new file mode 100644 index 000000000..337c41661 --- /dev/null +++ b/crates/engine/src/macros/recorder.rs @@ -0,0 +1,528 @@ +//! Turning a recording into a macro. +//! +//! The recording buffer (`crate::record`) holds the top-level commands run while recording, each +//! with the sheet and selection it ran in. When recording stops: +//! +//! - commands a macro may not run (saving, opening files…) and other macros' commands are left +//! out; a macro run while recording is replaced by its steps when it has no inputs, no output +//! and only plain commands (otherwise it is left out), and an Automate script run while +//! recording by the steps it ran, leaving out the steps a macro may not run; both are the steps +//! as they ran, kept with the recording ([`crate::record::Recorder::ran`]), and a run without +//! them is left out; +//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed +//! between commands without one, and runs of `selection.set` collapse to the last; +//! - with relative references (the default), the selection when recording started is the example +//! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`, +//! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's +//! shape, header and column types are inferred from it as it was when recording started; +//! - recorded text that playback would read as a token stays text: a reference parameter that +//! reads as one (a defined name called `Output`) becomes `{"$text": …}`, and `{{` in a string +//! becomes `{{{{`. + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook}; +use serde_json::{Map, Value as Json, json}; +use std::collections::BTreeMap; + +use super::policy::{Policy, macro_policy}; +use crate::Selection; +use crate::record::RecordedStep; + +/// How far outside the example input a reference may be and still be made relative to it. +const NEAR_ROWS: u32 = 50; +const NEAR_COLS: u32 = 26; + +/// Where recording started, kept beside the buffer, with the name, shortcut and description the +/// macro is to be saved with (from the Record Macro dialog), applied when recording stops. +#[derive(Clone, Debug)] +pub struct RecordingStart { + pub doc: u64, + pub sheet: usize, + pub sheet_name: String, + pub selection: Selection, + pub relative: bool, + /// With relative references, the example input (the selection) as it was when recording + /// started: the recorded steps may change its values (text made into numbers), and the + /// macro is for data like the data it was recorded on. + pub input: Option, + /// The name, shortcut and description given when recording started (Record Macro). + pub name: Option, + pub shortcut: Option, + pub description: Option, +} + +impl RecordingStart { + /// Recording starting now in `d`. + pub fn new(d: &crate::DocState, relative: bool) -> RecordingStart { + let sheet = d.wb.active_sheet(); + RecordingStart { + doc: d.uid, + sheet, + sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + selection: d.selection.clone(), + relative, + input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())), + name: None, + shortcut: None, + description: None, + } + } +} + +/// The steps a recording keeps for a run of `m`: its enabled command steps as written, when they +/// stand on their own (no inputs, no output and only plain commands; a Repeat, If or Stop means +/// nothing outside its macro). `None` when they don't, and the recording leaves the run out. +pub fn inline_steps(m: &Macro) -> Option> { + if !m.inputs.is_empty() || m.output.anchor.is_some() || !m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) { + return None; + } + Some( + m.steps + .iter() + .filter(|x| x.enabled) + .filter_map(|x| match &x.kind { + StepKind::Command { id, params } => Some((id.clone(), params.clone())), + _ => None, + }) + .collect(), + ) +} + +/// Where a gathered command came from, which says how its parameters are written. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Origin { + /// Run while recording: plain parameters, which may become relative. + Recorded, + /// A step of a recorded script run: plain parameters, kept as they are. + Script, + /// A step of a recorded macro run: macro parameters (tokens and all), kept as written. + Macro, +} + +/// A recording turned into a macro, with what was left out. +pub struct Converted { + pub mac: Macro, + /// Commands left out (ids), in order. + pub skipped: Vec, + /// Steps not kept: past the macro's step limit (a recorded run of a long macro adds all of + /// its steps) or recorded in another workbook. + pub cut: usize, +} + +/// Builds a macro called `name` from the recorded steps. +/// `ran` is what a macro or script run ran, by step index ([`crate::record::Recorder::ran`]); a +/// run without an entry is left out and listed in `skipped`. +pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap>) -> Converted { + let mut skipped = Vec::new(); + // Commands as they'll be played back: (id, params, sheet before, selection before, where + // they came from). At most the macro's step limit are gathered: a recording of a thousand + // runs of a long macro would otherwise expand to millions of steps before the limit cut them + // back. + let mut cmds: Vec<(String, Json, usize, String, Selection, Origin)> = Vec::new(); + let mut cut = steps.iter().filter(|st| st.doc != start.doc).count(); + let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_)); + let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, Origin)>, cmd: (String, Json, usize, String, Selection, Origin)| { + if cmds.len() < MAX_MACRO_STEPS { + cmds.push(cmd); + } else { + cut = cut.saturating_add(1); + } + }; + for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) { + let id = st.id.as_str(); + // Another macro's steps (when they stand on their own, [`inline_steps`]) or an Automate + // script's, as they ran: what the run kept with the recording. Without them the run is + // left out; the macro or script as it stands now may not be what ran. + let origin = match id { + "macro.run" => Some(Origin::Macro), + "automate.runScript" => Some(Origin::Script), + _ => None, + }; + if let Some(origin) = origin { + match ran.get(&index) { + Some(steps) => { + for (cid, params) in steps { + if refused(cid, params) { + skipped.push(cid.clone()); + continue; + } + push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), origin)); + } + } + None => skipped.push(id.to_string()), + } + continue; + } + if refused(id, &st.params) { + skipped.push(id.to_string()); + continue; + } + push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), Origin::Recorded)); + } + + // Sheet and selection changes made without a command: where a command ran on another sheet, + // or with another selection, than the one before it, say so (a command that moved the + // selection itself does it again when played back, so the extra step is harmless). + // Each step keeps the name of the sheet it runs on. + let mut out: Vec<(String, Json, String, Origin)> = Vec::new(); + let (mut sheet, mut sel) = (start.sheet, start.selection.clone()); + for (id, params, at_sheet, sheet_name, at_sel, origin) in cmds { + if at_sheet != sheet { + out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), Origin::Recorded)); + } + // A recorded selection.set just before already put the selection there. + let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set"); + if (at_sel != sel || at_sheet != sheet) && !selected { + out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), Origin::Recorded)); + } + (sheet, sel) = (at_sheet, at_sel); + out.push((id, params, sheet_name, origin)); + } + // Runs of selection.set (or sheet.activate): the last one wins. + let mut collapsed: Vec<(String, Json, String, Origin)> = Vec::new(); + for (id, params, on, origin) in out { + if matches!(id.as_str(), "selection.set" | "sheet.activate") + && let Some((last, ..)) = collapsed.last() + && *last == id + { + collapsed.pop(); + } + collapsed.push((id, params, on, origin)); + } + + // Relative references. Only references on the input's sheet become tokens: on another sheet + // `C4` is that sheet's C4. + let example = start.selection.current(); + let mut used_input = false; + let steps: Vec = collapsed + .into_iter() + .map(|(id, params, on, origin)| { + // A step that names another sheet with its `sheet` parameter runs there, whatever + // sheet was active: its unqualified references are that sheet's cells. + let names_other_sheet = params.get("sheet").is_some_and(|v| match v { + Json::String(n) => !n.eq_ignore_ascii_case(&start.sheet_name), + Json::Number(i) => i.as_u64() != Some(start.sheet as u64), + _ => false, + }); + let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet; + // Plain text first, so the tokens added next are the only ones. + let params = if origin == Origin::Macro { params } else { literal(¶ms, None) }; + let params = if start.relative && origin == Origin::Recorded { + relative(¶ms, example, &start.sheet_name, here, &mut used_input, None) + } else { + params + }; + Step::command(id, params) + }) + .collect(); + let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() }; + if start.relative && used_input { + mac.inputs = vec![start.input.clone().unwrap_or_default()]; + } + Converted { mac, skipped, cut } +} + +fn selection_params(sel: &Selection) -> Json { + let ranges: Vec = sel.ranges.iter().map(RangeRef::a1).collect(); + json!({"range": ranges.join(","), "active": sel.active.a1()}) +} + +/// Plain recorded `params` written so playback reads them as they are: a string in a reference +/// parameter that playback would read as a token, or that holds `{{`, becomes `{"$text": …}`, +/// and `{{` in any other string becomes `{{{{`. +fn literal(v: &Json, key: Option<&str>) -> Json { + match v { + Json::Object(o) => Json::Object(o.iter().map(|(k, x)| (k.clone(), literal(x, Some(k)))).collect()), + Json::Array(a) => Json::Array(a.iter().map(|x| literal(x, key)).collect()), + Json::String(t) if key.is_some_and(super::resolve::is_ref_key) && (t.contains("{{") || super::resolve::reads_as_token(t, &["Input"])) => { + json!({"$text": t}) + } + Json::String(t) if t.contains("{{") => json!(t.replace("{{", "{{{{")), + other => other.clone(), + } +} + +/// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the +/// step runs on that sheet, so its unqualified references are on it. +fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json { + match v { + // Text kept as text ([`literal`]). + Json::Object(o) if o.len() == 1 && o.contains_key("$text") => v.clone(), + Json::Object(o) => { + // A selection of several areas stays as it is, its active cell with it: a token for + // the active cell alone would put it outside the areas on playback. + let several_areas = o.get("range").and_then(Json::as_str).is_some_and(|r| r.contains(',')); + let mut out = Map::new(); + for (k, x) in o { + let x = if several_areas && k == "active" { x.clone() } else { relative(x, example, sheet, here, used, Some(k)) }; + out.insert(k.clone(), x); + } + Json::Object(out) + } + Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()), + Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => { + // One area becomes a token; a list of areas ("A1:B2,D4") stays as it is. + if t.contains(',') { + return v.clone(); + } + match ref_token(t, example, sheet, here) { + Some(token) => { + *used = true; + json!(token) + } + None => v.clone(), + } + } + Json::String(t) if here && t.starts_with('=') && example.count() > 1 => { + let (f, changed) = formula_tokens(t, example); + *used |= changed; + json!(f) + } + other => other.clone(), + } +} + +/// The token for an A1 reference on the example's sheet near the example input. `here` says whether +/// an unqualified reference is on that sheet. +fn ref_token(text: &str, example: RangeRef, sheet: &str, here: bool) -> Option { + let (on, body) = crate::cmd::split_sheet(text.trim()); + match on { + Some(on) if !on.eq_ignore_ascii_case(sheet) => return None, + None if !here => return None, + _ => {} + } + let t = RangeRef::parse(body)?; + if t.is_full_cols() || t.is_full_rows() { + return None; + } + token(t, example) +} + +/// `t` as a token relative to `r`. +fn token(t: RangeRef, r: RangeRef) -> Option { + if t == r { + return Some("Input".into()); + } + let rel_row = |row: u32| i64::from(row) - i64::from(r.start.row) + 1; + let rel_col = |col: u32| i64::from(col) - i64::from(r.start.col) + 1; + let cols = |a: u32, b: u32| if a == b { format!("col({})", rel_col(a)) } else { format!("cols({}..{})", rel_col(a), rel_col(b)) }; + if r.contains_range(&t) { + let full_rows = t.start.row == r.start.row && t.end.row == r.end.row; + let full_cols = t.start.col == r.start.col && t.end.col == r.end.col; + return Some(if full_rows { + format!("Input.{}", cols(t.start.col, t.end.col)) + } else if full_cols && t.start.row == t.end.row { + format!("Input.row({})", rel_row(t.start.row)) + } else if full_cols && t.end.row == r.end.row { + format!("Input.rows({}..)", rel_row(t.start.row)) + } else if full_cols { + format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row)) + } else if t.is_single() { + format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col)) + } else { + format!("Input.cell({},{}).resize({},{})", rel_row(t.start.row), rel_col(t.start.col), t.height(), t.width()) + }); + } + let near = t.start.row.saturating_add(NEAR_ROWS) >= r.start.row + && t.end.row <= r.end.row.saturating_add(NEAR_ROWS) + && t.start.col.saturating_add(NEAR_COLS) >= r.start.col + && t.end.col <= r.end.col.saturating_add(NEAR_COLS); + if !near { + return None; + } + // Just below the input, in its columns: follows the input's length. + if t.start.row == r.end.row + 1 && t.start.col >= r.start.col && t.end.col <= r.end.col { + let base = if t.start.col == r.start.col && t.end.col == r.end.col { + "Input".to_string() + } else { + format!("Input.{}", cols(t.start.col, t.end.col)) + }; + return Some(if t.height() == 1 { format!("{base}.below") } else { format!("{base}.below({})", t.height()) }); + } + // Just right of the input, in its rows: follows its width. + if t.start.col == r.end.col + 1 && t.start.row >= r.start.row && t.end.row <= r.end.row { + let base = if t.start.row == r.start.row && t.end.row == r.end.row { + "Input".to_string() + } else if t.start.row == t.end.row { + format!("Input.row({})", rel_row(t.start.row)) + } else { + format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row)) + }; + return Some(if t.width() == 1 { format!("{base}.right") } else { format!("{base}.right({})", t.width()) }); + } + let cell = format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col)); + Some(if t.is_single() { cell } else { format!("{cell}.resize({},{})", t.height(), t.width()) }) +} + +/// Replaces references in a formula that are exactly the example input, one of its columns or +/// rows, or a column without its first row, with `{{token}}`. +fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) { + // Each reference in the formula text is read and matched on its own, so the work is the + // formula's length whatever the example's size (an example of a whole sheet has a million + // rows, which no list of candidates may grow with). + let mut out = String::with_capacity(f.len()); + let mut changed = false; + let (mut i, mut in_text) = (0, false); + let bytes = f.as_bytes(); + while i < f.len() { + let Some(rest) = f.get(i..) else { break }; + let ch = rest.chars().next().unwrap_or(' '); + if ch == '"' { + in_text = !in_text; + } + // A reference right after `{` stays as it is: `{{` before the token would read as the + // escape of a literal `{{`. + let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':' | b'{')); + if !in_text && prev_ok && ch.is_ascii_alphabetic() { + // The longest run of reference characters here: letters, digits and one `:`. + let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count(); + let text = rest.get(..n).unwrap_or(""); + let followed = rest.get(n..).and_then(|x| x.chars().next()).is_some_and(|c| matches!(c, '!' | '(' | '_' | '$')); + if !followed + && !text.contains('$') + && let Some(t) = RangeRef::parse(text) + && let Some(tok) = formula_token(t, r) + { + out.push_str("{{"); + out.push_str(&tok); + out.push_str("}}"); + i += n; + changed = true; + continue; + } + } + out.push(ch); + i += ch.len_utf8(); + } + (out, changed) +} + +/// The token for a reference inside a formula: exactly the example input, one of its columns or +/// rows, or a column without its first row. Other references stay as they are. +fn formula_token(t: RangeRef, r: RangeRef) -> Option { + if t == r { + return Some("Input".into()); + } + if !r.contains_range(&t) { + return None; + } + let col = usize::try_from(t.start.col.checked_sub(r.start.col)?).ok()?.checked_add(1)?; + let row = usize::try_from(t.start.row.checked_sub(r.start.row)?).ok()?.checked_add(1)?; + if t.start.col == t.end.col && t.start.row == r.start.row && t.end.row == r.end.row { + return Some(format!("Input.col({col})")); + } + if t.start.col == t.end.col && r.height() > 2 && t.start.row == r.start.row.checked_add(1)? && t.end.row == r.end.row { + return Some(format!("Input.rows(2..).col({col})")); + } + if r.width() > 1 && t.start.row == t.end.row && t.start.col == r.start.col && t.end.col == r.end.col { + return Some(format!("Input.row({row})")); + } + None +} + +/// The example input's shape: a header when the first row is all text over other kinds of +/// values, and each column's type when all its values agree. +pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput { + let mut input = NamedInput::default(); + if r.is_single() { + input.kind = InputKind::Cell; + input.rows = [1, 1]; + input.cols = [1, 1]; + return input; + } + input.cols = [r.width(), r.width()]; + let Some(sh) = wb.sheet(sheet) else { return input }; + if r.count() > 100_000 { + return input; + } + let kind = |c: CellRef| -> Option { + let v = sh.value(c); + Some(match v { + Value::Empty => return None, + Value::Text(t) if t.is_empty() => return None, + Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date, + Value::Number(_) => ColType::Number, + Value::Text(_) => ColType::Text, + Value::Bool(_) => ColType::Bool, + _ => ColType::Any, + }) + }; + let column = |col: u32, from: u32| -> ColType { + let mut seen: Option = None; + for row in from..=r.end.row { + match (seen, kind(CellRef::new(row, col))) { + (_, None) => {} + (None, k) => seen = k, + (Some(a), Some(b)) if a != b => return ColType::Any, + _ => {} + } + } + seen.unwrap_or(ColType::Any) + }; + let first_text = (r.start.col..=r.end.col).all(|c| kind(CellRef::new(r.start.row, c)) == Some(ColType::Text)); + let below = (r.start.col..=r.end.col).map(|c| column(c, r.start.row + 1)).collect::>(); + input.header = r.height() > 1 && first_text && below.iter().any(|k| !matches!(k, ColType::Text | ColType::Any)); + input.col_types = if input.header { below } else { (r.start.col..=r.end.col).map(|c| column(c, r.start.row)).collect() }; + if input.col_types.iter().all(|k| *k == ColType::Any) { + input.col_types.clear(); + } + input.rows = [1, 0]; + input +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(a1: &str) -> RangeRef { + RangeRef::parse(a1).unwrap() + } + + #[test] + fn references_near_the_example_become_tokens() { + let ex = r("B2:D11"); + for (t, want) in [ + ("B2:D11", Some("Input")), + ("C2:C11", Some("Input.col(2)")), + ("C2:D11", Some("Input.cols(2..3)")), + ("B2:D2", Some("Input.row(1)")), + ("B3:D11", Some("Input.rows(2..)")), + ("B3:D5", Some("Input.rows(2..4)")), + ("C4", Some("Input.cell(3,2)")), + ("C4:D5", Some("Input.cell(3,2).resize(2,2)")), + ("B12:D12", Some("Input.below")), + ("C12", Some("Input.col(2).below")), + ("C12:C13", Some("Input.col(2).below(2)")), + ("E2:E11", Some("Input.right")), + ("E5", Some("Input.row(4).right")), + ("E3:F4", Some("Input.rows(2..3).right(2)")), + ("G2", Some("Input.cell(1,6)")), + ("A1", Some("Input.cell(0,0)")), + ("Z500", None), + ("A:A", None), + ] { + assert_eq!(ref_token(t, ex, "Sheet1", true).as_deref(), want, "{t}"); + } + assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", true).as_deref(), Some("Input.cell(3,2)")); + assert_eq!(ref_token("Other!C4", ex, "Sheet1", true), None); + // On another sheet only a reference qualified with the input's sheet is on it. + assert_eq!(ref_token("C4", ex, "Sheet1", false), None); + assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", false).as_deref(), Some("Input.cell(3,2)")); + } + + #[test] + fn formulas_get_tokens_for_exact_references() { + let ex = r("B2:C5"); + for (f, want) in [ + ("=SUM(C3:C5)", "=SUM({{Input.rows(2..).col(2)}})"), + ("=SUM(C2:C5)+COUNT(B2:C5)", "=SUM({{Input.col(2)}})+COUNT({{Input}})"), + ("=SUM($C$3:$C$5)", "=SUM($C$3:$C$5)"), + ("=SUM(Other!C3:C5)", "=SUM(Other!C3:C5)"), + ("=\"C3:C5\"&C3:C5", "=\"C3:C5\"&{{Input.rows(2..).col(2)}}"), + ("=SUM(AC3:C5)", "=SUM(AC3:C5)"), + ("=B2", "=B2"), + ] { + assert_eq!(formula_tokens(f, ex).0, want, "{f}"); + } + } +} diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs new file mode 100644 index 000000000..c5c2da8d7 --- /dev/null +++ b/crates/engine/src/macros/resolve.rs @@ -0,0 +1,970 @@ +//! Macro tokens: references in step parameters that are worked out when the step runs, so a +//! macro made on one block of data works on another. +//! +//! A token is a base followed by methods: +//! +//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or +//! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or +//! a table's data; a name can hold periods, and the longest one that exists is used, so +//! `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`); +//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row, +//! also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the +//! top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`, +//! `.resize(h,w)`, `.data` (without the header row), `.below(n)` and `.right(n)` (the n rows +//! below or columns right of the range). +//! +//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole +//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending +//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`). +//! Each becomes A1 text before the command runs, so commands never see a token. Text that would +//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or +//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1 +//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor +//! does. + +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use gridcraft_model::{RepeatOf, Workbook}; +use serde_json::{Map, Value as Json}; + +/// A resolved reference: a block of cells on a sheet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Place { + pub sheet: usize, + pub range: RangeRef, +} + +/// Why a reference couldn't be worked out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolveError { + /// It runs past the edge of the sheet. + Edge(String), + /// Anything else (an unknown input, a malformed token). + Bad(String), +} + +impl ResolveError { + pub fn message(&self) -> &str { + match self { + ResolveError::Edge(m) | ResolveError::Bad(m) => m, + } + } +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +type R = Result; + +fn bad(msg: impl Into) -> R { + Err(ResolveError::Bad(msg.into())) +} + +/// What tokens refer to while a step runs. +#[derive(Clone, Copy, Debug)] +pub struct Env<'a> { + pub wb: &'a Workbook, + /// The sheet a reference without a sheet name is on. + pub active_sheet: usize, + /// Declared inputs, with whether each has a header row. + pub inputs: &'a [(String, Place, bool)], + pub output: Option, + /// The innermost Repeat step's current row or column. + pub item: Option<(RepeatOf, Place)>, +} + +/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a +/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs +/// `{"$ref": …}` or `{{…}}`. +const REF_KEYS: &[&str] = &[ + "active", + "anchor", + "area", + "areas", + "at", + "cell", + "cells", + "changing", + "copyTo", + "destination", + "from", + "fromCell", + "location", + "objective", + "over", + "range", + "ranges", + "reference", + "resultCells", + "source", + "sources", + "target", + "to", + "variables", +]; + +/// Whether a parameter called `key` holds a reference. +pub fn is_ref_key(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end)) +} + +/// Reference keys that other commands use for plain words: `to` is a replacement word in +/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a +/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token. +fn is_general_key(key: &str) -> bool { + ["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// `Row` or `Column` on its own, with no method or sheet after it. +fn is_bare_item_word(text: &str) -> bool { + let t = text.trim(); + t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column") +} + +/// The most nesting [`resolve_params`] walks into. +const MAX_JSON_DEPTH: usize = 64; + +/// Whether `text` starts like a token (its base is one this environment knows). A reference on a +/// sheet with such a name (`Output!A1`) is not a token. +pub fn is_token(text: &str, env: &Env) -> bool { + starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base))) +} + +/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]). +pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool { + starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base))) +} + +fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool { + let t = text.trim(); + if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + return true; + } + let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let base = t.get(..n).unwrap_or(""); + if t.get(n..).unwrap_or("").trim_start().starts_with('!') { + return false; + } + is_builtin(base) || is_input(base) +} + +fn is_builtin(base: &str) -> bool { + ["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base)) +} + +/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name). +pub fn resolve_ref(text: &str, env: &Env) -> R { + let t = text.trim(); + if is_token(t, env) { + return resolve_token(t, env); + } + plain_ref(t, env) +} + +/// `A1`, `'My Sheet'!A1:B2` or a defined name or table. +fn plain_ref(t: &str, env: &Env) -> R { + let (sheet, body) = crate::cmd::split_sheet(t); + let si = match &sheet { + Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?, + None => env.active_sheet, + }; + if let Some(range) = RangeRef::parse(body) { + return Ok(Place { sheet: si, range }); + } + if sheet.is_none() { + return named(t, env); + } + bad(format!("'{t}' isn't a reference.")) +} + +/// A defined name (by its formula) or a table (its data rows). +fn named(name: &str, env: &Env) -> R { + if let Some(n) = env.wb.name(name, env.active_sheet) { + // The sheet part keeps its characters (a sheet may be called `US$`); only the reference + // loses its `$` anchors. + let f = n.formula.trim().trim_start_matches('='); + let (sheet, body) = crate::cmd::split_sheet(f); + let si = match sheet { + Some(s) => env.wb.sheet_index(&s), + None => n.scope.or(Some(env.active_sheet)), + }; + if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) { + return Ok(Place { sheet, range }); + } + return bad(format!("The name '{name}' doesn't refer to a range.")); + } + if let Some((si, ti)) = env.wb.table(name) + && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti)) + { + // A table stands for its data rows; one with none (a header, perhaps a totals row) has + // nothing to give. + let Some(range) = t.data_range() else { + return bad(format!("The table '{name}' has no data rows.")); + }; + return Ok(Place { sheet: si, range }); + } + bad(format!("There is no name or table called '{name}'.")) +} + +/// Token method names. +const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"]; + +/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't +/// hold: at the end or at a period, the longest choice that is a defined name or table. When +/// none is, the name ends before the first method (`Nope.cell`), so the error names it. +fn name_end(run: &str, env: &Env) -> usize { + // One pass over the names and tables, as a single lookup makes. + let fits = |name: &str| { + !name.is_empty() + && run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name)) + && run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.')) + }; + let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str()); + let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str())); + if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() { + return end; + } + run.match_indices('.') + .map(|(i, _)| i) + .find(|&i| { + let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or(""); + METHODS.iter().any(|m| m.eq_ignore_ascii_case(next)) + }) + .unwrap_or(run.len()) +} + +/// Resolves a token (see the module docs). +pub fn resolve_token(text: &str, env: &Env) -> R { + let t = text.trim(); + let mut p = Parser { s: t, at: 0 }; + let (mut place, mut header) = p.base(env)?; + // The row of column names `col("…")` reads: the input's header row, kept through `data`. + let mut names_row = header.then_some(place.range.start.row); + while p.eat('.') { + let name = p.ident(); + if name.is_empty() { + return bad(format!("'{t}': expected a method name after '.'.")); + } + let args = if p.eat('(') { p.args(t)? } else { vec![] }; + let r = place.range; + let method = name.to_ascii_lowercase(); + let range = match (method.as_str(), args.as_slice()) { + ("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?, + ("col", [Arg::Text(h)]) => { + let Some(row) = names_row else { + return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n).")); + }; + let c = header_col(env, place, row, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?; + sub(r, None, Some((c, c)), t)? + } + ("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?, + ("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?, + ("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?, + ("cell", [Arg::Int(row), Arg::Int(col)]) => { + let c = shift(r.start, row - 1, col - 1, t)?; + RangeRef::cell(c) + } + ("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?), + ("resize", [Arg::Int(h), Arg::Int(w)]) => { + if *h < 1 || *w < 1 { + return bad(format!("'{t}': resize needs at least 1 row and 1 column.")); + } + RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?) + } + ("data", []) => { + if header { + // A header row with nothing under it has no data rows to give. + if r.height() < 2 { + return bad(format!("'{t}': the range is only a header row, with no data under it.")); + } + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + } + } + ("below", []) | ("below", [Arg::Int(_)]) => { + let n = count_arg(&args, t)?; + let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?; + RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?) + } + ("right", []) | ("right", [Arg::Int(_)]) => { + let n = count_arg(&args, t)?; + let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?; + RangeRef::new(left, shift(r.end, 0, n, t)?) + } + _ => { + return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", "))); + } + }; + // After a method the result is plain cells, except that `col` and `cols` keep the input's top + // row, so a later `data` still takes the header off; only `data` keeps the header row for + // `col("…")`. + if !matches!(method.as_str(), "col" | "cols") { + header = false; + } + if method != "data" { + names_row = None; + } + place = Place { sheet: place.sheet, range }; + } + p.skip_ws(); + if p.at < p.s.len() { + return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or(""))); + } + Ok(place) +} + +/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1. +fn count_arg(args: &[Arg], t: &str) -> R { + match args.first() { + Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")), + Some(Arg::Int(n)) => Ok(*n), + _ => Ok(1), + } +} + +/// `a..b` with open ends filled from 1 and `len`. +fn span(a: Option, b: Option, len: i64) -> (i64, i64) { + (a.unwrap_or(1), b.unwrap_or(len)) +} + +/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all. +fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R { + let (h, w) = (i64::from(r.height()), i64::from(r.width())); + let (r0, r1) = rows.unwrap_or((1, h)); + let (c0, c1) = cols.unwrap_or((1, w)); + if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w { + return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w)); + } + Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?)) +} + +fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R { + let row = i64::from(c.row).checked_add(dr); + let col = i64::from(c.col).checked_add(dc); + match (row, col) { + (Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => { + Ok(CellRef::new(row as u32, col as u32)) + } + (Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))), + (Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))), + _ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))), + } +} + +/// The 1-based column of `place` whose header (in sheet row `row`) reads `h`. +fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option { + let sh = env.wb.sheet(place.sheet)?; + let r = place.range; + (r.start.col..=r.end.col) + // The header's value, however the sheet shows it: with Show Formulas on, the cell's + // display text is its formula. + .position(|c| crate::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim())) + .map(|i| i as i64 + 1) +} + +#[derive(Debug)] +enum Arg { + Int(i64), + Span(Option, Option), + Text(String), +} + +struct Parser<'a> { + s: &'a str, + at: usize, +} + +impl Parser<'_> { + fn rest(&self) -> &str { + self.s.get(self.at..).unwrap_or("") + } + fn skip_ws(&mut self) { + let n = self.rest().len() - self.rest().trim_start().len(); + self.at += n; + } + fn eat(&mut self, c: char) -> bool { + self.skip_ws(); + if self.rest().starts_with(c) { + self.at += c.len_utf8(); + true + } else { + false + } + } + fn ident(&mut self) -> String { + self.skip_ws(); + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let out = self.rest().get(..n).unwrap_or("").to_string(); + self.at += n; + out + } + fn int(&mut self) -> Option { + self.skip_ws(); + let neg = self.rest().starts_with('-'); + let start = self.at + usize::from(neg); + let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 9 { + return None; + } + let n: i64 = self.s.get(start..start + digits)?.parse().ok()?; + self.at = start + digits; + Some(if neg { -n } else { n }) + } + /// The base of a token and whether it has a header row. + fn base(&mut self, env: &Env) -> R<(Place, bool)> { + self.skip_ws(); + if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + self.at += 5; + // A name can hold periods (`Sales.Q1`), so it is the longest part of the run, up to + // a period or the end, that names something; methods follow it. + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum(); + let run = self.rest().get(..n).unwrap_or("").to_string(); + let end = name_end(&run, env); + let name = run.get(..end).unwrap_or(""); + if name.is_empty() { + return bad("'Name:' needs a defined name after it."); + } + self.at += end; + return Ok((named(name, env)?, false)); + } + let base = self.ident(); + if base.eq_ignore_ascii_case("Output") { + return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into())); + } + if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") { + return match env.item { + Some((_, p)) => Ok((p, false)), + None => bad(format!("'{base}' only means something inside a Repeat step.")), + }; + } + match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) { + Some((_, p, header)) => Ok((*p, *header)), + None => bad(format!("'{base}' isn't an input of this macro.")), + } + } + fn args(&mut self, t: &str) -> R> { + let mut out = Vec::new(); + if self.eat(')') { + return Ok(out); + } + loop { + self.skip_ws(); + if self.rest().starts_with('"') { + self.at += 1; + let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?; + out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string())); + self.at += n + 1; + } else { + let a = self.int(); + self.skip_ws(); + if self.rest().starts_with("..") { + self.at += 2; + let b = self.int(); + out.push(Arg::Span(a, b)); + } else { + out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?)); + } + } + if out.len() > 4 { + return bad(format!("'{t}': too many arguments.")); + } + if self.eat(')') { + return Ok(out); + } + if !self.eat(',') { + return bad(format!("'{t}': expected ',' or ')'.")); + } + } + } +} + +/// `place` as A1 text, with the sheet name when it isn't the active sheet. +pub fn a1(place: Place, env: &Env) -> String { + if place.sheet == env.active_sheet { + return place.range.a1(); + } + let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or(""); + format!("{}!{}", quote_sheet(name), place.range.a1()) +} + +/// `Sheet1` or `'My Sheet'` (quotes doubled). +pub fn quote_sheet(name: &str) -> String { + // The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, or `Q1!A1` would be read as a reference to cell Q1. + gridcraft_core::quote_sheet(name) +} + +/// Step parameters with every token replaced by A1 text. +pub fn resolve_params(params: &Json, env: &Env) -> R { + walk(params, None, env, 0) +} + +fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { + if depth > MAX_JSON_DEPTH { + return bad("The step's parameters nest too deeply."); + } + Ok(match v { + Json::Object(o) => { + if o.len() == 1 + && let Some(Json::String(t)) = o.get("$ref") + { + return Ok(Json::String(a1(resolve_ref(t, env)?, env))); + } + // Text passed as it is: never a token, and no `{{…}}` in it is read. + if o.len() == 1 + && let Some(Json::String(t)) = o.get("$text") + { + return Ok(Json::String(t.clone())); + } + let mut out = Map::new(); + for (k, x) in o { + out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?); + } + Json::Object(out) + } + // Items of a list keep their key's kind (`ranges: ["Input", …]`). + Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::>>()?), + Json::String(s) => { + if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) { + Json::String(a1(resolve_token(s, env)?, env)) + } else { + Json::String(interpolate(s, env)?) + } + } + other => other.clone(), + }) +} + +/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces +/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`). +pub fn interpolate(s: &str, env: &Env) -> R { + if !s.contains("{{") { + return Ok(s.to_string()); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("{{") { + out.push_str(rest.get(..i).unwrap_or("")); + if rest.get(i..i + 4) == Some("{{{{") { + out.push_str("{{"); + rest = rest.get(i + 4..).unwrap_or(""); + continue; + } + let after = rest.get(i + 2..).unwrap_or(""); + let Some(j) = after.find("}}") else { + out.push_str(rest.get(i..).unwrap_or("")); + return Ok(out); + }; + let token = after.get(..j).unwrap_or(""); + out.push_str(&a1(resolve_ref(token, env)?, env)); + rest = after.get(j + 2..).unwrap_or(""); + } + out.push_str(rest); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn wb() -> Workbook { + let mut wb = Workbook::new(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("My Data"))); + let sh = wb.sheet_mut(0).unwrap(); + for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() { + sh.set_value(CellRef::new(1, 1 + i as u32), gridcraft_core::Value::text(*h)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Total".into(), + scope: None, + formula: "Sheet1!$F$20".into(), + comment: String::new(), + hidden: false, + }); + wb.names.push(gridcraft_model::DefinedName { + name: "Rates".into(), + scope: None, + formula: "'My Data'!$A$1:$A$5".into(), + comment: String::new(), + hidden: false, + }); + wb + } + + fn place(a1: &str) -> Place { + Place { sheet: 0, range: RangeRef::parse(a1).unwrap() } + } + + fn check(env: &Env, token: &str, want: &str) { + match resolve_ref(token, env) { + Ok(p) => assert_eq!(a1(p, env), want, "{token}"), + Err(e) => panic!("{token}: {e}"), + } + } + + #[test] + fn a_formula_header_matches_while_formulas_are_shown() { + // The header was read through the cell's display text, which is the formula while Show + // Formulas is on, so `col("Amount")` found no column headed by a formula. + let mut wb = wb(); + let sh = wb.sheet_mut(0).unwrap(); + let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\""))); + sh.set_cell( + CellRef::new(1, 2), + gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default(), py: None }, + ); + sh.show_formulas = true; + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + check(&env, "Input.col(\"Amount\")", "C2:C11"); + } + + #[test] + fn every_token_resolves() { + let wb = wb(); + // Input B2:D11 with a header row; Other on another sheet. + let inputs = vec![ + ("Input".to_string(), place("B2:D11"), true), + ("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false), + ]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) }; + for (token, want) in [ + ("Input", "B2:D11"), + ("input", "B2:D11"), + ("Input.col(2)", "C2:C11"), + ("Input.col(\"Amount\")", "C2:C11"), + ("Input.col(\"date\").data", "D3:D11"), + ("Input.cols(2..3).data", "C3:D11"), + ("Input.col(2).data.col(1)", "C3:C11"), + ("Input.data", "B3:D11"), + ("Input.data.col(2)", "C3:C11"), + ("Input.data.col(\"Amount\")", "C3:C11"), + ("Input.row(1)", "B2:D2"), + ("Input.rows(2..)", "B3:D11"), + ("Input.rows(2..4)", "B3:D5"), + ("Input.rows(..2)", "B2:D3"), + ("Input.cols(2..3)", "C2:D11"), + ("Input.cell(1,1)", "B2"), + ("Input.cell(3,2)", "C4"), + ("Input.cell(0,1)", "B1"), + ("Input.cell(12,3)", "D13"), + ("Input.offset(0,4)", "F2:H11"), + ("Input.offset(-1,-1)", "A1:C10"), + ("Input.cell(1,1).resize(2,3)", "B2:D3"), + ("Input.below()", "B12:D12"), + ("Input.below(2)", "B12:D13"), + ("Input.col(2).below", "C12"), + ("Input.right", "E2:E11"), + (" Input . col( 2 ) ", "C2:C11"), + ("Output", "G2"), + ("Output.offset(1,0)", "G3"), + ("Row", "B5:D5"), + ("Row.cell(1,2)", "C5"), + ("Column", "B5:D5"), + ("Name:Total", "F20"), + ("Name:Rates", "'My Data'!A1:A5"), + ("Name:Rates.cell(2,1)", "'My Data'!A2"), + ("Other", "'My Data'!C3:C4"), + ("B7", "B7"), + ("'My Data'!A1:B2", "'My Data'!A1:B2"), + ("Total", "F20"), + ] { + check(&env, token, want); + } + } + + #[test] + fn bad_tokens_say_why() { + let wb = wb(); + // The same cells, read with and without a header row. + let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (token, needle, edge) in [ + ("Input.col(4)", "outside the range", false), + ("Input.col(0)", "outside the range", false), + ("Input.rows(5..2)", "outside the range", false), + ("Headed.col(\"Nope\")", "no column is headed", false), + ("Input.col(\"Name\")", "needs an input with a header row", false), + ("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false), + ("Name:Rates.col(\"Name\")", "needs an input with a header row", false), + ("Input.colour(1)", "isn't a token method", false), + ("Input.col(1", "expected", false), + ("Input.col(x)", "whole number", false), + ("Input.col(99999999999)", "whole number", false), + ("Input.", "method name", false), + ("Input junk", "unexpected", false), + ("Input.resize(0,1)", "at least 1 row", false), + ("Output", "no output anchor", false), + ("Row", "inside a Repeat", false), + ("Name:", "needs a defined name", false), + ("Name:Nothing", "no name or table", false), + ("Nowhere!A1", "no sheet named", false), + ("Input.offset(-2,0)", "past the top", true), + ("Input.offset(0,-2)", "edge of the sheet", true), + ("Input.offset(1048570,0)", "past the last row", true), + ("Input.cell(1,16384)", "edge of the sheet", true), + ] { + let e = resolve_ref(token, &env).unwrap_err(); + assert!(e.message().contains(needle), "{token}: {e}"); + assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}"); + } + } + + #[test] + fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None }; + let p = json!({ + "cell": "Output", + "range": "Input.col(2)", + "ranges": ["Input.row(1)", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}", + "nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true}, + "text": {"$ref": "Output"}, + "type": "column", + "sheet": "Input", + "rowInputCell": "Input.cell(1,1)", + "unclosed": "a {{b" + }); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!( + r, + json!({ + "cell": "F2", + "range": "C2:C11", + "ranges": ["B2:D2", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM(C3:C11)/F20", + "nested": {"target": "B2", "n": 3, "flag": true}, + "text": "F2", + "type": "column", + "sheet": "Input", + "rowInputCell": "B2", + "unclosed": "a {{b" + }) + ); + // Deeply nested parameters are refused, not followed. + let mut deep = json!(1); + for _ in 0..100 { + deep = json!([deep]); + } + assert!(resolve_params(&deep, &env).is_err()); + assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err()); + } + + #[test] + fn references_on_sheets_named_like_tokens_stay_references() { + // Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the + // sheet Output, in a reference key, in `$ref` and in `{{…}}`. + let mut wb = Workbook::new(); + for name in ["Output", "Row", "Column", "Input", "Rates"] { + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name))); + } + let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)]; + for output in [None, Some(place("H2"))] { + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) }; + for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] { + for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] { + let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() }; + assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}"); + let a = a1(want, &env); + let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")}); + let r = resolve_params(&p, &env).unwrap(); + // A plain reference in a reference key is passed on as written. + assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}"); + } + } + assert!(!is_token("Output !A1", &env)); + // The bare names are still tokens. + check(&env, "Input.col(2)", "C2:C11"); + check(&env, "Rates", "F2:F5"); + check(&env, "Row", "B5:D5"); + } + } + + #[test] + fn defined_names_can_hold_periods() { + // `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like + // a method. The longest name that exists is used, and methods follow it. + let mut wb = wb(); + for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] { + wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: formula.into(), + comment: String::new(), + hidden: false, + }); + } + let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None }; + for (token, want) in [ + ("Name:Sales.Q1", "B2:C5"), + ("Name:sales.q1", "B2:C5"), + ("Name:Sales.Q1.cell(1,1)", "B2"), + ("Name:Sales.Q1.col(2).row(4)", "C5"), + ("Name:Sales", "A1"), + ("Name:Sales.offset(1,0)", "A2"), + ("Name:Data.row", "'My Data'!E1:E3"), + ("Name:Data.row.row(2)", "'My Data'!E2"), + ("Name:Data.row.cell(1,1)", "'My Data'!E1"), + ("Sales.Q1", "B2:C5"), + ] { + check(&env, token, want); + } + // In a reference parameter, `$ref` and `{{…}}`, with and without a method. + let p = json!({ + "range": "Name:Sales.Q1", + "cell": "Name:Sales.Q1.cell(1,1)", + "a": {"$ref": "Name:Sales.Q1"}, + "b": {"$ref": "Name:Sales.Q1.cell(2,2)"}, + "formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}" + }); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"})); + // Unknown names and bad methods still fail, naming what is wrong. + for (token, needle) in [ + ("Name:Nope.Q1", "no name or table called 'Nope.Q1'"), + ("Name:Nope.cell(1,1)", "no name or table called 'Nope'"), + ("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"), + ("Name:Sales.Q1.cell(1", "expected"), + ("Name:Sales.Q1.", "method name"), + ("Name:.cell(1,1)", "needs a defined name"), + ] { + let e = resolve_ref(token, &env).unwrap_err(); + assert!(e.message().contains(needle), "{token}: {e}"); + let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err(); + assert!(e.message().contains(needle), "{token} in braces: {e}"); + } + } + + #[test] + fn sheet_names_are_quoted_when_needed() { + // The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, and a period on its own is not a reason to quote. + for (name, want) in [ + ("Sheet1", "Sheet1"), + ("My Data", "'My Data'"), + ("O'Hara", "'O''Hara'"), + ("2024", "'2024'"), + ("", "''"), + ("Q1", "'Q1'"), + ("R1C1", "'R1C1'"), + ("TRUE", "'TRUE'"), + ("Sales.Q1", "Sales.Q1"), + ] { + assert_eq!(quote_sheet(name), want, "{name}"); + } + } + + #[test] + fn a_bare_row_or_column_under_a_general_key_is_text() { + // `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a + // pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not; + // the token forms still resolve there, and the bare word resolves under `range`. + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] { + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item }; + let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}"); + } + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) }; + let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"})); + } + + #[test] + fn a_header_row_alone_and_a_table_without_data_rows_are_refused() { + let mut wb = wb(); + let sh = wb.sheet_mut(0).unwrap(); + sh.tables.push(gridcraft_model::Table { + id: 1, + name: "Empty".into(), + range: RangeRef::parse("H1:J1").unwrap(), + header_row: true, + totals_row: false, + columns: Vec::new(), + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: Vec::new(), + table_type: None, + connection_id: None, + }); + let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row")); + assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row")); + check(&env, "Plain.data", "B2:D2"); + check(&env, "Input.col(2)", "C2"); + assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows")); + } + + #[test] + fn doubled_braces_are_literal_braces() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},"); + assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}"); + assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error"); + } + + #[test] + fn text_marked_as_text_is_never_a_token() { + // A defined name called Output or Input in a reference parameter, a Name: prefix and + // braces: `$text` passes each as it is, in any key; unmarked, each reads as a token. + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] { + let p = json!({"range": {"$text": text}, "input": {"$text": text}}); + assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}"); + assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}"); + assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}"); + } + assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"])); + } + + #[test] + fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() { + let mut wb = wb(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$"))); + wb.names.push(gridcraft_model::DefinedName { + name: "Dollars".into(), + scope: None, + formula: "'US$'!$F$20".into(), + comment: String::new(), + hidden: false, + }); + let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None }; + check(&env, "Name:Dollars", "'US$'!F20"); + } + + #[test] + fn below_and_right_refuse_a_count_under_one() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + check(&env, "Input.below", "B12:D12"); + check(&env, "Input.below(2)", "B12:D13"); + check(&env, "Input.right(1)", "E2:E11"); + for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] { + assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}"); + } + } +} diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs new file mode 100644 index 000000000..fcb6768f3 --- /dev/null +++ b/crates/engine/src/macros/run.rs @@ -0,0 +1,1653 @@ +//! Running a macro. +//! +//! 1. **Gate**: every step's command goes through [`macro_policy`]. Denied commands stop the macro +//! before anything runs; commands that touch files must be listed in the macro's permissions; +//! a macro from a file someone else made, or one that touches files, runs only once the person +//! approves it (once per macro content, for the session). +//! 2. **Inputs**: each declared input is bound to a range (given, its default, or the selection) +//! and checked against its shape and column types, with messages that name the cells. +//! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run +//! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it +//! would write outside its inputs that already hold data are conflicts, handled by the output +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). The +//! footprint can miss a write (a dry run stops early at a file step or Solver, and RAND or NOW +//! can send the run another way than the dry run), so the run also checks each step as it goes. +//! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and +//! names the step. +//! +//! The steps run on a small machine with an explicit frame stack, so the debugger can stop +//! between any two steps and pick up again on a later frame. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_formula::adjust::Edit; +use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; +use serde::Serialize; +use serde_json::{Value as Json, json}; + +use super::policy::{Policy, macro_policy, previewable, writes_file}; +use super::resolve::{self, Env, Place, ResolveError}; +use crate::{EngineError, Result, Selection, Session, Trust, UiRequest}; + +/// The most steps one run executes (counting each pass through a Repeat). +pub const MAX_EXECUTED_STEPS: u64 = 100_000; +/// How long a run may take, in milliseconds. The web build has no clock; there only the step +/// limit applies. +pub const DEFAULT_TIME_LIMIT_MS: f64 = 60_000.0; +/// The most cells an input may have for its content to be checked. +pub const MAX_INPUT_CELLS: u64 = 1_000_000; +/// The most bytes of parameters one run passes to its commands, all steps together: on the web +/// build, where there is no clock, this and the step limit bound the work a run does. +pub const MAX_PARAM_BYTES: u64 = 256 * 1024 * 1024; +/// The most ranges a footprint lists per sheet (the counts stay exact). +const MAX_LISTED_RANGES: usize = 500; + +/// An input bound to a range: name, where, whether its first row is a header. +pub type Bound = (String, Place, bool); + +/// Where and why a step failed. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StepError { + /// The step's path: `3`, `2.1` inside a Repeat, `4.then.1` inside an If. + pub step: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub message: String, + /// The step ran past the edge of the sheet. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub edge: bool, + /// The step would write over data and the conflict rule is ask: overwriting answers it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub confirm: bool, + /// Files the run wrote before the step failed ("step 1 (file.exportCsv)"): rolling back the + /// workbook leaves them written. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub wrote_files: Vec, +} + +impl StepError { + fn new(step: &str, command: Option<&str>, message: impl Into) -> StepError { + StepError { + step: step.to_string(), + command: command.map(str::to_string), + message: message.into(), + edge: false, + confirm: false, + wrote_files: Vec::new(), + } + } + + fn resolve(step: &str, command: Option<&str>, e: ResolveError) -> StepError { + let edge = matches!(e, ResolveError::Edge(_)); + StepError { edge, ..StepError::new(step, command, e.message()) } + } + + /// `Step 3 (cell.set): …` + pub fn describe(&self) -> String { + match &self.command { + Some(c) => format!("Step {} ({c}): {}", self.step, self.message), + None => format!("Step {}: {}", self.step, self.message), + } + } + + pub fn into_engine(self, name: &str) -> EngineError { + let text = format!("Macro '{name}' stopped. {} {}", self.describe(), rolled_back(&self.wrote_files)); + if self.confirm { EngineError::Confirm(text) } else { EngineError::Other(text) } + } +} + +/// What a run that rolled back leaves: "Nothing was changed.", or the files it wrote before. +pub fn rolled_back(wrote_files: &[String]) -> String { + match wrote_files { + [] => "Nothing was changed.".into(), + [one] => format!("The workbook was put back; the file {one} wrote stays."), + many => format!("The workbook was put back; the files {} wrote stay.", join_and(many)), + } +} + +/// The size of `v` as JSON text, near enough to bound how much a run passes to its commands. +fn json_bytes(v: &Json) -> u64 { + match v { + Json::String(t) => t.len() as u64 + 2, + Json::Array(a) => a.iter().map(json_bytes).fold(2, u64::saturating_add), + Json::Object(o) => o.iter().map(|(k, v)| (k.len() as u64 + 3).saturating_add(json_bytes(v))).fold(2, u64::saturating_add), + _ => 8, + } +} + +// ---------------------------------------------------------------- gate + +/// Checks every enabled step against the policy: denied commands and undeclared file access are +/// errors; otherwise returns what the person must approve (empty when nothing). +pub fn gate(m: &Macro, trust: Trust) -> Result> { + let mut asks = BTreeSet::new(); + let mut err = None; + walk_enabled(&m.steps, "", &mut |path, id, params| { + if err.is_some() { + return; + } + match macro_policy(id, params, trust) { + Policy::Allow => {} + Policy::Ask(why) => { + asks.insert(why); + } + Policy::NeedsPermission(what) => { + if m.permissions.iter().any(|p| p == id) { + asks.insert(format!("runs {id}, which {what}")); + } else { + err = Some(format!("Step {path} ({id}) {what}. Add {id} to the macro's permissions to allow it.")); + } + } + Policy::Deny(why) => err = Some(format!("Step {path} ({id}) can't run in a macro: {why}.")), + } + }); + match err { + Some(e) => Err(EngineError::Other(format!("Macro '{}' can't run. {e}", m.name))), + None => Ok(asks.into_iter().collect()), + } +} + +/// Calls `f(path, id, params)` for each enabled command step (skipping disabled steps' bodies). +fn walk_enabled(steps: &[Step], prefix: &str, f: &mut impl FnMut(&str, &str, &Json)) { + for (i, st) in steps.iter().enumerate() { + if !st.enabled { + continue; + } + let path = child_path(prefix, i); + match &st.kind { + StepKind::Command { id, params } => f(&path, id, params), + StepKind::Repeat { body, .. } => walk_enabled(body, &path, f), + StepKind::If { then, otherwise, .. } => { + walk_enabled(then, &format!("{path}.then"), f); + walk_enabled(otherwise, &format!("{path}.else"), f); + } + StepKind::Stop { .. } => {} + } + } +} + +fn child_path(prefix: &str, i: usize) -> String { + if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) } +} + +/// Approves the macro for this session when `approve` is set, or asks (an +/// [`EngineError::Approve`]) when it needs approval it doesn't have. +pub fn approve(s: &mut Session, m: &Macro, doc: u64, asks: &[String], approve: bool) -> Result<()> { + if asks.is_empty() { + return Ok(()); + } + let key = (doc, super::fingerprint(m)); + if s.macro_approvals.contains(&key) { + return Ok(()); + } + if approve { + s.macro_approvals.insert(key); + return Ok(()); + } + Err(EngineError::Approve(format!("Run macro '{}'? It {}.", m.name, join_and(asks)))) +} + +fn join_and(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + } +} + +// ---------------------------------------------------------------- inputs + +/// A macro ready to run: its inputs bound and checked, its output anchor resolved. +#[derive(Clone, Debug)] +pub struct Prepared { + pub mac: Macro, + pub doc: u64, + pub trust: Trust, + pub inputs: Vec, + pub output: Option, +} + +impl Prepared { + pub fn inputs_json(&self, wb: &Workbook) -> Json { + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &[], output: None, item: None }; + Json::Object(self.inputs.iter().map(|(n, p, _)| (n.clone(), json!(resolve::a1(*p, &env)))).collect()) + } +} + +/// Binds the macro's inputs on the active document (`given`: `{"Input": "B2:D9"}`, or a reference +/// for the first input) and checks them. Errors list every problem found. +pub fn prepare(s: &Session, m: &Macro, given: Option<&Json>) -> Result { + let d = s.doc()?; + let wb = &d.wb; + let mut inputs: Vec = Vec::new(); + let mut problems = Vec::new(); + for (i, input) in m.inputs.iter().enumerate() { + let named = given.and_then(Json::as_object).and_then(|o| o.iter().find(|(k, _)| k.eq_ignore_ascii_case(&input.name)).map(|(_, v)| v)); + let text = named.and_then(Json::as_str).or_else(|| if i == 0 { given.and_then(Json::as_str) } else { None }).or(input.default_ref.as_deref()); + let place = match text { + Some(t) => { + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + match resolve::resolve_ref(t, &env) { + Ok(p) => p, + Err(e) => { + problems.push(format!("{}: {}", input.name, e.message())); + continue; + } + } + } + None if i == 0 => Place { sheet: wb.active_sheet(), range: d.selection.current() }, + None => { + problems.push(format!("{0} needs a range: pass inputs: {{\"{0}\": \"A1:B5\"}}.", input.name)); + continue; + } + }; + let place = trim(wb, place); + problems.extend(check_input(wb, input, place)); + inputs.push((input.name.clone(), place, input.header)); + } + if !problems.is_empty() { + return Err(EngineError::Other(format!("Macro '{}' can't run on these inputs. {}", m.name, problems.join(" ")))); + } + let output = match &m.output.anchor { + Some(a) if !a.trim().is_empty() => { + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + Some( + resolve::resolve_ref(a, &env) + .map_err(|e| EngineError::Other(format!("Macro '{}': its output anchor {}", m.name, lower_first(e.message()))))?, + ) + } + _ => None, + }; + Ok(Prepared { mac: m.clone(), doc: d.uid, trust: d.trust, inputs, output }) +} + +fn lower_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_lowercase().chain(c).collect(), + None => String::new(), + } +} + +/// Whole rows or columns shrink to the part of the sheet in use. +fn trim(wb: &Workbook, p: Place) -> Place { + if !(p.range.is_full_cols() || p.range.is_full_rows()) { + return p; + } + let used = wb.sheet(p.sheet).and_then(Sheet::used_range); + let range = used.and_then(|u| p.range.intersection(&u)).unwrap_or(RangeRef::cell(p.range.start)); + Place { sheet: p.sheet, range } +} + +/// What is wrong with `place` as `input`, one sentence each. +fn check_input(wb: &Workbook, input: &gridcraft_model::NamedInput, place: Place) -> Vec { + let mut out = Vec::new(); + let r = place.range; + let name = &input.name; + let at = r.a1(); + if input.kind == InputKind::Cell && !r.is_single() { + out.push(format!("{name} must be a single cell; {at} is {} cells.", r.count())); + return out; + } + let header = u32::from(input.header && r.height() > 0); + let rows = r.height().saturating_sub(header); + let between = |what: &str, n: u32, [min, max]: [u32; 2]| -> Option { + let plural = |k: u32| if k == 1 { what.to_string() } else { format!("{what}s") }; + if max > 0 && min == max && n != min { + Some(format!("exactly {min} {}", plural(min))) + } else if n < min { + Some(format!("at least {min} {}", plural(min))) + } else if max > 0 && n > max { + Some(format!("at most {max} {}", plural(max))) + } else { + None + } + }; + if let Some(want) = between("data row", rows, input.rows) { + out.push(format!("{name} needs {want}; {at} has {rows}.")); + } + if let Some(want) = between("column", r.width(), input.cols) { + out.push(format!("{name} needs {want}; {at} has {}.", r.width())); + } + if r.count() > MAX_INPUT_CELLS { + out.push(format!("{name} is too large to check ({} cells; at most {}).", r.count(), MAX_INPUT_CELLS)); + return out; + } + let Some(sh) = wb.sheet(place.sheet) else { return out }; + let mut blanks = Vec::new(); + for (ci, col) in (r.start.col..=r.end.col).enumerate() { + let ty = input.col_types.get(ci).copied().unwrap_or_default(); + let mut wrong = Vec::new(); + for row in r.start.row + header..=r.end.row { + let c = CellRef::new(row, col); + let v = sh.value(c); + if v.is_empty() || v.as_text() == Some("") { + if input.non_empty { + blanks.push(c); + } + continue; + } + let date = || wb.styles.get(sh.style_id(c)).num_fmt.as_str().to_string(); + let ok = match ty { + gridcraft_model::ColType::Any => true, + gridcraft_model::ColType::Number => v.is_number(), + gridcraft_model::ColType::Text => v.is_text(), + gridcraft_model::ColType::Bool => matches!(v, Value::Bool(_)), + gridcraft_model::ColType::Date => v.is_number() && crate::display::number_format(&date()).is_date(), + }; + if !ok { + wrong.push((c, holds(&v, ty))); + } + } + if !wrong.is_empty() { + let head = if input.header { + Some(crate::display::cell_text(wb, sh, CellRef::new(r.start.row, col))).filter(|h| !h.trim().is_empty()) + } else { + None + }; + let column = match head { + Some(h) => format!("{name} column {} ({})", ci + 1, h.trim()), + None => format!("{name} column {}", ci + 1), + }; + let listed: Vec = wrong.iter().take(3).map(|(c, what)| format!("{} holds {what}", c.a1())).collect(); + let more = if wrong.len() > 3 { format!(" and {} more cells don't", wrong.len() - 3) } else { String::new() }; + out.push(format!("{column} must hold {}; {}{more}.", ty.noun(), listed.join(", "))); + } + } + if let Some(first) = blanks.first() { + let more = if blanks.len() > 1 { format!(" (and {} more)", blanks.len() - 1) } else { String::new() }; + out.push(format!("{name} must have no blank cells; {} is blank{more}.", first.a1())); + } + out +} + +/// What a cell holds, for "C7 holds text". +fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str { + match v { + Value::Number(_) if wanted == gridcraft_model::ColType::Date => "a number that isn't a date", + Value::Number(_) => "a number", + Value::Text(_) => "text", + Value::Bool(_) => "TRUE or FALSE", + Value::Error(_) => "an error", + Value::Array(_) => "an array", + Value::Empty => "nothing", + } +} + +// ---------------------------------------------------------------- the machine + +/// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing. +#[derive(Clone, Copy, Debug)] +pub struct Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant, +} + +impl Stopwatch { + pub fn start() -> Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::disallowed_methods)] // never on wasm (see above) + let w = Stopwatch { start: std::time::Instant::now() }; + #[cfg(target_arch = "wasm32")] + let w = Stopwatch {}; + w + } + + /// Milliseconds since the start, or `None` where there is no clock. + fn elapsed_ms(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let ms = Some(self.start.elapsed().as_secs_f64() * 1000.0); + #[cfg(target_arch = "wasm32")] + let ms = None; + ms + } +} + +#[derive(Clone, Debug)] +struct Frame { + steps: Arc>, + next: usize, + /// Path of the step that opened this frame (`""` at the top, `3` for a Repeat's body, + /// `4.then` for an If's branch). + prefix: String, + repeat: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Loop { + of: RepeatOf, + over: Place, + /// The current item (0-based) and how many there are. + at: u32, + count: u32, +} + +impl Loop { + fn item(&self) -> Place { + let r = self.over.range; + let range = match self.of { + RepeatOf::Rows => { + let row = r.start.row.saturating_add(self.at); + RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)) + } + RepeatOf::Columns => { + let col = r.start.col.saturating_add(self.at); + RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)) + } + }; + Place { sheet: self.over.sheet, range } + } +} + +/// A macro run in progress: which step is next, and what has run. +#[derive(Clone, Debug)] +pub struct Machine { + pub name: String, + pub doc: u64, + pub inputs: Vec, + pub output: Option, + permissions: Vec, + trust: Trust, + frames: Vec, + /// Steps executed so far (each pass through a Repeat counts). + pub executed: u64, + clock: Stopwatch, + pub time_limit_ms: f64, + /// Dry run (the footprint check): steps that would touch files or can't be previewed stop + /// the run early instead of running. + pub dry_run: bool, + /// A Stop step ended the run: its message, and whether it asked for a rollback. + pub stopped: Option<(String, bool)>, + /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. + pub partial: Option<(String, String)>, + /// The last step's resolved parameters (for the debugger). + pub last_params: Option, + /// Files the run wrote ("step 1 (file.exportCsv)"): rolling the run back doesn't take them back. + pub wrote_files: Vec, + /// Repeat and If bodies by the path of their frame: a body is copied the first time its step + /// runs, not on every pass. + bodies: HashMap>>, + /// Bytes of parameters passed to commands so far, against [`MAX_PARAM_BYTES`]. + param_bytes: u64, + /// Set unless the macro may overwrite: each step is checked for writes over data as it runs, + /// since the footprint can miss a write (it stopped early, or the run took another path). + guard: Option, +} + +/// Checks each step of a real run for writes over data the footprint didn't see. +#[derive(Clone, Debug)] +struct Guard { + /// The workbook when the run started, and for each sheet now, its index there (`None` for a + /// sheet the run added). + start: Arc, + origin: Vec>, + /// Where the footprint stopped, if it did: "step 2 (file.exportCsv writes a file)". + partial: Option, + /// The conflict rule is ask. + ask: bool, +} + +impl Guard { + fn new(start: Arc, partial: Option<&Json>, ask: bool) -> Guard { + let partial = partial.map(|p| { + let text = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + format!("step {} ({})", text("step"), text("reason")) + }); + Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask } + } + + /// What one step wrote over that held data when the run started: cells outside the inputs, + /// sheets and objects it deleted ("B1 on Sheet1; 1 object"), or `""`. + fn check(&mut self, prev: &Workbook, after: &Workbook, inputs: &[Bound]) -> String { + let (pairs, _) = pair_sheets(prev, after); + let mut origin = vec![None; after.sheets.len()]; + let mut parts = Vec::new(); + let mut objects = 0usize; + for (pi, ai) in pairs { + // A sheet the run added holds nothing of the person's. + let Some(si) = self.origin.get(pi).copied().flatten() else { continue }; + let Some(ss) = self.start.sheets.get(si) else { continue }; + let Some(ai) = ai else { + parts.push(format!("the sheet {}", ss.name)); + continue; + }; + if let Some(o) = origin.get_mut(ai) { + *o = Some(si); + } + let (Some(ps), Some(sa)) = (prev.sheets.get(pi), after.sheets.get(ai)) else { continue }; + if Arc::ptr_eq(ps, sa) { + continue; + } + // The inputs have followed their cells, so they are on the sheet's index now. + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == ai && p.range.contains(c)); + let mut hit: Vec = ps + .cells + .diff(&sa.cells) + .into_iter() + .filter(|&c| !in_input(c) && !recalculated_only(ps.cell(c), sa.cell(c))) + .filter(|&c| ss.cell(c).is_some_and(has_content) && content_differs(ss.cell(c), sa.cell(c))) + .collect(); + if !hit.is_empty() { + let mut listed = ranges(&mut hit); + if listed.len() > 3 { + listed.truncate(3); + listed.push("…".into()); + } + parts.push(format!("{} on {}", listed.join(", "), sa.name)); + } + objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id) + + deleted(&ss.images, &ps.images, &sa.images, |o| o.id) + + deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id); + } + if objects > 0 { + parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" })); + } + self.origin = origin; + parts.join("; ") + } +} + +/// Objects of `prev` that `after` no longer has and that were there at the start. +fn deleted(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> usize { + let kept: BTreeSet = after.iter().map(&id).collect(); + let had: BTreeSet = start.iter().map(&id).collect(); + prev.iter().map(&id).filter(|i| !kept.contains(i) && had.contains(i)).count() +} + +impl Machine { + pub fn new(p: &Prepared, dry_run: bool) -> Machine { + let mut m = Machine { + name: p.mac.name.clone(), + doc: p.doc, + inputs: p.inputs.clone(), + output: p.output, + permissions: p.mac.permissions.clone(), + trust: p.trust, + frames: vec![Frame { steps: Arc::new(p.mac.steps.clone()), next: 0, prefix: String::new(), repeat: None }], + executed: 0, + clock: Stopwatch::start(), + time_limit_ms: DEFAULT_TIME_LIMIT_MS, + dry_run, + stopped: None, + partial: None, + last_params: None, + wrote_files: Vec::new(), + bodies: HashMap::new(), + param_bytes: 0, + guard: None, + }; + m.settle(); + m + } + + /// Moves to the next enabled step: past disabled steps, finished frames and loop passes. + fn settle(&mut self) { + while let Some(f) = self.frames.last_mut() { + if let Some(st) = f.steps.get(f.next) { + if st.enabled { + return; + } + f.next += 1; + continue; + } + if let Some(l) = f.repeat.as_mut() + && l.at.saturating_add(1) < l.count + { + l.at += 1; + f.next = 0; + continue; + } + self.frames.pop(); + } + } + + pub fn is_done(&self) -> bool { + self.frames.is_empty() + } + + /// Starts the time limit again (the debugger gives each action its own). + pub fn restart_clock(&mut self) { + self.clock = Stopwatch::start(); + } + + /// The current step for the debugger: its path, kind, command, parameters as they would run + /// now, and the Repeat pass. + pub fn current_json(&self, wb: &Workbook) -> Json { + let Some((path, st)) = self.current() else { return Json::Null }; + let env = self.env(wb); + let mut out = json!({"step": path, "breakpoint": st.breakpoint}); + if !st.note.is_empty() { + out["note"] = json!(st.note); + } + match &st.kind { + StepKind::Command { id, params } => { + out["kind"] = json!("command"); + out["command"] = json!(id); + match resolve::resolve_params(params, &env) { + Ok(p) => out["params"] = p, + Err(e) => out["paramsError"] = json!(e.message()), + } + } + StepKind::Repeat { over, of, .. } => { + out["kind"] = json!("repeat"); + // Whole rows or columns shrink to the part in use, as when the Repeat runs. + out["over"] = + json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(trim(wb, p), &env)).unwrap_or_else(|e| e.message().to_string())); + out["of"] = json!(if *of == RepeatOf::Rows { "rows" } else { "columns" }); + } + StepKind::If { cell, .. } => { + out["kind"] = json!("if"); + out["cell"] = json!(resolve::resolve_ref(cell, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string())); + } + StepKind::Stop { message, .. } => { + out["kind"] = json!("stop"); + out["message"] = json!(message); + } + } + if let Some((at, count)) = self.iteration() { + out["iteration"] = json!({"at": at, "of": count}); + } + out + } + + /// The step that runs next, with its path. + pub fn current(&self) -> Option<(String, &Step)> { + let f = self.frames.last()?; + f.steps.get(f.next).map(|st| (child_path(&f.prefix, f.next), st)) + } + + /// The innermost Repeat's pass (1-based) and count. + pub fn iteration(&self) -> Option<(u32, u32)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.at + 1, l.count)) + } + + fn item(&self) -> Option<(RepeatOf, Place)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.of, l.item())) + } + + /// Runs every remaining step. + pub fn run_to_end(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + while !self.is_done() { + self.step(s)?; + } + Ok(()) + } + + /// Runs the current step and moves to the next one. + pub fn step(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + // The frame's steps are shared, so the step (and its bodies) isn't copied to run it. + let Some((path, steps, i)) = self.frames.last().map(|f| (child_path(&f.prefix, f.next), Arc::clone(&f.steps), f.next)) else { return Ok(()) }; + let Some(st) = steps.get(i) else { return Ok(()) }; + if let Some(f) = self.frames.last_mut() { + f.next += 1; + } + let mut r = self.exec(s, &path, st); + if let Err(e) = r.as_mut() { + e.wrote_files.clone_from(&self.wrote_files); + self.frames.clear(); + } + self.settle(); + r + } + + fn exec(&mut self, s: &mut Session, path: &str, st: &Step) -> std::result::Result<(), StepError> { + self.executed += 1; + let at = |msg: String| StepError::new(path, None, msg); + if self.executed > MAX_EXECUTED_STEPS { + return Err(at(format!("The macro ran more than {MAX_EXECUTED_STEPS} steps and was stopped."))); + } + if self.clock.elapsed_ms().is_some_and(|ms| ms > self.time_limit_ms) { + return Err(at(format!("The macro ran longer than {} seconds and was stopped.", (self.time_limit_ms / 1000.0).round()))); + } + // Steps run on the macro's workbook, whichever one is in front. + let Some(i) = s.docs.iter().position(|d| d.uid == self.doc) else { return Err(at("The workbook was closed.".into())) }; + s.set_active(i); + match &st.kind { + StepKind::Command { id, params } => self.command(s, path, id, params), + StepKind::Repeat { over, of, max, body } => { + let place = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(over, &env).map_err(|e| StepError::resolve(path, None, e))?; + trim(wb, p) + }; + let items = match of { + RepeatOf::Rows => place.range.height(), + RepeatOf::Columns => place.range.width(), + }; + let cap = (*max).min(MAX_EXECUTED_STEPS as u32); + if items > cap { + let what = if *of == RepeatOf::Rows { "rows" } else { "columns" }; + return Err(at(format!("Repeat over {} has {items} {what}, more than its limit of {cap}.", place.range.a1()))); + } + if items > 0 && body.iter().any(|b| b.enabled) { + let repeat = Some(Loop { of: *of, over: place, at: 0, count: items }); + let steps = self.body(path.to_string(), body); + self.frames.push(Frame { steps, next: 0, prefix: path.to_string(), repeat }); + } + Ok(()) + } + StepKind::If { cell, op, value, then, otherwise } => { + let v = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(cell, &env).map_err(|e| StepError::resolve(path, None, e))?; + if !p.range.is_single() { + return Err(at(format!("If looks at one cell; {} is {} cells.", p.range.a1(), p.range.count()))); + } + wb.sheet(p.sheet).map(|sh| sh.value(p.range.start)).unwrap_or_default() + }; + let (branch, name) = if compare(&v, *op, value) { (then, "then") } else { (otherwise, "else") }; + if branch.iter().any(|b| b.enabled) { + let prefix = format!("{path}.{name}"); + let steps = self.body(prefix.clone(), branch); + self.frames.push(Frame { steps, next: 0, prefix, repeat: None }); + } + Ok(()) + } + StepKind::Stop { message, rollback } => { + self.stopped = Some((message.clone(), *rollback)); + self.frames.clear(); + Ok(()) + } + } + } + + /// After a step that inserted, deleted or moved sheets, rows, columns or cells, moves the + /// inputs, the output and the ranges of the Repeats in progress with their cells, as the + /// references in formulas move. A step that deletes one of them, or changes the size of a + /// range a Repeat is going over, stops the run. + fn follow(&mut self, before: &Workbook, after: &Workbook, edits: &[(String, Edit)]) -> std::result::Result<(), String> { + let same_sheets = before.sheets.len() == after.sheets.len() && before.sheets.iter().zip(&after.sheets).all(|(b, a)| b.name == a.name); + if same_sheets && edits.is_empty() { + return Ok(()); + } + let (pairs, _) = pair_sheets(before, after); + let follow = |what: &str, p: Place| follow_place(before, after, &pairs, edits, p).ok_or_else(|| format!("It deleted the cells of {what}.")); + for (name, place, _) in self.inputs.iter_mut() { + *place = follow(name, *place)?; + } + if let Some(o) = self.output { + self.output = Some(follow("the output anchor", o)?); + } + for f in self.frames.iter_mut() { + let Some(l) = f.repeat.as_mut() else { continue }; + let over = follow(&format!("the range Repeat {} goes over", f.prefix), l.over)?; + if (over.range.height(), over.range.width()) != (l.over.range.height(), l.over.range.width()) { + return Err(format!("It changed the size of the range Repeat {} goes over.", f.prefix)); + } + l.over = over; + } + Ok(()) + } + + /// The shared copy of the body that runs under `prefix`. + fn body(&mut self, prefix: String, body: &[Step]) -> Arc> { + Arc::clone(self.bodies.entry(prefix).or_insert_with(|| Arc::new(body.to_vec()))) + } + + fn env<'a>(&'a self, wb: &'a Workbook) -> Env<'a> { + Env { wb, active_sheet: wb.active_sheet(), inputs: &self.inputs, output: self.output, item: self.item() } + } + + fn command(&mut self, s: &mut Session, path: &str, id: &str, params: &Json) -> std::result::Result<(), StepError> { + let fail = |msg: String| StepError::new(path, Some(id), msg); + // The policy sees the parameters the command will get, after tokens became A1 text. + let resolved = { + let wb = &s.doc().map_err(|e| fail(e.to_string()))?.wb; + resolve::resolve_params(params, &self.env(wb)).map_err(|e| StepError::resolve(path, Some(id), e))? + }; + match (macro_policy(id, params, self.trust), macro_policy(id, &resolved, self.trust)) { + (_, Policy::Deny(why)) | (Policy::Deny(why), _) => return Err(fail(format!("{id} can't run in a macro: {why}."))), + (written, Policy::NeedsPermission(what)) => { + if !self.permissions.iter().any(|p| p == id) { + return Err(fail(format!("{id} {what}. Add it to the macro's permissions to allow it."))); + } + // The gate asked the person about this step only if it saw the file access. + if !matches!(written, Policy::NeedsPermission(_)) { + return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved."))); + } + if self.dry_run { + self.partial = Some((path.to_string(), format!("{id} {what}"))); + self.frames.clear(); + return Ok(()); + } + } + (_, Policy::Allow | Policy::Ask(_)) => {} + } + if self.dry_run && !previewable(id) { + self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); + self.frames.clear(); + return Ok(()); + } + self.param_bytes = self.param_bytes.saturating_add(json_bytes(&resolved)); + if self.param_bytes > MAX_PARAM_BYTES { + return Err(fail(format!( + "The macro passed more than {} MB of parameters to its steps and was stopped.", + MAX_PARAM_BYTES / (1024 * 1024) + ))); + } + let file_step = matches!(macro_policy(id, &resolved, self.trust), Policy::NeedsPermission(_)); + let writes = writes_file(id, &resolved); + self.last_params = Some(resolved.clone()); + let prev = s.docs.iter_mut().find(|d| d.uid == self.doc).map(|d| { + d.edits = Some(Vec::new()); + d.wb.clone() + }); + let queued = s.ui_requests.len(); + // Only an approved file step reaches the file system: any other step runs as a script's + // does, so a command that reads or writes a file under another parameter name can't. + let sandbox = (!file_step).then(crate::io::ScriptSandbox::enter); + let r = s.execute(id, resolved); + drop(sandbox); + let edits = s.docs.iter_mut().find(|d| d.uid == self.doc).and_then(|d| d.edits.take()).unwrap_or_default(); + // Macros never open dialogs or web pages, and never start editing a cell; messages for + // the person stay queued. + let mut blocked = None; + let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); + for q in asked { + match q { + UiRequest::Dialog(name, _) => blocked = Some(format!("{id} would open the {name} dialog; give the step the parameters it needs.")), + UiRequest::OpenUrl(_) => blocked = Some(format!("{id} would open a web page, which macros don't do.")), + UiRequest::EditCell(_) | UiRequest::InsertName(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + r.map_err(|e| fail(e.to_string()))?; + if writes { + self.wrote_files.push(format!("step {path} ({id})")); + } + if let Some(b) = blocked { + return Err(fail(b)); + } + let after = s.docs.iter().find(|d| d.uid == self.doc).map(|d| d.wb.clone()).ok_or_else(|| fail("The workbook was closed.".into()))?; + if let Some(prev) = &prev { + self.follow(prev, &after, &edits).map_err(fail)?; + } + if let (Some(g), Some(prev)) = (self.guard.as_mut(), prev) { + let what = g.check(&prev, &after, &self.inputs); + if !what.is_empty() { + let msg = match &g.partial { + Some(at) => format!("It would write over data ({what}); the check before the run stopped at {at} and couldn't see this."), + // A value such as RAND or NOW differed from the check's, and the run went another way. + None => format!("It would write over data ({what}); the check before the run didn't see this write."), + }; + return Err(StepError { confirm: g.ask, ..fail(msg) }); + } + } + Ok(()) + } +} + +/// Where the cells of `p` are after a step: on the same sheet wherever it moved (`pairs`, from +/// [`pair_sheets`]), and moved by each structural edit the step made there, or `None` when the +/// step deleted them (or their sheet). +fn follow_place(before: &Workbook, after: &Workbook, pairs: &[(usize, Option)], edits: &[(String, Edit)], p: Place) -> Option { + let sheet = pairs.iter().find(|(b, _)| *b == p.sheet).and_then(|(_, a)| *a)?; + let mut name = after.sheet(sheet)?.name.clone(); + let old_name = before.sheet(p.sheet).map(|s| s.name.as_str()).unwrap_or_default(); + let mut text = format!("{}!{}", resolve::quote_sheet(&name), p.range.a1()); + for (target, e) in edits { + if target != &name && target != old_name { + continue; + } + let expr = gridcraft_formula::parse(&text).ok()?; + text = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, target, target, e)); + if text.contains("#REF!") { + return None; + } + // A cut and paste can take the cells to another sheet. + let env = Env { wb: after, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + name = after.sheet(resolve::resolve_ref(&text, &env).ok()?.sheet)?.name.clone(); + } + let env = Env { wb: after, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + if text.contains('$') { + text = text.replace('$', ""); + } + resolve::resolve_ref(&text, &env).ok() +} + +/// If's comparison of a cell's value with a step's `value`, the way Excel's `=`, `<`… compare. +fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { + let empty = v.is_empty() || v.as_text() == Some(""); + let want_text = || match want { + Json::String(t) => t.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let ord = || -> Option { + let w = match want { + Json::Null => Value::Empty, + Json::Bool(b) => Value::Bool(*b), + Json::Number(n) => Value::number(n.as_f64()?), + // "5" compares as a number with a number in the cell. + Json::String(t) => match (v, t.trim().parse::()) { + (Value::Number(_), Ok(n)) => Value::number(n), + _ => Value::text(t.as_str()), + }, + _ => return None, + }; + match (v, &w) { + (Value::Error(e), Value::Text(t)) => { + Some(if e.as_str().eq_ignore_ascii_case(t.trim()) { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }) + } + (Value::Error(_), _) | (_, Value::Error(_)) | (Value::Array(_), _) => None, + _ => Some(gridcraft_core::value::compare(v, &w)), + } + }; + use std::cmp::Ordering::*; + match op { + CompareOp::Empty => empty, + CompareOp::NotEmpty => !empty, + CompareOp::Contains => v.display().to_lowercase().contains(&want_text().to_lowercase()), + CompareOp::Eq => ord() == Some(Equal), + CompareOp::Ne => ord() != Some(Equal), + CompareOp::Gt => ord() == Some(Greater), + CompareOp::Ge => matches!(ord(), Some(Greater | Equal)), + CompareOp::Lt => ord() == Some(Less), + CompareOp::Le => matches!(ord(), Some(Less | Equal)), + } +} + +// ---------------------------------------------------------------- footprint + +/// What a macro would change, from a dry run on the real workbook. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Footprint { + /// Steps executed and bytes of parameters passed by the end of the dry run that made this + /// footprint (the run's one budget). + #[serde(skip)] + pub executed: u64, + #[serde(skip)] + pub param_bytes: u64, + /// The Stop step that ended the dry run asks for the run to be rolled back. + #[serde(skip)] + pub rolls_back: bool, + /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value + /// may be missing from `cells`: a shift cannot trust the block. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub log_full: bool, + pub sheets: Vec, + pub sheets_added: Vec, + pub sheets_removed: Vec, + pub objects_added: usize, + pub objects_changed: usize, + pub objects_removed: usize, + /// The dry run stopped early before this step; later steps aren't in the footprint. + #[serde(skip_serializing_if = "Option::is_none")] + pub partial: Option, + /// The step the run would fail at. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// A Stop step's message. + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped: Option, +} + +/// The cells a macro would write on one sheet, outside its inputs. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetFootprint { + pub sheet: String, + #[serde(skip)] + pub index: usize, + /// The written cells as ranges (at most 500 listed), and how many cells. + pub cells: Vec, + pub changed: usize, + /// Written cells that hold data now. + pub conflicts: Vec, + pub conflict_count: usize, + /// The block that holds every written cell. + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + #[serde(skip)] + pub bounds_range: Option, +} + +impl Footprint { + /// Cells that hold data and would be written, plus sheets and objects that would go. + pub fn conflict_count(&self) -> usize { + self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed + } + + /// "C2:C9 on Sheet1, the sheet Old, 2 objects". + pub fn describe_conflicts(&self) -> String { + let mut parts: Vec = self + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .map(|s| { + let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); + if s.conflicts.len() > 3 { + listed.push_str(", …"); + } + format!("{listed} on {}", s.sheet) + }) + .collect(); + parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); + if self.objects_removed > 0 { + parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); + } + parts.join("; ") + } +} + +/// Session state a dry run may touch, put back afterwards. The workbook, its selection and its +/// history are put back by the dry run's undo group. +struct Saved { + active: usize, + docs: Vec, + /// The workbook as last saved: `sheet.activate` and the view commands move it to the + /// current workbook on a clean document, and the rollback puts the workbook back but not this. + saved: Option>, + /// The document's revision: the rollback counts as a change, and front ends key their caches + /// on it, so a check that changed nothing leaves it as it was. + revision: Option, + sheet_selections: Vec, + unlocked_ranges: Vec<(String, String)>, + trust: Trust, + go_to_recent: Vec, + clipboard: Option, + prefs: crate::Prefs, + mode: crate::Mode, + /// The journal's length: the dry run journals nothing (`Session::journal_paused`), so nothing + /// of the person's is evicted, and the length puts back exactly what was there. + journal: usize, + recording: Option, + ui_requests: usize, + format_painter: Option<(Arc, usize, RangeRef, bool)>, + last_find: Option, + hide_ruler: bool, + windows: (Vec, Option, u64, u64), + draw: (String, String, f32), + comments_pane: bool, + show_all_notes: bool, +} + +impl Saved { + /// Takes the state and stops journaling until [`Saved::restore`]. + fn take(s: &mut Session, doc: u64) -> Saved { + let d = s.docs.iter().find(|d| d.uid == doc); + let saved = Saved { + active: s.active, + docs: s.docs.iter().map(|d| d.uid).collect(), + saved: d.map(|d| d.saved.clone()), + revision: d.map(|d| d.revision), + sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), + unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), + trust: d.map_or(Trust::Local, |d| d.trust), + go_to_recent: d.map(|d| d.go_to_recent.clone()).unwrap_or_default(), + clipboard: s.clipboard.clone(), + prefs: s.prefs.clone(), + mode: s.mode, + journal: s.journal.len(), + recording: s.recording, + ui_requests: s.ui_requests.len(), + format_painter: s.format_painter.clone(), + last_find: s.last_find.clone(), + hide_ruler: s.hide_ruler, + windows: (s.windows.clone(), s.active_window, s.next_window, s.activation), + draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), + comments_pane: s.comments_pane, + show_all_notes: s.show_all_notes, + }; + s.journal_paused = s.journal_paused.saturating_add(1); + saved + } + + fn restore(self, s: &mut Session, doc: u64) { + s.docs.retain(|d| self.docs.contains(&d.uid)); + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + if let Some(saved) = self.saved { + d.saved = saved; + } + if let Some(revision) = self.revision { + d.revision = revision; + } + d.sheet_selections = self.sheet_selections; + d.unlocked_ranges = self.unlocked_ranges; + d.trust = self.trust; + d.go_to_recent = self.go_to_recent; + } + s.active = self.active.min(s.docs.len().saturating_sub(1)); + s.clipboard = self.clipboard; + s.prefs = self.prefs; + s.mode = self.mode; + s.truncate_journal(self.journal); + s.recording = self.recording; + s.journal_paused = s.journal_paused.saturating_sub(1); + s.ui_requests.truncate(self.ui_requests); + s.format_painter = self.format_painter; + s.last_find = self.last_find; + s.hide_ruler = self.hide_ruler; + (s.windows, s.active_window, s.next_window, s.activation) = self.windows; + (s.draw_tool, s.draw_color, s.draw_width) = self.draw; + s.comments_pane = self.comments_pane; + s.show_all_notes = self.show_all_notes; + } +} + +/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would +/// change. The dry run spends from `run`'s budget: its clock and time limit, its steps and the +/// bytes of parameters it passed. +pub fn footprint(s: &mut Session, p: &Prepared, run: &Machine) -> Result { + let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; + s.set_active(i); + let before = s.doc()?.wb.clone(); + let token = s.group_begin("Check Macro")?; + // After the calls that can fail, so nothing returns between taking and putting back. + let saved = Saved::take(s, p.doc); + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { + d.touched = Some(Vec::new()); + } + let mut m = Machine::new(p, true); + (m.clock, m.time_limit_ms, m.executed, m.param_bytes) = (run.clock, run.time_limit_ms, run.executed, run.param_bytes); + let outcome = m.run_to_end(s); + let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { + Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), + None => (before.clone(), Vec::new()), + }; + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; + let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; + (fp.executed, fp.param_bytes) = (m.executed, m.param_bytes); + fp.rolls_back = matches!(m.stopped, Some((_, true))); + fp.error = outcome.err(); + fp.stopped = m.stopped.map(|(msg, _)| msg); + fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); + Ok(fp) +} + +/// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote +/// without changing them (a formula written again), so a shift moves everything the macro writes. +fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { + let mut fp = Footprint::default(); + // One pass over the touch log (up to a million writes), not one per sheet. + let mut by_sheet: HashMap<&str, BTreeSet> = HashMap::new(); + for (n, c) in touched { + by_sheet.entry(n.as_str()).or_default().insert(*c); + } + let (pairs, added) = pair_sheets(before, after); + fp.sheets_added = added.into_iter().filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); + for (bi, ai) in pairs { + let Some(bs) = before.sheets.get(bi) else { continue }; + let Some(ai) = ai else { + fp.sheets_removed.push(bs.name.clone()); + continue; + }; + let Some(after_sheet) = after.sheets.get(ai) else { continue }; + let rewritten = by_sheet.remove(after_sheet.name.as_str()).unwrap_or_default(); + if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { + continue; + } + let (added, changed, removed) = objects(bs, after_sheet); + fp.objects_added += added; + fp.objects_changed += changed; + fp.objects_removed += removed; + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); + let mut written = Vec::new(); + let mut conflicts = Vec::new(); + let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); + cells.extend(rewritten.iter().copied()); + for c in cells { + if in_input(c) { + continue; + } + let (b, a) = (bs.cell(c), after_sheet.cell(c)); + if recalculated_only(b, a) && !rewritten.contains(&c) { + continue; + } + written.push(c); + if b.is_some_and(has_content) && content_differs(b, a) { + conflicts.push(c); + } + } + let Some(first) = written.first() else { continue }; + let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); + fp.sheets.push(SheetFootprint { + sheet: bs.name.clone(), + index: bi, + changed: written.len(), + conflict_count: conflicts.len(), + cells: ranges(&mut written), + conflicts: ranges(&mut conflicts), + bounds: Some(bounds.a1()), + bounds_range: Some(bounds), + }); + } + fp +} + +/// Each sheet of `before` with its index in `after` (`None` when deleted), and the sheets of +/// `after` that are new. Sheets are matched by name; what's left is matched in order (a renamed +/// sheet). +pub(crate) fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, Option)>, Vec) { + let mut pairs: Vec<(usize, Option)> = before.sheets.iter().enumerate().map(|(bi, sh)| (bi, after.sheet_index(&sh.name))).collect(); + let matched: BTreeSet = pairs.iter().filter_map(|(_, a)| *a).collect(); + let mut spare = (0..after.sheets.len()).filter(|a| !matched.contains(a)); + for (_, a) in pairs.iter_mut().filter(|(_, a)| a.is_none()) { + *a = spare.next(); + } + (pairs, spare.collect()) +} + +/// Only a formula's cached value changed (a recalculation, not a write). +fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style, + _ => false, + }, + _ => false, + } +} + +fn has_content(c: &Cell) -> bool { + c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) +} + +fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text != y.text, + (None, None) => b.value != a.value, + _ => true, + }, + (Some(b), None) => has_content(b), + _ => false, + } +} + +/// Objects added, changed and removed between two versions of a sheet. +fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { + fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { + let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); + let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); + let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); + (added, changed, removed) + } + let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; + parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) +} + +/// Cells as A1 ranges: runs along each row, then runs with the same columns on following rows. +pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { + cells.sort_unstable_by_key(|c| (c.row, c.col)); + let mut runs: Vec = Vec::new(); + for c in cells.iter() { + match runs.last_mut() { + Some(r) if r.end.row == c.row && r.end.col.checked_add(1) == Some(c.col) => r.end.col = c.col, + _ => runs.push(RangeRef::cell(*c)), + } + } + let mut merged: Vec = Vec::new(); + for r in runs { + // The block above with the same columns that ends on the row before. + match merged + .iter_mut() + .rev() + .take(64) + .find(|m| m.start.col == r.start.col && m.end.col == r.end.col && m.end.row.checked_add(1) == Some(r.start.row)) + { + Some(m) => m.end.row = r.end.row, + None => merged.push(r), + } + } + merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() +} + +/// Inserts blank cells where the footprint lands (moving what is there down or right), so the +/// macro writes into empty cells. The block is every written cell's, so a row the macro writes +/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were +/// checked and bound where they are, and only the block's columns (or rows) would move. +fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { + let wb = s.doc()?.wb.clone(); + if let Some(why) = shift_refusal(&wb, fp, p, down) { + return Err(EngineError::Other(why)); + } + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let moves: Vec = fp + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .filter_map(|sh| sh.bounds_range.map(|block| resolve::a1(Place { sheet: sh.index, range: block }, &env))) + .collect(); + for range in moves { + s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; + } + Ok(()) +} + +/// Why shifting the data can't make room for `fp`, or `None`: the macro deletes sheets or +/// objects, the footprint couldn't follow every write, or the shift would move an input. +fn shift_refusal(wb: &Workbook, fp: &Footprint, p: &Prepared, down: bool) -> Option { + if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { + return Some("Shifting can't make room: the macro deletes sheets or objects.".into()); + } + if fp.log_full { + return Some("Shifting can't make room: the macro writes more cells than the footprint can follow.".into()); + } + let env = Env { wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { + let Some(block) = sh.bounds_range else { continue }; + // What the insertion moves: the block's columns from its top row down, or its rows from + // its left column rightwards. + let moved = |r: &RangeRef| { + if down { + r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row + } else { + r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col + } + }; + // The output is where the macro writes, so moving what is there is the point; the second + // footprint catches data the shift moved into it. + if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { + return Some(format!( + "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", + resolve::a1(pl, &env) + )); + } + } + None +} + +// ---------------------------------------------------------------- run + +/// A macro that passed its checks and is about to run. +pub struct Begun { + pub mac: Macro, + pub prepared: Prepared, + pub rule: OnConflict, + pub time_limit_ms: f64, + pub inputs: Json, +} + +/// The checks before a run, on the active workbook: the macro exists, the policy allows it, the +/// person approved it if it needs that, and its inputs are right. +pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let m = super::find(&d.wb, name) + .and_then(|i| d.wb.macros.get(i)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; + check_params(p, &m)?; + let asks = gate(&m, trust)?; + approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?; + let prepared = prepare(s, &m, p.get("inputs"))?; + let rule = conflict_rule(p, &m)?; + let time_limit_ms = time_limit(p); + let inputs = prepared.inputs_json(&s.doc()?.wb); + Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) +} + +/// The footprint check and the output rule, inside the run's undo group: an error when the macro +/// would fail or write over data it may not, after shifting the data if the rule says so. Returns +/// the machine for the run and whether it shifted. The dry runs count towards the run's time +/// limit. +/// +/// The footprint proves nothing about steps it didn't reach (it stopped early) or a path the run +/// takes and the dry run didn't (RAND, NOW), so the machine also checks each step as it runs and +/// stops the run at one that writes over data. +pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { + let (m, rule) = (&b.mac, b.rule); + let mut machine = Machine::new(&b.prepared, false); + machine.time_limit_ms = b.time_limit_ms; + if rule == OnConflict::Overwrite { + return Ok((machine, false)); + } + // The dry runs and the run share one budget (steps, parameters) as they share one clock. + let mut fp = footprint(s, &b.prepared, &machine)?; + (machine.executed, machine.param_bytes) = (fp.executed, fp.param_bytes); + if let Some(e) = fp.error.take() { + return Err(e.into_engine(&m.name)); + } + let mut shifted = false; + if fp.conflict_count() > 0 { + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => { + // Overwriting answers for every step, also the ones a partial footprint didn't reach. + let unseen = fp.partial.as_ref().map_or(String::new(), |p| { + let text = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or_default().to_string(); + format!( + " The check stopped at step {} ({}), so overwriting also lets the steps after it write over data unchecked.", + text("step"), + text("reason") + ) + }); + return Err(EngineError::Confirm(format!( + "Macro '{}' would write over cells that hold data ({what}).{unseen} Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + m.name + ))); + } + OnConflict::Fail | OnConflict::Overwrite => { + return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); + } + OnConflict::ShiftDown | OnConflict::ShiftRight => { + make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; + fp = footprint(s, &b.prepared, &machine)?; + (machine.executed, machine.param_bytes) = (fp.executed, fp.param_bytes); + if let Some(e) = fp.error.take() { + return Err(e.into_engine(&m.name)); + } + if fp.conflict_count() > 0 { + return Err(EngineError::Other(format!( + "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", + m.name, + fp.describe_conflicts() + ))); + } + shifted = true; + } + } + } + let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone(); + machine.guard = Some(Guard::new(start, fp.partial.as_ref(), rule == OnConflict::Ask)); + Ok((machine, shifted)) +} + +/// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, +/// timeLimit?}` (see `macro.run`). +pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { + let b = begin(s, name, p)?; + let label = format!("Run Macro: {}", b.mac.name); + s.grouped(&label, |s| { + let (mut machine, shifted) = make_way(s, &b)?; + let name = &b.mac.name; + // `ran` counts the run's own steps; the budget also holds the dry runs'. + let before_run = machine.executed; + machine.run_to_end(s).map_err(|e| e.into_engine(name))?; + match machine.stopped { + Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} {}", rolled_back(&machine.wrote_files)))), + stopped => { + let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); + if let Some((msg, _)) = stopped { + out["stopped"] = json!(msg); + } + if shifted { + out["shifted"] = json!(b.rule.name()); + } + Ok(out) + } + } + }) +} + +/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an +/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve` +/// or `timeLimit` of the wrong kind (`null` counts as not given). +pub fn check_params(p: &Json, m: &Macro) -> Result<()> { + let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name))); + let names = || m.inputs.iter().map(|i| i.name.clone()).collect::>().join(", "); + match p.get("inputs") { + None | Some(Json::Null) => {} + Some(Json::String(_)) if !m.inputs.is_empty() => {} + Some(Json::String(_)) => return refuse("it takes no inputs.".into()), + Some(Json::Object(o)) => { + for (k, v) in o { + if !m.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(k)) { + return refuse(if m.inputs.is_empty() { + format!("it has no input called '{k}'; it takes no inputs.") + } else { + format!("it has no input called '{k}' (its inputs: {}).", names()) + }); + } + if !v.is_string() { + return refuse(format!("give the input {k} as reference text, such as \"B2:D9\".")); + } + } + } + Some(_) => return refuse("give `inputs` as reference text, such as \"B2:D9\", or as {\"Input\": \"B2:D9\"}.".into()), + } + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + if given("onConflict").is_some_and(|v| !v.is_string()) { + return refuse("`onConflict` must be ask, fail, overwrite, shiftDown or shiftRight.".into()); + } + for k in ["overwrite", "approve"] { + if given(k).is_some_and(|v| !v.is_boolean()) { + return refuse(format!("`{k}` must be true or false.")); + } + } + if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) { + return refuse("`timeLimit` must be a number of seconds above 0.".into()); + } + Ok(()) +} + +/// The run's `timeLimit` (seconds) in milliseconds, or the default. +fn time_limit(p: &Json) -> f64 { + p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS) +} + +/// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the +/// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given. +fn conflict_rule(p: &Json, m: &Macro) -> Result { + let rule = match p.get("onConflict").and_then(Json::as_str) { + Some(t) => OnConflict::parse(t) + .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?, + None => m.output.on_conflict, + }; + if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) { + return Ok(OnConflict::Overwrite); + } + Ok(rule) +} + +/// For `macro.check`, as the run would: shifts the data, checks the footprint again, and puts +/// everything back. What still stands in the way, or `None`. +fn shifted_conflicts(s: &mut Session, fp: &Footprint, p: &Prepared, budget: &Machine, down: bool) -> Result> { + let token = s.group_begin("Check Macro")?; + let saved = Saved::take(s, p.doc); + let mut spent = budget.clone(); + (spent.executed, spent.param_bytes) = (fp.executed, fp.param_bytes); + let again = make_room(s, fp, p, down).and_then(|()| footprint(s, p, &spent)); + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + Ok(match again { + Err(e) => Some(e.to_string()), + Ok(again) => match (&again.error, again.conflict_count()) { + (Some(e), _) => Some(e.describe()), + (None, 0) => None, + (None, _) => Some(format!("Shifting the data doesn't make room ({}).", again.describe_conflicts())), + }, + }) +} + +/// `macro.check`: what running the macro would do, without changing anything. +pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let m = super::find(&d.wb, name) + .and_then(|i| d.wb.macros.get(i)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; + let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); + let refused = |mut out: Json, e: String| { + out["errors"] = json!([e]); + Ok(out) + }; + if let Err(e) = check_params(p, &m) { + return refused(out, e.to_string()); + } + let rule = match conflict_rule(p, &m) { + Ok(r) => r, + Err(e) => return refused(out, e.to_string()), + }; + out["onConflict"] = json!(rule.name()); + let asks = match gate(&m, trust) { + Ok(a) => a, + Err(e) => return refused(out, e.to_string()), + }; + let approved = s.macro_approvals.contains(&(doc, super::fingerprint(&m))); + if !asks.is_empty() && !approved { + // Nothing from an unapproved macro runs, not even in a dry run. + out["needsApproval"] = json!(asks); + } + let prepared = match prepare(s, &m, p.get("inputs")) { + Ok(pr) => pr, + Err(e) => return refused(out, e.to_string()), + }; + out["inputs"] = prepared.inputs_json(&s.doc()?.wb); + if let Some(o) = prepared.output { + let wb = &s.doc()?.wb; + out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet(), inputs: &[], output: None, item: None })); + } + if !asks.is_empty() && !approved { + return Ok(out); + } + let mut budget = Machine::new(&prepared, false); + budget.time_limit_ms = time_limit(p); + let fp = footprint(s, &prepared, &budget)?; + // `ok`: the run would go ahead with this rule, without a question. + let mut problems = Vec::new(); + if let Some(e) = &fp.error { + problems.push(e.describe()); + } + if let (true, Some(msg)) = (fp.rolls_back, &fp.stopped) { + problems.push(format!("A Stop step ends the run and rolls it back: {msg}")); + } + let shifts = matches!(rule, OnConflict::ShiftDown | OnConflict::ShiftRight); + if fp.conflict_count() > 0 { + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => problems.push(format!("It would write over cells that hold data ({what}); the run asks first.")), + OnConflict::Fail => problems.push(format!("It would write over cells that hold data ({what}).")), + OnConflict::Overwrite => {} + OnConflict::ShiftDown | OnConflict::ShiftRight => { + let down = rule == OnConflict::ShiftDown; + if let Some(why) = shift_refusal(&s.doc()?.wb, &fp, &prepared, down) { + problems.push(why); + } else if let Some(why) = shifted_conflicts(s, &fp, &prepared, &budget, down)? { + problems.push(why); + } + } + } + } + // The run spends its budget on the dry runs before it too: one, or two when it shifts. + let passes: u64 = match rule { + OnConflict::Overwrite => 1, + _ if shifts && fp.conflict_count() > 0 => 3, + _ => 2, + }; + if fp.executed.saturating_mul(passes) > MAX_EXECUTED_STEPS { + problems.push(format!("The run would execute more than {MAX_EXECUTED_STEPS} steps, counting the check before it ({} here).", fp.executed)); + } + if fp.param_bytes.saturating_mul(passes) > MAX_PARAM_BYTES { + problems.push(format!("The run would pass more than {} MB of parameters, counting the check before it.", MAX_PARAM_BYTES / (1024 * 1024))); + } + out["ok"] = json!(problems.is_empty()); + out["conflicts"] = json!(fp.conflict_count()); + if !problems.is_empty() { + out["errors"] = json!(problems); + } + out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); + Ok(out) +} diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs new file mode 100644 index 000000000..52f3ba53f --- /dev/null +++ b/crates/engine/src/macros/tests.rs @@ -0,0 +1,740 @@ +use gridcraft_model::*; +use serde_json::json; + +use super::*; + +fn sample_macro(name: &str) -> Macro { + parse_macro(&json!({ + "name": name, + "description": "Totals the amounts", + "shortcut": "Ctrl+Shift+T", + "inputs": [{"name": "Input", "rows": [1, 500], "cols": [2, 2], "header": true, "colTypes": ["text", "number"], "nonEmpty": true}], + "steps": [ + {"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "=SUM({{Input.col(2)}})"}, "breakpoint": true, "note": "the total"}, + {"type": "repeat", "over": "Input.rows(2..)", "of": "rows", "max": 100, "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, "then": [ + {"command": "home.bold", "params": {"range": "Row"}} + ], "else": [{"type": "stop", "message": "done"}]} + ]}, + {"type": "command", "id": "home.italic", "enabled": false} + ], + "output": {"anchor": "Input.offset(0,3)", "onConflict": "shiftDown"}, + "permissions": ["data.getData"] + })) + .unwrap() +} + +#[test] +fn macros_read_from_json_in_both_step_forms() { + let m = sample_macro("Totals"); + assert_eq!(m.v, MACRO_VERSION); + assert_eq!(m.inputs[0].col_types, [ColType::Text, ColType::Number]); + assert_eq!(m.output.on_conflict, OnConflict::ShiftDown); + assert_eq!(m.step_count(), 6); + assert!(m.steps[0].breakpoint && m.steps[0].enabled && !m.steps[2].enabled); + let StepKind::Repeat { body, max, .. } = &m.steps[1].kind else { panic!("{:?}", m.steps[1]) }; + assert_eq!(*max, 100); + let StepKind::If { then, otherwise, op, .. } = &body[0].kind else { panic!() }; + assert_eq!(*op, CompareOp::Gt); + // The short form of earlier scripts reads as a command step. + assert_eq!(then[0].kind, StepKind::Command { id: "home.bold".into(), params: json!({"range": "Row"}) }); + assert!(matches!(&otherwise[0].kind, StepKind::Stop { message, rollback: false } if message == "done")); + // Writing and reading again gives the same macro; defaults stay out of the JSON. + let v = macro_json(&m); + assert_eq!(v["steps"][0]["type"], "command"); + assert!(v["steps"][1].get("enabled").is_none() && v["steps"][1].get("breakpoint").is_none()); + assert_eq!(v["steps"][2]["enabled"], false); + assert_eq!(parse_macro(&v).unwrap(), m); + // A minimal macro gets the defaults. + let m = parse_macro(&json!({"name": " Bare "})).unwrap(); + assert_eq!((m.name.as_str(), m.output.on_conflict, m.inputs.len()), ("Bare", OnConflict::Ask, 0)); +} + +#[test] +fn macros_over_the_limits_are_refused() { + let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]}); + assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok()); + assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps")); + // Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a + // command, or nothing), a 5th is not. + let nest = |repeats: usize| { + let mut step = json!({"command": "cell.set"}); + for _ in 0..repeats { + step = json!({"type": "repeat", "over": "Input", "body": [step]}); + } + json!({"name": "Deep", "steps": [step]}) + }; + assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok()); + assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1); + assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep")); + let mut empty = json!({"type": "repeat", "over": "Input", "body": []}); + for _ in 1..MAX_MACRO_DEPTH { + empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []}); + } + let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap(); + assert_eq!(deep.step_count(), MAX_MACRO_DEPTH); + assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser"); + // Far deeper than serde would allow is refused before it is read. + assert!(parse_macro(&nest(200)).is_err()); + for bad in [ + json!({"name": ""}), + json!({"name": "x".repeat(256)}), + json!({"name": "a\nb"}), + json!({"name": "A", "steps": {}}), + json!({"name": "A", "steps": [1]}), + json!({"name": "A", "steps": [{"type": "nope"}]}), + json!({"name": "A", "steps": [{"type": "if", "cell": "A1", "op": "between"}]}), + json!({"name": "A", "inputs": [{"name": ""}]}), + json!({"name": "A", "inputs": [{"name": "Has space"}]}), + json!({"name": "A", "inputs": [{"name": "row"}]}), + json!({"name": "A", "inputs": [{"name": "In"}, {"name": "IN"}]}), + json!({"name": "A", "output": {"onConflict": "explode"}}), + json!([]), + ] { + assert!(parse_macro(&bad).is_err(), "{bad}"); + } +} + +fn names_of(wb: &Workbook) -> Vec { + wb.names.iter().map(|n| n.name.clone()).collect() +} + +#[test] +fn macros_round_trip_through_xlsx_and_json() { + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("Totals")); + // Large enough to need several hidden names. + let long: Vec = (0..300).map(|r| Step::command("cell.set", json!({"cell": format!("A{r}"), "input": "x\"y".repeat(10)}))).collect(); + wb.macros.push(Macro { name: "Long".into(), steps: long, ..Macro::default() }); + for file in ["book.xlsx", "book.json"] { + let bytes = crate::io::save_bytes(&wb, file).unwrap(); + let (back, warnings) = crate::io::open_bytes(file, &bytes).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(back.macros, wb.macros, "{file}"); + assert!(back.names.is_empty(), "{file}: macros stay out of the names once opened: {:?}", names_of(&back)); + } + // In the xlsx file they are hidden names; the long one spans several. + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + let (raw, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap(); + let names = names_of(&raw); + assert!(names.contains(&"_gc_macro_0_0".to_string()), "{names:?}"); + assert!(names.iter().filter(|n| n.starts_with("_gc_macro_1_")).count() >= 2, "{names:?}"); + assert!(raw.names.iter().all(|n| n.hidden)); + // Saving without macros writes no macro names, even if stale ones linger. + let mut empty = raw.clone(); + empty.macros.clear(); + let (again, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&empty, "x.xlsx").unwrap()).unwrap(); + assert!(again.names.iter().all(|n| !n.name.starts_with("_gc_macro_"))); + // The JSON format keeps the field (and leaves it out when there are no macros). + let json = String::from_utf8(crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + assert!(json.contains("\"macros\"")); + let plain = String::from_utf8(crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + assert!(!plain.contains("\"macros\"")); +} + +fn hidden(name: &str, text: &str) -> DefinedName { + DefinedName { name: name.into(), scope: None, formula: format!("\"{}\"", text.replace('"', "\"\"")), comment: String::new(), hidden: true } +} + +#[test] +fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { + // Macro storage is hidden workbook-scope names; a visible `_gc_macro_0_0` someone defined + // is neither read as a macro nor removed when the workbook opens. + let mut wb = Workbook::new(); + let name = DefinedName { hidden: false, ..hidden("_gc_macro_0_0", "x") }; + wb.names.push(name.clone()); + let warnings = load(&mut wb); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(wb.macros.is_empty()); + assert_eq!(wb.names, vec![name]); +} + +#[test] +fn input_names_that_read_as_cell_references_are_refused() { + // A recorded step holds plain A1 text; an input called `Q1` would take its place. + let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]}); + for name in ["Q1", "AB12", "Sum1", "a1"] { + let err = parse_macro(&with_input(name)).unwrap_err(); + assert!(err.contains("reads as a cell reference"), "{name}: {err}"); + } + for name in ["Amount", "Q", "Sales_Q1", "Total1a"] { + assert!(parse_macro(&with_input(name)).is_ok(), "{name}"); + } + assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own")); +} + +#[test] +fn a_hidden_macro_name_this_build_did_not_write_is_kept() { + // `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a + // save with macros keeps it beside the items written. + let mut wb = Workbook::new(); + let theirs = hidden("_gc_macro_index_0", "{}"); + wb.names.push(theirs.clone()); + wb.macros.push(sample_macro("Totals")); + let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + let names = names_of(&raw); + assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}"); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), ["_gc_macro_index_0"]); +} + +#[test] +fn workbook_snapshots_share_the_macro_list_until_it_changes() { + // An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every + // step; the undo budget charges a list only once it is a snapshot's own. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("Totals")); + let snapshot = wb.clone(); + assert!(wb.macros.shares(&snapshot.macros)); + assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::()); + wb.macros.push(sample_macro("More")); + assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first"); + assert_eq!(snapshot.macros.len(), 1); + assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::()); + // The copy is per list, not per workbook copy: cloning again shares the new list. + let again = wb.clone(); + assert!(wb.macros.shares(&again.macros)); + let rebuilt: Macros = wb.macros.iter().cloned().collect(); + assert_eq!(rebuilt, wb.macros); + assert_eq!(rebuilt.into_iter().count(), 2); +} + +#[test] +fn a_macro_built_in_code_goes_through_the_size_check_too() { + // `check` is the one check for every path, so a macro that passes it never makes a save fail. + let big = Macro { + name: "Big".into(), + steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))], + ..Macro::default() + }; + assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size"); + assert!(check(&big).unwrap_err().contains("too large")); + // A macro that fails `check` fails the save in either format, rather than being written and + // dropped the next time the file opens: too large, or past the model's limits. + let bad_name = Macro { name: "Two\nlines".into(), ..Macro::default() }; + assert!(check(&bad_name).is_err()); + for m in [big, bad_name] { + let mut wb = Workbook::new(); + wb.macros.push(m); + for file in ["book.xlsx", "book.json"] { + assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}"); + } + } +} + +fn visible(name: &str) -> DefinedName { + DefinedName { name: name.into(), scope: None, formula: "Sheet1!$A$1".into(), comment: String::new(), hidden: false } +} + +/// Saves `wb` to xlsx and returns the names in the file and the workbook opened from it. +fn xlsx_round_trip(wb: &Workbook) -> (Vec, Workbook) { + let bytes = crate::io::save_bytes(wb, "book.xlsx").unwrap(); + let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + (raw, back) +} + +#[test] +fn a_visible_name_of_the_storage_shape_does_not_block_a_save() { + // The index a macro is stored under is GridCraft's choice, so a person's own visible name + // of that shape (here in capitals: names compare without case) is stepped around, not + // refused. Stale hidden items still go in the same pass. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.macros.push(sample_macro("B")); + wb.names.push(visible("_GC_MACRO_0_0")); + wb.names.push(hidden("_gc_macro_7_0", "stale")); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, ["_GC_MACRO_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]); + assert_eq!(back.macros, wb.macros, "both macros, in their order"); + assert_eq!(back.names, vec![visible("_GC_MACRO_0_0")], "the person's name is kept as it was"); + assert_eq!(wb.names.len(), 2, "the open workbook is not changed by a save"); +} + +#[test] +fn a_huge_visible_index_takes_no_part_in_numbering() { + // A file can carry a visible name with any number in it. The macros take the free indices + // from 0 up, so no arithmetic depends on that number (one past the largest would overflow). + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.names.push(visible(&format!("_gc_macro_{}_0", u64::MAX))); + wb.names.push(visible("_gc_macro_0_0")); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, [format!("_gc_macro_{}_0", u64::MAX), "_gc_macro_0_0".into(), "_gc_macro_1_0".into()]); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), names_of(&wb)); +} + +#[test] +fn a_visible_legacy_name_of_the_storage_shape_is_kept() { + // The stash reads a legacy `_sc_` name as data whether or not it is hidden, so writing + // key `macro_0` would remove a person's visible `_sc_macro_0`. That key is stepped around too. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.names.push(visible("_sc_macro_0")); + let (raw, back) = xlsx_round_trip(&wb); + assert_eq!(raw, ["_sc_macro_0", "_gc_macro_1_0"]); + assert_eq!(back.macros, wb.macros); + assert_eq!(back.names, vec![visible("_sc_macro_0")]); +} + +#[test] +fn a_name_defined_after_opening_does_not_block_the_next_save() { + // Once a file is open its macros are out of the names, so Define Name accepts a visible + // `_gc_macro_0_0`; the next save steps around it. + let mut s = crate::Session::new(); + s.execute("file.new", json!({})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).macros.push(sample_macro("A")); + s.execute("formulas.defineName", json!({"name": "_gc_macro_0_0", "refersTo": "=Sheet1!$A$1"})).unwrap(); + let (raw, back) = xlsx_round_trip(&s.doc().unwrap().wb); + assert_eq!(raw, ["_gc_macro_0_0", "_gc_macro_1_0"]); + assert_eq!(back.macros.len(), 1); + assert_eq!(names_of(&back), ["_gc_macro_0_0"]); +} + +#[test] +fn scripts_and_vba_stay_as_they_are() { + // Automate scripts keep their own storage, and a VBA project is carried through unchanged. + let mut wb = Workbook::new(); + let scripts = json!({"Fill": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]}); + wb.names.push(hidden("_sc_scripts", &scripts.to_string())); + wb.vba_project = Some(std::sync::Arc::new(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec())); + wb.macros.push(sample_macro("Totals")); + let bytes = crate::io::save_bytes(&wb, "book.xlsm").unwrap(); + let (back, warnings) = crate::io::open_bytes("book.xlsm", &bytes).unwrap(); + // The only warning is the reader's note that the VBA project is kept and not run. + assert!(warnings.iter().all(|w| w.contains("VBA project")), "{warnings:?}"); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), ["_sc_scripts"], "the scripts item is untouched"); + assert_eq!(back.names[0].formula, wb.names[0].formula); + assert_eq!(back.vba_project, wb.vba_project); +} + +#[test] +fn damaged_or_oversized_macros_are_dropped_with_a_warning() { + let mut wb = Workbook::new(); + wb.names.push(hidden("_gc_macro_0_0", &json!({"name": "Good"}).to_string())); + wb.names.push(hidden("_gc_macro_1_0", "{not json")); + wb.names.push(hidden("_gc_macro_2_0", &json!({"name": "Huge", "steps": vec![json!({"command": "x"}); MAX_MACRO_STEPS + 1]}).to_string())); + wb.names.push(hidden("_gc_macro_3_0", &json!({"name": "good"}).to_string())); + wb.names.push(hidden("_gc_macro_10_0", &json!({"name": "Tenth"}).to_string())); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("hostile.xlsx", &bytes).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + // Saved order is by number (10 after 3); a clashing name gets a suffix. + assert_eq!(names, ["Good", "good (2)", "Tenth"]); + assert!(back.names.is_empty(), "{:?}", names_of(&back)); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].starts_with("2 macros"), "{warnings:?}"); + + // The JSON format: more than the limit, and one over the step limit. + let mut wb = Workbook::new(); + wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect(); + wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1]; + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap(); + // Only the first MAX_MACROS entries are read, as in xlsx. + assert_eq!(back.macros.len(), MAX_MACROS - 1); + assert_eq!(back.macros[0].name, "M1"); + assert!(warnings[0].starts_with("6 macros"), "{warnings:?}"); + + // A macro too large for the hidden names is refused, so a save never fails on it. + let huge = "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS); + let err = parse_macro(&json!({"name": "Huge", "steps": [{"command": "cell.set", "params": {"input": huge}}]})).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() }); + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap(); + assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}"); +} + +#[test] +fn names() { + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "Macro1".into(), ..Macro::default() }); + assert_eq!(unique_name(&wb, "macro1"), "macro1 (2)"); + assert_eq!(unique_name(&wb, "Other"), "Other"); + assert_eq!(unique_name(&wb, ""), "Macro"); + // A long name is shortened to make room for the suffix. + let long = "y".repeat(MAX_MACRO_NAME); + wb.macros.push(Macro { name: long.clone(), ..Macro::default() }); + let renamed = unique_name(&wb, &long); + assert_eq!(renamed, format!("{} (2)", "y".repeat(MAX_MACRO_NAME - 4))); + wb.macros.push(Macro { name: renamed, ..Macro::default() }); + assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME); + assert_eq!(find(&wb, " MACRO1 "), Some(0)); + assert_eq!(find(&wb, "Macro2"), None); + let a = sample_macro("A"); + let mut b = a.clone(); + assert_eq!(fingerprint(&a), fingerprint(&b)); + b.steps[0].note = "changed".into(); + assert_ne!(fingerprint(&a), fingerprint(&b)); + assert_eq!(next_name(&wb), "Macro2"); + let paths: Vec<(String, &str)> = command_steps(&a.steps).into_iter().map(|(p, id, _)| (p, id)).collect(); + assert_eq!(paths, [("1".into(), "cell.set"), ("2.1.then.1".into(), "home.bold"), ("3".into(), "home.italic")]); +} + +#[test] +fn json_field_macros_get_the_name_check_too() { + // A JSON workbook can't bring in a name that macro.save and macro.rename refuse. + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() }); + wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() }); + // Written as another program would: a GridCraft save refuses a macro that fails `check`. + let bytes = serde_json::to_vec(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Fine"]); + assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro "), "{warnings:?}"); +} + +#[test] +fn duplicate_names_at_the_length_limit_survive_save_and_reopen() { + // Two macros with the same 255-character name: the second is renamed within the limit, so a + // save and a second open keep both. + let long = "é".repeat(MAX_MACRO_NAME); + for file in ["dup.json", "dup.xlsx"] { + let mut wb = Workbook::new(); + wb.macros.push(Macro { name: long.clone(), ..Macro::default() }); + wb.macros.push(Macro { name: long.clone(), description: "second".into(), ..Macro::default() }); + let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(first.macros.len(), 2, "{file}"); + let renamed = &first.macros[1].name; + assert_eq!(renamed.chars().count(), MAX_MACRO_NAME, "{file}"); + assert!(renamed.ends_with(" (2)") && renamed.starts_with("éé"), "{file}: {renamed}"); + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(again.macros, first.macros, "{file}"); + } +} + +#[test] +#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm +fn many_hidden_macro_names_are_read_in_one_pass() { + // A hostile file with many damaged macro items: only the first MAX_MACROS are read, and the + // names are gathered and removed in one pass instead of one scan of every name per item. + let mut wb = Workbook::new(); + let items = 20_000; + for i in 0..items { + wb.names.push(hidden(&format!("_gc_macro_{i}_0"), "{not json")); + } + wb.names.push(hidden("_gc_other_0", "kept")); + let start = std::time::Instant::now(); + let warnings = load(&mut wb); + let took = start.elapsed(); + assert!(wb.macros.is_empty()); + assert_eq!(names_of(&wb), ["_gc_other_0"]); + assert!(warnings.len() == 1 && warnings[0].starts_with(&format!("{items} macros")), "{warnings:?}"); + assert!(took < std::time::Duration::from_secs(3), "{took:?}"); +} + +#[test] +fn a_damaged_json_macro_drops_alone() { + // One macro this build can't read (a newer step type, an unknown value, the wrong shape) is + // dropped with the usual warning; the workbook and its other macros open. + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(gridcraft_core::CellRef::new(0, 0), gridcraft_core::Value::text("kept")); + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + v["macros"] = json!([ + {"name": "Good"}, + {"name": "Newer", "steps": [{"type": "fromALaterBuild"}]}, + {"name": "Odd", "output": {"onConflict": "explode"}}, + {"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]}, + "not a macro" + ]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Good", "Short"]); + assert!(matches!(&back.macros[1].steps[0].kind, StepKind::Command { id, .. } if id == "cell.set")); + assert!(back.macros_read.is_empty()); + assert!(warnings.len() == 1 && warnings[0].starts_with("3 macros"), "{warnings:?}"); + assert_eq!(back.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0)), gridcraft_core::Value::text("kept")); +} + +#[test] +fn a_macro_in_a_newer_format_is_kept_unread_and_saved_back_unchanged() { + // A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field + // if it were read here, so it is never read: it is kept as the file held it, saved back + // unchanged, and the newer build gets it back whole. Version 1 and a missing version are + // read as before. + let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]}); + let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]}); + let missing = json!({"name": "Missing"}); + let err = parse_macro(&future).unwrap_err(); + assert!(err.contains("newer version") && err.contains("format 2"), "{err}"); + assert!(parse_macro(&json!({"name": "Huge", "v": u64::from(u32::MAX) + 1})).unwrap_err().contains("newer version")); + // A macro built in code goes through the same check on every path. + assert!(Macro { name: "Built".into(), v: MACRO_VERSION + 1, ..Macro::default() }.check_limits().is_err()); + let check = |file: &str, back: &Workbook, warnings: &[String]| { + let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, ["Current", "Missing"], "{file}"); + assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}"); + assert_eq!(*back.macros_unread, std::slice::from_ref(&future), "{file}"); + assert!( + warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version") && warnings[0].contains("unchanged"), + "{file}: {warnings:?}" + ); + // Saved and opened again, it is still there, whole, and the others are as they were. + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap(); + assert_eq!(warnings.len(), 1, "{file}: {warnings:?}"); + assert_eq!(again.macros, back.macros, "{file}"); + assert_eq!(again.macros_unread, back.macros_unread, "{file}"); + }; + // The JSON format's field. + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + v["macros"] = json!([future, current, missing]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + check("book.json", &back, &warnings); + let saved: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&back, "book.json").unwrap()).unwrap(); + assert!(saved["macros"].as_array().unwrap().contains(&future), "the file holds it as it was"); + // Hidden names in xlsx. + let mut wb = Workbook::new(); + for (i, m) in [&future, ¤t, &missing].into_iter().enumerate() { + wb.names.push(hidden(&format!("_gc_macro_{i}_0"), &m.to_string())); + } + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert!(back.names.is_empty(), "{:?}", names_of(&back)); + check("book.xlsx", &back, &warnings); +} + +#[test] +fn kept_macros_share_the_save_limits_and_the_numbering() { + // A newer macro too large for the file is left out (keeping it would make every xlsx save + // fail), and readable and unread macros together stay within the macro limit, readable ones + // first. Kept ones are numbered after the readable ones, around a person's visible name. + let newer = |name: &str| json!({"name": name, "v": MACRO_VERSION + 1}); + let mut big = newer("Big"); + big["pad"] = json!(long(crate::cmd::stash::MAX_ITEM_CHARS)); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + let mut list = vec![big, newer("Kept"), json!({"name": "Read"})]; + file["macros"] = json!(list); + let (mut wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(*wb.macros_unread, [newer("Kept")]); + assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}"); + wb.names.push(visible("_gc_macro_1_0")); + let (raw, back) = { + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0); + (raw, crate::io::open_bytes("book.xlsx", &bytes).unwrap().0) + }; + assert_eq!(raw, ["_gc_macro_1_0", "_gc_macro_0_0", "_gc_macro_2_0"]); + assert_eq!((back.macros.len(), back.macros_unread.len()), (1, 1)); + // Past the limit the rest are left out; and when a JSON file's list is full, an unread item + // it also holds in hidden names is the one left out: readable macros come first. + list = (0..MAX_MACROS).map(|i| json!({"name": format!("M{i}")})).collect(); + let mut with_item = Workbook::new(); + with_item.names.push(hidden("_gc_macro_0_0", &newer("InNames").to_string())); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&with_item, "book.json").unwrap()).unwrap(); + for extra in [None, Some(newer("Over"))] { + file["macros"] = json!(list.iter().cloned().chain(extra).collect::>()); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!((wb.macros.len(), wb.macros_unread.len()), (MAX_MACROS, 0)); + assert!(warnings.len() == 1 && warnings[0].contains("couldn't be opened"), "{warnings:?}"); + } +} + +#[test] +fn many_macros_with_one_name_all_survive_save_and_reopen() { + // MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)" + // to " (1000)" and the kept part of the name shrinks as they widen. + let long = "é".repeat(MAX_MACRO_NAME); + for file in ["same.json", "same.xlsx"] { + let mut wb = Workbook::new(); + wb.macros = (0..MAX_MACROS).map(|i| Macro { name: long.clone(), description: format!("{i}"), ..Macro::default() }).collect(); + let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(first.macros.len(), MAX_MACROS, "{file}"); + let names: std::collections::HashSet = first.macros.iter().map(|m| m.name.to_lowercase()).collect(); + assert_eq!(names.len(), MAX_MACROS, "{file}: every name is different"); + assert!(first.macros.iter().all(|m| m.name.chars().count() <= MAX_MACRO_NAME && m.check_limits().is_ok()), "{file}"); + assert_eq!(first.macros[0].name, long); + assert_eq!(first.macros[9].name, format!("{} (10)", "é".repeat(MAX_MACRO_NAME - 5))); + assert_eq!(first.macros[MAX_MACROS - 1].name, format!("{} (1000)", "é".repeat(MAX_MACRO_NAME - 7))); + assert!(first.macros.iter().enumerate().all(|(i, m)| m.description == format!("{i}")), "{file}: order kept"); + let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{file}: {warnings:?}"); + assert_eq!(again.macros, first.macros, "{file}"); + } +} + +#[test] +fn naming_clashing_macros_takes_linear_work() { + // Each suffix is looked up about once, not once per earlier macro with the same name. + let mut taken = Taken::default(); + // Names a file may already use, in the way of the first suffixes. + for n in 2..=500 { + taken.insert(&format!("Same ({n})")); + } + let mut out = Vec::new(); + for _ in 0..MAX_MACROS { + let name = taken.unique("SAME"); + taken.insert(&name); + out.push(name); + } + assert_eq!(out[..3], ["SAME", "SAME (501)", "SAME (502)"]); + assert!(taken.probes <= 3 * MAX_MACROS, "{} lookups", taken.probes); + // The smallest free suffix is still the one used. + let mut taken = Taken::default(); + for name in ["A", "A (3)"] { + taken.insert(name); + } + assert_eq!(taken.unique("a"), "a (2)"); + taken.insert("a (2)"); + assert_eq!(taken.unique("a"), "a (4)"); +} + +#[test] +fn a_json_workbook_with_no_sheets_keeps_its_macros() { + // A JSON file with an empty sheet list opens as a new workbook; its macros come along. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap(); + v["sheets"] = json!([]); + let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.sheets.len(), 1); + assert_eq!(back.macros, wb.macros); +} + +fn long(n: usize) -> String { + "x".repeat(n) +} + +/// `sample_macro` as JSON with `change` made to it. +fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value { + let mut v = macro_json(&sample_macro("T")); + change(&mut v); + v +} + +#[test] +fn every_field_a_file_sets_has_a_limit() { + // A named edit to the sample file's JSON. + type Edit = (&'static str, fn(&mut serde_json::Value)); + // The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop. + let refused: [Edit; 14] = [ + ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))), + ("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")), + ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])), + ("permission text", |v| v["permissions"] = json!(["data getData"])), + ("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF + 1))), + ("default range", |v| v["inputs"][0]["defaultRef"] = json!(long(MAX_MACRO_REF + 1))), + ("column types", |v| { + v["inputs"][0]["cols"] = json!([1, 0]); + v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS + 1]); + }), + ("command id", |v| v["steps"][0]["id"] = json!("cell set")), + ("empty command id", |v| v["steps"][0]["id"] = json!("")), + ("Repeat range", |v| v["steps"][1]["over"] = json!(long(MAX_MACRO_REF + 1))), + ("If cell", |v| v["steps"][1]["body"][0]["cell"] = json!(long(MAX_MACRO_REF + 1))), + // As JSON the string takes its two quotes as well. + ("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 1))), + ("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT + 1))), + ("Stop message, nested", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT * 2))), + ]; + for (what, change) in refused { + assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused"); + } + let accepted: [Edit; 9] = [ + ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))), + ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])), + ("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))), + ("column types", |v| { + v["inputs"][0]["cols"] = json!([1, 0]); + v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS]); + }), + ("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 2))), + // Empty and NotEmpty ignore the value, which is then left out (null). + ("If with no value", |v| { + v["steps"][1]["body"][0]["op"] = json!("empty"); + v["steps"][1]["body"][0].as_object_mut().unwrap().remove("value"); + }), + // How many times a Repeat may run is the runner's to bound, not the format's. + ("Repeat max", |v| v["steps"][1]["max"] = json!(u32::MAX)), + ("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT))), + ("no shortcut", |v| { + v.as_object_mut().unwrap().remove("shortcut"); + }), + ]; + for (what, change) in accepted { + assert!(parse_macro(&changed(change)).is_ok(), "{what} at its limit is accepted"); + } + // Every command there is passes as a step and as a permission. + for spec in crate::command_specs() { + let m = json!({"name": "T", "steps": [{"command": spec.id}], "permissions": [spec.id]}); + assert!(parse_macro(&m).is_ok(), "{}", spec.id); + } +} + +#[test] +fn an_input_declaration_is_checked_as_a_whole() { + let input = |i: serde_json::Value| json!({"name": "T", "inputs": [i]}); + for i in [ + json!({"name": "Input", "rows": [5, 2]}), + json!({"name": "Input", "cols": [3, 2]}), + json!({"name": "Input", "kind": "cell", "cols": [2, 5]}), + json!({"name": "Input", "kind": "cell", "rows": [0, 0]}), + json!({"name": "Input", "kind": "cell", "header": true}), + json!({"name": "Input", "kind": "cell", "colTypes": ["number", "text"]}), + json!({"name": "Input", "cols": [2, 2], "colTypes": ["number", "text", "any"]}), + ] { + assert!(parse_macro(&input(i.clone())).is_err(), "{i}"); + } + for i in [ + json!({"name": "Input", "rows": [5, 0]}), + json!({"name": "Input", "rows": [2, 2], "cols": [0, 3]}), + json!({"name": "Input", "kind": "cell"}), + json!({"name": "Input", "kind": "cell", "rows": [1, 1], "cols": [1, 1], "colTypes": ["number"]}), + json!({"name": "Input", "cols": [1, 2], "colTypes": ["number", "text"]}), + ] { + assert!(parse_macro(&input(i.clone())).is_ok(), "{i}"); + } + // From a file, a macro whose declaration fails is left out with the usual warning, and the + // workbook's other macros open. + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap(); + file["macros"] = json!([input(json!({"name": "Input", "rows": [5, 2]})), {"name": "Fine"}]); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fine"]); + assert_eq!(warnings.len(), 1, "{warnings:?}"); +} + +#[test] +fn the_storage_rules_hold_together_through_both_formats() { + // A visible name of the storage shape, a macro in a newer format and one whose input breaks + // the declaration rules, in one file, saved and opened again in each format and then the + // other: the visible name stays, the newer macro comes back whole, the broken one is left out + // once, and the readable one survives. + let newer = json!({"name": "Kept", "v": MACRO_VERSION + 1, "fromALaterBuild": true}); + let broken = json!({"name": "Broken", "inputs": [{"name": "Input", "rows": [5, 2]}]}); + let mut start = Workbook::new(); + start.names.push(visible("_gc_macro_0_0")); + let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&start, "book.json").unwrap()).unwrap(); + file["macros"] = json!([newer, broken, {"name": "Good"}]); + let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap(); + assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}"); + let expect = |wb: &Workbook, how: &str| { + assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Good"], "{how}"); + assert_eq!(*wb.macros_unread, std::slice::from_ref(&newer), "{how}"); + assert_eq!(names_of(wb), ["_gc_macro_0_0"], "{how}"); + }; + expect(&wb, "opened"); + for formats in [["book.xlsx", "book.json"], ["book.json", "book.xlsx"]] { + let mut now = wb.clone(); + for file in formats { + let (back, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&now, file).unwrap()).unwrap(); + assert_eq!(warnings.len(), 1, "{file}: only the kept macro is reported again: {warnings:?}"); + expect(&back, file); + now = back; + } + } + let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap(); + assert_eq!(names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0), ["_gc_macro_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]); +} diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index e541199d9..1e76c1215 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -18,6 +18,21 @@ use gridcraft_model::{ use crate::pivot_calc::CalcSet; +mod charts; +mod compute; +mod items; +mod output; + +use charts::rebind_charts; +pub use charts::{chart_link, chart_series, clear_output, links_to, parse_chart_link}; +use compute::compute_from; +pub use compute::{NOT_A_VALUE_CELL, detail_cells, detail_records}; +pub use items::{distinct_labels, func_caption, item_label}; +#[cfg(test)] +use output::data_cols_exceed_sheet; +pub use output::{CellKind, OutCell, Output, RowKind}; +use output::{cell_style, look}; + /// Largest source block read (cells). pub const MAX_SOURCE_CELLS: u64 = 10_000_000; /// Largest report written (cells). @@ -171,1267 +186,12 @@ pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result, - index: HashMap, -} - -fn cmp_items(a: &Item, b: &Item) -> Ordering { - a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text)) -} - -/// (identity key, item) for a source value under a field's date grouping. -fn make_item(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> (String, Item) { - let it = |value: Value, label: String, class: u8, num: f64, formatted: bool| Item { - text: label.to_lowercase(), - value, - label, - class, - num, - first, - formatted, - }; - match v { - Value::Empty => ("b".into(), it(Value::text("(blank)"), "(blank)".into(), 4, 0.0, false)), - Value::Number(n) => { - if group != PivotDateGroup::None - && let Some(dt) = datetime_from_serial(wb.date_system, *n) - { - let mon = MONTHS.get(dt.month.saturating_sub(1) as usize).copied().unwrap_or("Jan"); - let (label, key) = match group { - PivotDateGroup::Years => (dt.year.to_string(), dt.year as f64), - PivotDateGroup::Quarters => { - let q = (dt.month.saturating_sub(1)) / 3 + 1; - (format!("Qtr{q}"), q as f64) - } - PivotDateGroup::Months => (mon.to_string(), dt.month as f64), - _ => (format!("{}-{mon}", dt.day), (dt.month * 100 + dt.day) as f64), - }; - return (format!("g{key}"), it(Value::text(label.as_str()), label, 0, key, false)); - } - let label = crate::display::format(v, fmt, wb).text; - (format!("n{}", n.to_bits()), it(v.clone(), label, 0, *n, true)) - } - Value::Text(t) => (format!("t{}", t.to_lowercase()), it(v.clone(), t.to_string(), 1, 0.0, false)), - Value::Bool(b) => { - let l = if *b { "TRUE" } else { "FALSE" }; - (format!("l{l}"), it(Value::Bool(*b), l.into(), 2, *b as u8 as f64, false)) - } - Value::Error(e) => (format!("e{}", e.as_str()), it(v.clone(), e.as_str().into(), 3, 0.0, false)), - Value::Array(_) => make_item(wb, &v.scalar(), group, fmt, first), - } -} - -impl FieldItems { - fn intern(&mut self, wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> u32 { - let (key, item) = make_item(wb, v, group, fmt, first); - if let Some(i) = self.index.get(&key) { - return *i; - } - let i = self.items.len() as u32; - self.items.push(item); - self.index.insert(key, i); - i - } - fn get(&self, i: u32) -> Option<&Item> { - self.items.get(i as usize) - } - fn label(&self, i: u32) -> String { - self.get(i).map(|x| x.label.clone()).unwrap_or_default() - } -} - -/// The label of a source value under a date grouping (for filters and field lists). -pub fn item_label(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str) -> String { - make_item(wb, v, group, fmt, 0).1.label -} - -/// Distinct item labels of a source column, sorted like the field list shows them. -pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDateGroup, limit: usize) -> Vec { - let mut fi = FieldItems::default(); - let fmt = src.formats.get(col).map(String::as_str).unwrap_or("General"); - for (i, r) in src.rows.iter().enumerate() { - if let Some(v) = r.get(col) { - fi.intern(wb, v, group, fmt, i); - } - if fi.items.len() > 100_000 { - break; - } - } - let mut items = fi.items; - items.sort_by(cmp_items); - items.into_iter().take(limit).map(|i| i.label).collect() -} - -// ---------------------------------------------------------------- aggregation - -#[derive(Clone, Debug, Default)] -struct Acc { - count: u64, - nums: u64, - sum: f64, - prod: f64, - min: f64, - max: f64, - mean: f64, - m2: f64, - err: Option, -} - -impl Acc { - fn add(&mut self, v: &Value) { - match v { - Value::Empty => {} - Value::Number(n) => { - let n = *n; - self.count += 1; - self.nums += 1; - self.sum += n; - if self.nums == 1 { - self.prod = n; - self.min = n; - self.max = n; - } else { - self.prod *= n; - self.min = self.min.min(n); - self.max = self.max.max(n); - } - let d = n - self.mean; - self.mean += d / self.nums as f64; - self.m2 += d * (n - self.mean); - } - Value::Error(e) => { - self.count += 1; - if self.err.is_none() { - self.err = Some(*e); - } - } - _ => self.count += 1, - } - } - fn result(&self, f: PivotFunc) -> Value { - let n = self.nums as f64; - match f { - PivotFunc::Count => return Value::number(self.count as f64), - PivotFunc::CountNumbers => return Value::number(n), - _ => {} - } - if let Some(e) = self.err { - return Value::Error(e); - } - let div0 = Value::Error(CellError::Div0); - match f { - PivotFunc::Sum => Value::number(self.sum), - PivotFunc::Average => { - if self.nums == 0 { - div0 - } else { - Value::number(self.sum / n) - } - } - PivotFunc::Max => Value::number(if self.nums == 0 { 0.0 } else { self.max }), - PivotFunc::Min => Value::number(if self.nums == 0 { 0.0 } else { self.min }), - PivotFunc::Product => Value::number(if self.nums == 0 { 0.0 } else { self.prod }), - PivotFunc::StdDev | PivotFunc::Var => { - if self.nums < 2 { - div0 - } else { - let var = (self.m2 / (n - 1.0)).max(0.0); - Value::number(if f == PivotFunc::Var { var } else { var.sqrt() }) - } - } - PivotFunc::StdDevP | PivotFunc::VarP => { - if self.nums < 1 { - div0 - } else { - let var = (self.m2 / n).max(0.0); - Value::number(if f == PivotFunc::VarP { var } else { var.sqrt() }) - } - } - PivotFunc::Count | PivotFunc::CountNumbers => Value::Empty, - } - } -} - -/// Excel's caption verb for a function ("Sum of Sales"). -pub fn func_caption(f: PivotFunc) -> &'static str { - match f { - PivotFunc::Sum => "Sum", - PivotFunc::Count => "Count", - PivotFunc::Average => "Average", - PivotFunc::Max => "Max", - PivotFunc::Min => "Min", - PivotFunc::Product => "Product", - PivotFunc::CountNumbers => "Count Numbers", - PivotFunc::StdDev => "StdDev", - PivotFunc::StdDevP => "StdDevp", - PivotFunc::Var => "Var", - PivotFunc::VarP => "Varp", - } -} - -// ---------------------------------------------------------------- trees - -#[derive(Clone, Debug)] -struct Node { - item: u32, - children: Vec, -} - -fn build_tree(paths: &[&[u32]], depth: usize, fields: &[&PivotField], items: &[FieldItems]) -> Vec { - let (Some(field), Some(fi)) = (fields.get(depth), items.get(depth)) else { return vec![] }; - let mut order: Vec = Vec::new(); - let mut groups: HashMap> = HashMap::new(); - for p in paths { - if let Some(&it) = p.get(depth) { - let g = groups.entry(it).or_default(); - if g.is_empty() { - order.push(it); - } - g.push(p); - } - } - let mut nodes: Vec = order - .into_iter() - .map(|it| { - let ps = groups.remove(&it).unwrap_or_default(); - Node { item: it, children: build_tree(&ps, depth + 1, fields, items) } - }) - .collect(); - let by = |a: &Node, b: &Node| match (fi.get(a.item), fi.get(b.item)) { - (Some(x), Some(y)) => match field.sort { - PivotSort::Asc => cmp_items(x, y), - PivotSort::Desc => cmp_items(y, x), - PivotSort::None => x.first.cmp(&y.first), - }, - _ => Ordering::Equal, - }; - nodes.sort_by(by); - nodes -} - -fn is_collapsed(field: &PivotField, label: &str) -> bool { - field.collapsed_items.iter().any(|c| c.eq_ignore_ascii_case(label)) -} - -fn collect_siblings(nodes: &[Node], prefix: &mut Vec, out: &mut HashMap, Vec>) { - out.insert(prefix.clone(), nodes.iter().map(|n| n.item).collect()); - for n in nodes { - prefix.push(n.item); - collect_siblings(&n.children, prefix, out); - prefix.pop(); - } -} - -// ---------------------------------------------------------------- output - -/// What a report cell is. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -pub enum CellKind { - Empty, - Header, - RowLabel, - ColLabel, - Data, - Subtotal, - GrandTotal, - Filter, - Placeholder, -} - -/// What a report row is (for row-wide styling). -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -pub enum RowKind { - Filter, - Blank, - Header, - /// Item row; `true` for a parent item (bold). - Item(bool), - Subtotal, - Grand, - Placeholder, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct OutCell { - pub value: Value, - pub kind: CellKind, - pub indent: u8, - pub num_fmt: Option, -} - -impl OutCell { - fn empty() -> OutCell { - OutCell { value: Value::Empty, kind: CellKind::Empty, indent: 0, num_fmt: None } - } -} - -/// A computed report: a dense grid relative to its top-left, with row kinds. -#[derive(Clone, Debug)] -pub struct Output { - pub grid: Vec>, - pub row_kinds: Vec, - /// Rows above the body (report filters plus a blank row). - pub body_offset: u32, - pub width: u32, - /// Grid rows of the innermost (or collapsed) items: a PivotChart's categories. - pub chart_rows: Vec, - /// Grid columns of item (not total) values with their series names: a PivotChart's series. - pub chart_cols: Vec<(u32, String)>, - /// Grid column holding the innermost row labels (`None` without row fields). - pub label_col: Option, -} - -impl Output { - pub fn height(&self) -> u32 { - self.grid.len() as u32 - } - /// Value at (row, col) relative to the top-left. - pub fn value(&self, r: usize, c: usize) -> Value { - self.grid.get(r).and_then(|row| row.get(c)).map(|x| x.value.clone()).unwrap_or_default() - } -} - -struct Grid { - rows: Vec>, - kinds: Vec, - width: usize, -} - -impl Grid { - fn push_row(&mut self, kind: RowKind) -> Result { - if (self.rows.len() as u64 + 1) * self.width.max(1) as u64 > MAX_OUTPUT_CELLS || self.rows.len() as u64 >= MAX_ROWS as u64 { - return Err("The PivotTable report is too large.".into()); - } - self.rows.push(vec![OutCell::empty(); self.width]); - self.kinds.push(kind); - Ok(self.rows.len() - 1) - } - fn set(&mut self, r: usize, c: usize, value: Value, kind: CellKind, indent: u8, fmt: Option) { - if let Some(cell) = self.rows.get_mut(r).and_then(|row| row.get_mut(c)) { - *cell = OutCell { value, kind, indent, num_fmt: fmt }; - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum LineKind { - Item(bool), - Subtotal, - Grand, -} - -struct Line { - kind: LineKind, - /// A category of a PivotChart: an innermost (or collapsed) item. - leaf: bool, - path: Vec, - values: bool, - /// (column, value, indent, number format) - labels: Vec<(usize, Value, u8, Option)>, - /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). - vi: Option, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ColKind { - Leaf, - Subtotal, - Grand, -} - -struct ColSpec { - kind: ColKind, - path: Vec, -} - -struct Ctx<'a> { - pt: &'a PivotTable, - rows: Vec<&'a PivotField>, - row_items: Vec, - row_fmts: Vec, - /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). - vpos: Option, - /// Value field captions. - val_names: Vec, -} - -impl Ctx<'_> { - fn label_cell(&self, depth: usize, item: u32) -> (Value, Option, String) { - let Some(it) = self.row_items.get(depth).and_then(|fi| fi.get(item)) else { return (Value::Empty, None, String::new()) }; - let fmt = if it.formatted { self.row_fmts.get(depth).cloned() } else { None }; - (it.value.clone(), fmt, it.label.clone()) - } - - /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. - fn level_cell(&self, level: usize) -> (usize, u8) { - if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } - } - - /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). - fn values_here(&self, depth: usize, vi: Option) -> bool { - vi.is_none() && self.vpos == Some(depth) - } - - /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). - fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { - for (vi, name) in self.val_names.iter().enumerate() { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.to_vec(), - values: true, - labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], - vi: Some(vi), - }); - } - } - - /// The Σ Values level at row field depth `depth`: one item per value field, each followed by - /// the row fields below it (`nodes`) or, innermost, holding that value. - fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { - let (col, indent) = self.level_cell(depth); - for (vi, name) in self.val_names.iter().enumerate() { - let labels = vec![(col, Value::text(name.as_str()), indent, None)]; - let leaf = nodes.is_empty(); - out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); - if !leaf { - self.flatten(nodes, depth, Some(vi), prefix, out); - } - } - } - - fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { - if self.values_here(depth, vi) { - self.flatten_values(nodes, depth, prefix, out); - return; - } - let n = self.rows.len(); - // Σ Values still to come below this level: totals need one line per value field. - let values_below = vi.is_none() && self.vpos.is_some(); - let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); - for node in nodes { - prefix.push(node.item); - let (v, fmt, label) = self.label_cell(depth, node.item); - let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); - if leaf && !values_below { - out.push(Line { - kind: LineKind::Item(depth + 1 < n), - leaf: true, - path: prefix.clone(), - values: true, - labels: vec![(col, v, indent, fmt)], - vi, - }); - } else if leaf { - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: false, - labels: vec![(col, v, indent, fmt)], - vi, - }); - // Outline gives each level its own column, so the captions go in the Values - // column even when the levels between are collapsed away. - let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; - self.flatten_values(&[], level, prefix, out); - } else if values_below { - // Subtotals per value field, below the group (one line can't hold them all). - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: false, - labels: vec![(col, v, indent, fmt)], - vi, - }); - self.flatten(&node.children, depth + 1, None, prefix, out); - if self.pt.subtotals { - self.value_subtotals(col, indent, &label, prefix, out); - } - } else { - let top = self.pt.subtotals && self.pt.subtotals_top; - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: top, - labels: vec![(col, v, indent, fmt)], - vi, - }); - self.flatten(&node.children, depth + 1, vi, prefix, out); - if self.pt.subtotals && !self.pt.subtotals_top { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.clone(), - values: true, - labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], - vi, - }); - } - } - prefix.pop(); - } - } - - /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. - fn flatten_tabular_values( - &self, - nodes: &[Node], - depth: usize, - prefix: &mut Vec, - pending: &[(usize, Value, u8, Option)], - out: &mut Vec, - ) { - for (vi, name) in self.val_names.iter().enumerate() { - let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; - labels.push((depth, Value::text(name.as_str()), 0, None)); - if nodes.is_empty() { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); - } else { - self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); - } - } - } - - fn flatten_tabular( - &self, - nodes: &[Node], - depth: usize, - vi: Option, - prefix: &mut Vec, - pending: &[(usize, Value, u8, Option)], - out: &mut Vec, - ) { - if self.values_here(depth, vi) { - self.flatten_tabular_values(nodes, depth, prefix, pending, out); - return; - } - let n = self.rows.len(); - let values_below = vi.is_none() && self.vpos.is_some(); - let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); - for (i, node) in nodes.iter().enumerate() { - prefix.push(node.item); - let (v, fmt, label) = self.label_cell(depth, node.item); - let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; - labels.push((col, v, 0, fmt)); - let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { - if values_below { - self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); - } else { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); - } - } else { - self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); - if self.pt.subtotals && values_below { - self.value_subtotals(col, 0, &label, prefix, out); - } else if self.pt.subtotals { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.clone(), - values: true, - labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], - vi, - }); - } - } - prefix.pop(); - } - } -} - -fn flatten_cols( - nodes: &[Node], - depth: usize, - fields: &[&PivotField], - items: &[FieldItems], - subtotals: bool, - prefix: &mut Vec, - out: &mut Vec, -) { - let n = fields.len(); - for node in nodes { - prefix.push(node.item); - let label = items.get(depth).map(|fi| fi.label(node.item)).unwrap_or_default(); - let collapsed = depth + 1 < n && fields.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { - out.push(ColSpec { kind: ColKind::Leaf, path: prefix.clone() }); - } else { - flatten_cols(&node.children, depth + 1, fields, items, subtotals, prefix, out); - if subtotals { - out.push(ColSpec { kind: ColKind::Subtotal, path: prefix.clone() }); - } - } - prefix.pop(); - } -} - -type AggMap = HashMap<(Vec, Vec), Vec>; - -/// Where a value field's numbers come from. -#[derive(Clone, Copy)] -enum ValSrc { - /// A source column. - Col(usize), - /// A calculated field (index into the PivotTable's calculated fields). - Calc(usize), -} - -/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields -/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. -fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { - (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize -} - /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; compute_from(wb, pt, &src, None) } -/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end -/// edits that cell instead). -pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; - -/// Show Details: the source list and the indices of its records behind the value cell at -/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when -/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report -/// on the sheet is no longer what the source computes: the cell's records would be another cell's. -pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { - let src = read_source(wb, &pt.source, pivot_sheet)?; - let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None }; - let out = compute_from(wb, pt, &src, Some(&mut probe))?; - // Checked first: in a stale report even a cell that computes as a label may show a value. - check_current(wb, pt, pivot_sheet, &out)?; - let Some((rp, cp)) = probe.hit else { return Ok(None) }; - let records = - probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); - Ok(Some((src, records))) -} - -/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds -/// records: its value, subtotal and grand total cells, from one computation of the report. The -/// same errors as [`detail_records`]. -pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, String> { - let src = read_source(wb, &pt.source, pivot_sheet)?; - let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) }; - let out = compute_from(wb, pt, &src, Some(&mut probe))?; - check_current(wb, pt, pivot_sheet, &out)?; - Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect()) -} - -/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable -/// cache; without one, the report written by the last refresh must still be what the source gives. -fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> { - let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; - let current = pt.last_range.is_some_and(|r| { - r.height() == out.height().max(1) - && r.width() == out.width.max(1) - && out.grid.iter().enumerate().all(|(ri, line)| { - line.iter().enumerate().all(|(ci, oc)| { - sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value - }) - }) - }); - if !current { - return Err("The PivotTable report is out of date. Refresh it first.".into()); - } - Ok(()) -} - -/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column -/// item paths of the value cell at `at`, the paths of every record the filters let through (when -/// `paths` is set), and every value cell (when `cells` is set). -struct Probe { - at: Option<(usize, usize)>, - hit: Option<(Vec, Vec)>, - paths: Option, Vec)>>, - cells: Option>, -} - -fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { - let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); - let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); - let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); - // Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`). - let calc = CalcSet::new(pt, &src.headers); - let vals: Vec<(ValSrc, &PivotValue)> = pt - .values - .iter() - .filter_map(|v| match src.col(&v.source_col) { - Some(c) => Some((ValSrc::Col(c), v)), - None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)), - }) - .collect(); - // Source columns summed for the calculated fields, accumulated after the value fields. - let mut extra: Vec = Vec::new(); - for (vs, _) in &vals { - if let ValSrc::Calc(i) = vs { - for name in calc.source_refs(*i) { - if let Some(c) = src.col(&name) - && !extra.contains(&c) - { - extra.push(c); - } - } - } - } - if rows.len() + cols.len() > MAX_AXIS_FIELDS { - return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields.")); - } - if vals.len() > MAX_VALUE_FIELDS { - return Err(format!("A PivotTable can have at most {MAX_VALUE_FIELDS} value fields.")); - } - let axis_group = |name: &str| -> Option { - pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group) - }; - // (column, filter, grouping used for labels, shown as a report filter) - let filters: Vec<(usize, &PivotFilter, PivotDateGroup, bool)> = pt - .filters - .iter() - .filter_map(|f| { - let c = src.col(&f.source_col)?; - let g = axis_group(&f.source_col); - Some((c, f, g.unwrap_or_default(), g.is_none())) - }) - .collect(); - let shown_filters: Vec<&(usize, &PivotFilter, PivotDateGroup, bool)> = filters.iter().filter(|f| f.3).collect(); - - let k = vals.len(); - let empty = rows.is_empty() && cols.is_empty() && vals.is_empty(); - // Σ Values in Rows (it exists with two or more value fields): its depth among the row fields - // found in the source. - let vpos: Option = (pt.values_on_rows && k > 1).then(|| { - let p = pt.values_position.unwrap_or(pt.rows.len()); - pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() - }); - let on_rows = vpos.is_some(); - let row_levels = rows.len() + usize::from(on_rows); - - // ---- aggregate - let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); - let mut col_items: Vec = cols.iter().map(|_| FieldItems::default()).collect(); - let mut aggs: AggMap = HashMap::new(); - let mut row_set: HashSet> = HashSet::new(); - let mut col_set: HashSet> = HashSet::new(); - let mut col_leaves: HashSet> = HashSet::new(); - let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); - let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); - let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); - 'rec: for (ri, rec) in src.rows.iter().enumerate() { - for ((c, f, g, _), fmt) in filters.iter().zip(filter_fmts.iter()) { - if let Some(sel) = &f.selected { - let label = item_label(wb, rec.get(*c).unwrap_or(&Value::Empty), *g, fmt); - if !sel.iter().any(|s| s.eq_ignore_ascii_case(&label)) { - continue 'rec; - } - } - } - if empty { - continue; - } - let mut rp = Vec::with_capacity(rows.len()); - for (((c, f), fi), fmt) in rows.iter().zip(row_items.iter_mut()).zip(row_fmts.iter()) { - rp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); - } - let mut cp = Vec::with_capacity(cols.len()); - for (((c, f), fi), fmt) in cols.iter().zip(col_items.iter_mut()).zip(col_fmts.iter()) { - cp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); - } - for a in 0..=rp.len() { - for b in 0..=cp.len() { - let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]); - for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) { - if let ValSrc::Col(c) = vs { - acc.add(rec.get(*c).unwrap_or(&Value::Empty)); - } - } - for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) { - acc.add(rec.get(*c).unwrap_or(&Value::Empty)); - } - } - } - // The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`). - let shown = cols - .iter() - .zip(col_items.iter()) - .zip(cp.iter()) - .position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it))) - .map_or(cp.len(), |d| d + 1); - if !col_leaves.contains(&cp[..shown]) { - col_leaves.insert(cp[..shown].to_vec()); - } - if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) { - paths.push((ri, rp.clone(), cp.clone())); - } - row_set.insert(rp); - col_set.insert(cp); - // Refuse a report that cannot fit before the accumulators for more column groups are allocated. - if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) { - return Err("The PivotTable report will not fit on the sheet.".into()); - } - if aggs.len() > 5_000_000 { - return Err("The PivotTable report is too large.".into()); - } - } - - // ---- axes - let row_fields: Vec<&PivotField> = rows.iter().map(|(_, f)| *f).collect(); - let col_fields: Vec<&PivotField> = cols.iter().map(|(_, f)| *f).collect(); - let row_paths: Vec<&[u32]> = row_set.iter().map(Vec::as_slice).collect(); - let col_paths: Vec<&[u32]> = col_set.iter().map(Vec::as_slice).collect(); - let row_tree = build_tree(&row_paths, 0, &row_fields, &row_items); - let col_tree = build_tree(&col_paths, 0, &col_fields, &col_items); - let mut siblings: HashMap, Vec> = HashMap::new(); - collect_siblings(&row_tree, &mut Vec::new(), &mut siblings); - let level0: Vec = row_tree.iter().map(|n| n.item).collect(); - - let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); - let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; - let mut lines: Vec = Vec::new(); - if row_levels == 0 { - lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); - } else { - if pt.layout == PivotLayout::Tabular { - ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); - } else { - ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); - } - // With only Σ Values in Rows its items are the totals already. - if pt.grand_totals_cols && !rows.is_empty() { - let grand = |text: String, vi: Option| Line { - kind: LineKind::Grand, - leaf: false, - path: vec![], - values: true, - labels: vec![(0, Value::text(text), 0, None)], - vi, - }; - if on_rows { - lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); - } else { - lines.push(grand("Grand Total".into(), None)); - } - } - } - let mut specs: Vec = Vec::new(); - if cols.is_empty() { - specs.push(ColSpec { kind: ColKind::Leaf, path: vec![] }); - } else { - flatten_cols(&col_tree, 0, &col_fields, &col_items, pt.subtotals, &mut Vec::new(), &mut specs); - if pt.grand_totals_rows { - specs.push(ColSpec { kind: ColKind::Grand, path: vec![] }); - } - } - let mut data_cols: Vec<(usize, Option)> = Vec::new(); - for (si, _) in specs.iter().enumerate() { - if on_rows { - // Each line names its value field; one data column per column item. - data_cols.push((si, None)); - } else if k == 0 { - if !cols.is_empty() { - data_cols.push((si, None)); - } - } else { - for vi in 0..k { - data_cols.push((si, Some(vi))); - } - } - if data_cols.len() > MAX_COLS as usize { - return Err("The PivotTable report will not fit on the sheet.".into()); - } - } - - let compact = pt.layout == PivotLayout::Compact; - let l = if row_levels == 0 && cols.is_empty() { - 0 - } else if compact { - 1 - } else { - row_levels.max(1) - }; - let mut width = l + data_cols.len(); - if !shown_filters.is_empty() { - width = width.max(2); - } - if empty { - width = width.max(1); - } - let width = width.max(1); - let mut g = Grid { rows: vec![], kinds: vec![], width }; - - // ---- report filters - for (c, f, _, _) in &shown_filters { - let r = g.push_row(RowKind::Filter)?; - let name = src.headers.get(*c).cloned().unwrap_or_default(); - let shown = match &f.selected { - None => "(All)".to_string(), - Some(v) if v.len() == 1 => v.first().cloned().unwrap_or_default(), - Some(_) => "(Multiple Items)".to_string(), - }; - g.set(r, 0, Value::text(name), CellKind::Filter, 0, None); - g.set(r, 1, Value::text(shown), CellKind::Filter, 0, None); - } - if !shown_filters.is_empty() { - g.push_row(RowKind::Blank)?; - } - let body_offset = g.rows.len() as u32; - - if empty { - let r = g.push_row(RowKind::Placeholder)?; - g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None); - return Ok(Output { - width: g.width as u32, - grid: g.rows, - row_kinds: g.kinds, - body_offset, - chart_rows: vec![], - chart_cols: vec![], - label_col: None, - }); - } - - let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default(); - let headers_on = pt.show_headers; - let row_header = |g: &mut Grid, r: usize| { - if row_levels == 0 || !headers_on { - return; - } - if compact { - g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None); - } else { - // One column per row field, Σ Values ("Values") at its place. - let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect(); - if let Some(p) = vpos { - names.insert(p.min(names.len()), "Values"); - } - for (i, name) in names.into_iter().enumerate() { - g.set(r, i, Value::text(name), CellKind::Header, 0, None); - } - } - }; - - // ---- header rows - if cols.is_empty() { - let r = g.push_row(RowKind::Header)?; - row_header(&mut g, r); - for (j, (_, vi)) in data_cols.iter().enumerate() { - match vi { - Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), - // Values on rows, no column field: the one data column is the grand total. - None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), - None => {} - } - } - } else { - let cap = g.push_row(RowKind::Header)?; - if l > 0 && k == 1 { - g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None); - } - if headers_on { - let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() }; - g.set(cap, l, Value::text(caption), CellKind::Header, 0, None); - } - let levels = cols.len() + usize::from(k > 1 && !on_rows); - let first_level = g.rows.len(); - for _ in 0..levels { - g.push_row(RowKind::Header)?; - } - let mut prev: Option<&ColSpec> = None; - for (j, (si, vi)) in data_cols.iter().enumerate() { - let Some(spec) = specs.get(*si) else { continue }; - let c = l + j; - match spec.kind { - ColKind::Leaf => { - for (lv, &it) in spec.path.iter().enumerate() { - let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv)); - if !same { - let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue }; - let fmt = if item.formatted { cols.get(lv).map(|(cc, _)| fmt_of(*cc)) } else { None }; - g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt); - } - } - if k > 1 - && let Some(vi) = vi - { - g.set(first_level + cols.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None); - } - } - ColKind::Subtotal => { - let lv = spec.path.len().saturating_sub(1); - let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default(); - let text = match vi { - Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)), - _ => format!("{label} Total"), - }; - g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None); - } - ColKind::Grand => { - let text = match vi { - Some(vi) if k > 1 => format!("Total {}", val_name(*vi)), - _ => "Grand Total".to_string(), - }; - g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None); - } - } - prev = Some(spec); - } - let last = g.rows.len() - 1; - row_header(&mut g, last); - } - - // ---- body - let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option { - let (vs, v) = vals.get(vi)?; - let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?; - match vs { - ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)), - ValSrc::Calc(i) => { - let sum = |name: &str| -> Value { - src.col(name) - .and_then(|c| extra.iter().position(|x| *x == c)) - .and_then(|j| accs.get(k + j)) - .map(|a| a.result(PivotFunc::Sum)) - .unwrap_or(Value::Number(0.0)) - }; - Some(calc.value(wb, *i, &sum)) - } - } - }; - let num = |v: &Option| v.as_ref().and_then(Value::as_f64); - let ratio = |a: Value, b: Option| -> Value { - match (a, b) { - (Value::Error(e), _) => Value::Error(e), - (_, Some(Value::Error(e))) => Value::Error(e), - (Value::Number(x), Some(Value::Number(y))) => { - if y == 0.0 { - Value::Error(CellError::Div0) - } else { - Value::number(x / y) - } - } - (v, _) => v, - } - }; - let data_fmt: Vec> = vals - .iter() - .map(|(vs, v)| match v.show_as { - PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => { - Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into())) - } - PivotShowAs::Rank => v.number_format.clone(), - _ => v.number_format.clone().or_else(|| match v.func { - PivotFunc::Count | PivotFunc::CountNumbers => None, - _ => { - let f = match vs { - ValSrc::Col(c) => fmt_of(*c), - ValSrc::Calc(_) => "General".to_string(), - }; - (f != "General").then_some(f) - } - }), - }) - .collect(); - let mut chart_rows: Vec = Vec::new(); - let chart_cols: Vec<(u32, String)> = data_cols - .iter() - .enumerate() - .filter_map(|(j, (si, vi))| { - let spec = specs.get(*si)?; - if spec.kind != ColKind::Leaf { - return None; - } - let mut parts: Vec = - spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect(); - if let Some(vi) = vi - && (k > 1 || parts.is_empty()) - { - parts.push(val_name(*vi)); - } else if parts.is_empty() { - parts.push("Total".into()); - } - Some(((l + j) as u32, parts.join(" - "))) - }) - .filter(|_| k > 0) - .collect(); - let label_col = if row_levels == 0 { - None - } else if compact { - Some(0) - } else { - Some(row_levels.saturating_sub(1) as u32) - }; - for line in &lines { - let rk = match line.kind { - LineKind::Item(b) => RowKind::Item(b), - LineKind::Subtotal => RowKind::Subtotal, - LineKind::Grand => RowKind::Grand, - }; - let ck = match line.kind { - LineKind::Item(_) => (CellKind::RowLabel, CellKind::Data), - LineKind::Subtotal => (CellKind::Subtotal, CellKind::Subtotal), - LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal), - }; - let r = g.push_row(rk)?; - if line.leaf && k > 0 { - chart_rows.push(r as u32); - } - for (c, v, indent, fmt) in &line.labels { - g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone()); - } - if !line.values { - continue; - } - let rp = line.path.as_slice(); - for (j, (si, col_vi)) in data_cols.iter().enumerate() { - let line_vi = col_vi.or(line.vi); - let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue }; - let Some((_, vdef)) = vals.get(*vi) else { continue }; - let cp = spec.path.as_slice(); - let Some(raw) = get(rp, cp, *vi) else { continue }; - let shown = match vdef.show_as { - PivotShowAs::Normal => raw, - PivotShowAs::PercentOfGrandTotal => ratio(raw, get(&[], &[], *vi)), - PivotShowAs::PercentOfColumnTotal => ratio(raw, get(&[], cp, *vi)), - PivotShowAs::PercentOfRowTotal => ratio(raw, get(rp, &[], *vi)), - PivotShowAs::RunningTotal => match (rp.first(), &raw) { - (Some(first), Value::Number(_)) => { - let mut acc = 0.0; - for it in &level0 { - let mut p = rp.to_vec(); - if let Some(x) = p.first_mut() { - *x = *it; - } - acc += num(&get(&p, cp, *vi)).unwrap_or(0.0); - if it == first { - break; - } - } - Value::number(acc) - } - _ => raw, - }, - PivotShowAs::Rank => match (rp.split_last(), raw.as_f64()) { - (Some((_, parent)), Some(me)) => { - let sibs = siblings.get(parent).cloned().unwrap_or_default(); - let mut rank = 1u64; - for sib in sibs { - let mut p = parent.to_vec(); - p.push(sib); - if num(&get(&p, cp, *vi)).is_some_and(|x| x > me) { - rank += 1; - } - } - Value::number(rank as f64) - } - (None, _) => Value::Empty, - _ => raw, - }, - }; - if let Some(pr) = probe.as_deref_mut() { - if pr.at == Some((r, l + j)) { - pr.hit = Some((rp.to_vec(), cp.to_vec())); - } - if let Some(cells) = pr.cells.as_mut() { - cells.insert((r, l + j)); - } - } - g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); - } - } - Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col }) -} - -// ---------------------------------------------------------------- styling and writing - -/// Colours for a PivotTable style name (our own palette, keyed by the familiar -/// `PivotStyleLight1…28 / Medium1…28 / Dark1…28` names). -struct Look { - header_fill: Color, - header_font: Color, - line: Color, - filter_fill: Color, -} - -fn look(style: &str) -> Look { - let rest = style.strip_prefix("PivotStyle").unwrap_or(style); - let (fam, n) = ["Light", "Medium", "Dark"] - .iter() - .find_map(|f| rest.strip_prefix(f).map(|num| (*f, num.parse::().unwrap_or(16)))) - .unwrap_or(("Light", 16)); - let k = n.saturating_sub(1) % 7; - let acc = if k == 0 { None } else { Some(3 + k as u8) }; - let tone = |tint: i16| match acc { - Some(a) => Color::Theme(a, tint), - None => Color::Theme(1, tint.clamp(-1000, 1000)), - }; - match fam { - "Dark" => Look { header_fill: Color::Theme(1, 0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(600) }, - "Medium" => Look { header_fill: tone(0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(800) }, - _ => Look { header_fill: tone(800), header_font: Color::Auto, line: tone(400), filter_fill: tone(800) }, - } -} - -fn cell_style(lk: &Look, rk: RowKind, cell: &OutCell, last_header: bool) -> Style { - let mut s = Style::default(); - match rk { - RowKind::Header => { - s.font.bold = true; - s.fill = Fill::solid(lk.header_fill); - s.font.color = lk.header_font; - if last_header { - s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: lk.line }; - } - } - RowKind::Filter => { - s.fill = Fill::solid(lk.filter_fill); - s.font.bold = last_header; - } - RowKind::Item(parent) => s.font.bold = parent, - RowKind::Subtotal => s.font.bold = true, - RowKind::Grand => { - s.font.bold = true; - s.borders.top = BorderLine { style: BorderStyle::Thin, color: lk.line }; - } - RowKind::Placeholder => { - s.font.italic = true; - s.font.color = Color::Theme(1, 500); - } - RowKind::Blank => {} - } - if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel | CellKind::Subtotal | CellKind::GrandTotal) && !cell.value.is_number() { - s.align.h = HAlign::Left; - } - if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel) && cell.value.is_number() { - s.align.h = HAlign::Left; - } - s.align.indent = cell.indent.min(15); - if let Some(f) = &cell.num_fmt { - s.num_fmt = NumFmt::new(f); - } - s -} - /// Recomputes a pivot and writes its report. With `replace` false, refuses when the target area /// holds other data. Returns the written area. pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Result { @@ -1557,112 +317,6 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re Ok(area) } -// ---------------------------------------------------------------- PivotCharts - -/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`. -pub fn chart_link(sheet: &str, pivot: &str) -> String { - format!("{}!{pivot}", quote_sheet(sheet)) -} - -/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored. -pub fn parse_chart_link(link: &str) -> Option<(String, String)> { - let t = link.trim(); - let t = match t.strip_prefix('[') { - Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest), - None => t, - }; - let (sheet, name) = t.rsplit_once('!')?; - let sheet = sheet.trim(); - let sheet = gridcraft_core::unquote_sheet(sheet); - let name = name.trim(); - if sheet.is_empty() || name.is_empty() { - return None; - } - Some((sheet, name.to_string())) -} - -/// Whether a PivotChart link points at `pivot` on `sheet`. -pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool { - parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot)) -} - -/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union). -fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option { - let q = quote_sheet(sheet); - let c = origin.col.checked_add(col)?; - let letters = gridcraft_core::col_to_letters(c); - let mut parts: Vec = Vec::new(); - let mut i = 0; - while let Some(&start) = rows.get(i) { - let mut end = start; - while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) { - end += 1; - i += 1; - } - i += 1; - let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1); - parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") }); - } - match parts.len() { - 0 => None, - 1 => parts.pop(), - _ => Some(format!("({})", parts.join(","))), - } -} - -/// Most category rows a PivotChart plots. -const MAX_CHART_ROWS: usize = 10_000; - -/// A PivotChart's series for a report written at `origin`: categories are the innermost row -/// items, one series per item column (column items × value fields); totals are left out. -pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec { - let rows: Vec = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect(); - let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows)); - out.chart_cols - .iter() - .filter_map(|(c, name)| { - Some(Series { - name: Some(format!("\"{}\"", name.replace('"', "\"\""))), - categories: categories.clone(), - values: rows_ref(sheet, origin, *c, &rows)?, - bubble_sizes: None, - color: None, - secondary: false, - kind: None, - quartiles: Default::default(), - smooth: false, - trendlines: Vec::new(), - }) - }) - .collect() -} - -/// Rebuilds the series of every PivotChart of a PivotTable after its report was written. -fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) { - let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot)); - let hits: Vec = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect(); - if hits.is_empty() { - return; - } - let series = chart_series(sheet, origin, out); - for i in hits { - let Some(sh) = wb.sheet_mut(i) else { continue }; - for ch in sh.charts.iter_mut().filter(|c| linked(c)) { - ch.series = series.clone(); - } - } -} - -/// Clears a pivot's last output area. -pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { - let Some(shm) = wb.sheet_mut(sheet) else { return }; - let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return }; - shm.take_cells(r); - if let Some(p) = shm.pivots.get_mut(idx) { - p.last_range = None; - } -} - #[cfg(test)] mod fit_tests { use super::data_cols_exceed_sheet; diff --git a/crates/engine/src/pivot/charts.rs b/crates/engine/src/pivot/charts.rs new file mode 100644 index 000000000..bc5d8e597 --- /dev/null +++ b/crates/engine/src/pivot/charts.rs @@ -0,0 +1,108 @@ +use super::output::Output; +use super::{CellRef, Chart, Series, Workbook, quote_sheet}; + +// ---------------------------------------------------------------- PivotCharts + +/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`. +pub fn chart_link(sheet: &str, pivot: &str) -> String { + format!("{}!{pivot}", quote_sheet(sheet)) +} + +/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored. +pub fn parse_chart_link(link: &str) -> Option<(String, String)> { + let t = link.trim(); + let t = match t.strip_prefix('[') { + Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest), + None => t, + }; + let (sheet, name) = t.rsplit_once('!')?; + let sheet = sheet.trim(); + let sheet = gridcraft_core::unquote_sheet(sheet); + let name = name.trim(); + if sheet.is_empty() || name.is_empty() { + return None; + } + Some((sheet, name.to_string())) +} + +/// Whether a PivotChart link points at `pivot` on `sheet`. +pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool { + parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot)) +} + +/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union). +fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option { + let q = quote_sheet(sheet); + let c = origin.col.checked_add(col)?; + let letters = gridcraft_core::col_to_letters(c); + let mut parts: Vec = Vec::new(); + let mut i = 0; + while let Some(&start) = rows.get(i) { + let mut end = start; + while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) { + end += 1; + i += 1; + } + i += 1; + let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1); + parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") }); + } + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(format!("({})", parts.join(","))), + } +} + +/// Most category rows a PivotChart plots. +const MAX_CHART_ROWS: usize = 10_000; + +/// A PivotChart's series for a report written at `origin`: categories are the innermost row +/// items, one series per item column (column items × value fields); totals are left out. +pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec { + let rows: Vec = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect(); + let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows)); + out.chart_cols + .iter() + .filter_map(|(c, name)| { + Some(Series { + name: Some(format!("\"{}\"", name.replace('"', "\"\""))), + categories: categories.clone(), + values: rows_ref(sheet, origin, *c, &rows)?, + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }) + }) + .collect() +} + +/// Rebuilds the series of every PivotChart of a PivotTable after its report was written. +pub(super) fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) { + let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot)); + let hits: Vec = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect(); + if hits.is_empty() { + return; + } + let series = chart_series(sheet, origin, out); + for i in hits { + let Some(sh) = wb.sheet_mut(i) else { continue }; + for ch in sh.charts.iter_mut().filter(|c| linked(c)) { + ch.series = series.clone(); + } + } +} + +/// Clears a pivot's last output area. +pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { + let Some(shm) = wb.sheet_mut(sheet) else { return }; + let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return }; + shm.take_cells(r); + if let Some(p) = shm.pivots.get_mut(idx) { + p.last_range = None; + } +} diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs new file mode 100644 index 000000000..9ebf44737 --- /dev/null +++ b/crates/engine/src/pivot/compute.rs @@ -0,0 +1,707 @@ +use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label}; +use super::output::{AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, data_cols_exceed_sheet, flatten_cols}; +use super::{ + CalcSet, CellError, CellRef, HashMap, HashSet, MAX_AXIS_FIELDS, MAX_COLS, MAX_VALUE_FIELDS, PivotDateGroup, PivotField, PivotFilter, PivotFunc, + PivotLayout, PivotShowAs, PivotTable, PivotValue, Source, Value, Workbook, read_source, +}; + +/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end +/// edits that cell instead). +pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; + +/// Show Details: the source list and the indices of its records behind the value cell at +/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when +/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report +/// on the sheet is no longer what the source computes: the cell's records would be another cell's. +pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None }; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; + // Checked first: in a stale report even a cell that computes as a label may show a value. + check_current(wb, pt, pivot_sheet, &out)?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = + probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds +/// records: its value, subtotal and grand total cells, from one computation of the report. The +/// same errors as [`detail_records`]. +pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) }; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; + check_current(wb, pt, pivot_sheet, &out)?; + Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect()) +} + +/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable +/// cache; without one, the report written by the last refresh must still be what the source gives. +fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> { + let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; + let current = pt.last_range.is_some_and(|r| { + r.height() == out.height().max(1) + && r.width() == out.width.max(1) + && out.grid.iter().enumerate().all(|(ri, line)| { + line.iter().enumerate().all(|(ci, oc)| { + sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value + }) + }) + }); + if !current { + return Err("The PivotTable report is out of date. Refresh it first.".into()); + } + Ok(()) +} + +type ProbePath = (usize, Vec, Vec); + +/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column +/// item paths of the value cell at `at`, the paths of every record the filters let through (when +/// `paths` is set), and every value cell (when `cells` is set). +pub(super) struct Probe { + at: Option<(usize, usize)>, + hit: Option<(Vec, Vec)>, + paths: Option>, + cells: Option>, +} + +struct AggregateInput<'a> { + wb: &'a Workbook, + pt: &'a PivotTable, + src: &'a Source, + rows: &'a [(usize, &'a PivotField)], + cols: &'a [(usize, &'a PivotField)], + vals: &'a [(ValSrc, &'a PivotValue)], + extra: &'a [usize], + filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)], + filter_fmts: &'a [String], + row_fmts: &'a [String], + col_fmts: &'a [String], + empty: bool, +} + +#[derive(Default)] +struct AggregateData { + row_items: Vec, + col_items: Vec, + aggs: AggMap, + row_set: HashSet>, + col_set: HashSet>, + col_leaves: HashSet>, +} + +fn aggregate_record( + input: &AggregateInput<'_>, + data: &mut AggregateData, + probe: &mut Option<&mut Probe>, + ri: usize, + rec: &[Value], +) -> Result<(), String> { + for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) { + if let Some(selected) = &field.selected { + let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt); + if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) { + return Ok(()); + } + } + } + if input.empty { + return Ok(()); + } + let mut row_path = Vec::with_capacity(input.rows.len()); + for (((column, field), items), fmt) in input.rows.iter().zip(data.row_items.iter_mut()).zip(input.row_fmts) { + row_path.push(items.intern(input.wb, rec.get(*column).unwrap_or(&Value::Empty), field.date_group, fmt, ri)); + } + let mut col_path = Vec::with_capacity(input.cols.len()); + for (((column, field), items), fmt) in input.cols.iter().zip(data.col_items.iter_mut()).zip(input.col_fmts) { + col_path.push(items.intern(input.wb, rec.get(*column).unwrap_or(&Value::Empty), field.date_group, fmt, ri)); + } + for row_depth in 0..=row_path.len() { + for col_depth in 0..=col_path.len() { + let accs = data + .aggs + .entry((row_path[..row_depth].to_vec(), col_path[..col_depth].to_vec())) + .or_insert_with(|| vec![Acc::default(); input.vals.len() + input.extra.len()]); + for (acc, (source, _)) in accs.iter_mut().zip(input.vals) { + if let ValSrc::Col(column) = source { + acc.add(rec.get(*column).unwrap_or(&Value::Empty)); + } + } + for (acc, column) in accs.iter_mut().skip(input.vals.len()).zip(input.extra) { + acc.add(rec.get(*column).unwrap_or(&Value::Empty)); + } + } + } + let shown_depth = input + .cols + .iter() + .zip(data.col_items.iter()) + .zip(col_path.iter()) + .position(|(((_, field), items), &item)| !field.collapsed_items.is_empty() && is_collapsed(field, &items.label(item))) + .map_or(col_path.len(), |depth| depth + 1); + data.col_leaves.insert(col_path[..shown_depth].to_vec()); + if let Some(paths) = probe.as_deref_mut().and_then(|value| value.paths.as_mut()) { + paths.push((ri, row_path.clone(), col_path.clone())); + } + data.row_set.insert(row_path); + data.col_set.insert(col_path); + if !input.cols.is_empty() && data_cols_exceed_sheet(data.col_leaves.len(), input.pt.grand_totals_rows, input.vals.len()) { + return Err("The PivotTable report will not fit on the sheet.".into()); + } + if data.aggs.len() > 5_000_000 { + return Err("The PivotTable report is too large.".into()); + } + Ok(()) +} + +fn aggregate_source(input: AggregateInput<'_>, probe: &mut Option<&mut Probe>) -> Result { + let mut data = AggregateData { + row_items: input.rows.iter().map(|_| FieldItems::default()).collect(), + col_items: input.cols.iter().map(|_| FieldItems::default()).collect(), + ..AggregateData::default() + }; + for (ri, record) in input.src.rows.iter().enumerate() { + aggregate_record(&input, &mut data, probe, ri, record)?; + } + Ok(data) +} + +struct HeaderInput<'a> { + vals: &'a [(ValSrc, &'a PivotValue)], + row_fields: &'a [&'a PivotField], + col_fields: &'a [&'a PivotField], + col_items: &'a [FieldItems], + col_fmts: &'a [String], + vpos: Option, + data_cols: &'a [(usize, Option)], + specs: &'a [ColSpec], + row_levels: usize, + compact: bool, + on_rows: bool, + k: usize, + l: usize, + headers_on: bool, +} + +fn write_headers(g: &mut Grid, input: HeaderInput<'_>) -> Result<(), String> { + let HeaderInput { vals, row_fields, col_fields, col_items, col_fmts, vpos, data_cols, specs, row_levels, compact, on_rows, k, l, headers_on } = + input; + let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default(); + let row_header = |g: &mut Grid, r: usize| { + if row_levels == 0 || !headers_on { + return; + } + if compact { + g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None); + } else { + // One column per row field, Σ Values ("Values") at its place. + let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect(); + if let Some(p) = vpos { + names.insert(p.min(names.len()), "Values"); + } + for (i, name) in names.into_iter().enumerate() { + g.set(r, i, Value::text(name), CellKind::Header, 0, None); + } + } + }; + + // ---- header rows + if col_fields.is_empty() { + let r = g.push_row(RowKind::Header)?; + row_header(g, r); + for (j, (_, vi)) in data_cols.iter().enumerate() { + match vi { + Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), + // Values on rows, no column field: the one data column is the grand total. + None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), + None => {} + } + } + } else { + let cap = g.push_row(RowKind::Header)?; + if l > 0 && k == 1 { + g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None); + } + if headers_on { + let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() }; + g.set(cap, l, Value::text(caption), CellKind::Header, 0, None); + } + let levels = col_fields.len() + usize::from(k > 1 && !on_rows); + let first_level = g.rows.len(); + for _ in 0..levels { + g.push_row(RowKind::Header)?; + } + let mut prev: Option<&ColSpec> = None; + for (j, (si, vi)) in data_cols.iter().enumerate() { + let Some(spec) = specs.get(*si) else { continue }; + let c = l + j; + match spec.kind { + ColKind::Leaf => { + for (lv, &it) in spec.path.iter().enumerate() { + let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv)); + if !same { + let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue }; + let fmt = if item.formatted { col_fmts.get(lv).cloned() } else { None }; + g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt); + } + } + if k > 1 + && let Some(vi) = vi + { + g.set(first_level + col_fields.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None); + } + } + ColKind::Subtotal => { + let lv = spec.path.len().saturating_sub(1); + let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default(); + let text = match vi { + Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)), + _ => format!("{label} Total"), + }; + g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None); + } + ColKind::Grand => { + let text = match vi { + Some(vi) if k > 1 => format!("Total {}", val_name(*vi)), + _ => "Grand Total".to_string(), + }; + g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None); + } + } + prev = Some(spec); + } + let last = g.rows.len() - 1; + row_header(g, last); + } + + Ok(()) +} + +struct ValueLookup<'a> { + wb: &'a Workbook, + source: &'a Source, + calc: &'a CalcSet, + vals: &'a [(ValSrc, &'a PivotValue)], + extra: &'a [usize], + aggs: &'a AggMap, + value_count: usize, +} + +impl ValueLookup<'_> { + fn get(&self, row_path: &[u32], col_path: &[u32], value_index: usize) -> Option { + let (source, definition) = self.vals.get(value_index)?; + let accumulators = self.aggs.get(&(row_path.to_vec(), col_path.to_vec()))?; + match source { + ValSrc::Col(_) => accumulators.get(value_index).map(|acc| acc.result(definition.func)), + ValSrc::Calc(index) => { + let sum = |name: &str| -> Value { + self.source + .col(name) + .and_then(|column| self.extra.iter().position(|extra_column| *extra_column == column)) + .and_then(|index| accumulators.get(self.value_count + index)) + .map(|acc| acc.result(PivotFunc::Sum)) + .unwrap_or(Value::Number(0.0)) + }; + Some(self.calc.value(self.wb, *index, &sum)) + } + } + } +} + +struct ShowContext<'a> { + row_path: &'a [u32], + col_path: &'a [u32], + value_index: usize, + level0: &'a [u32], + siblings: &'a HashMap, Vec>, +} + +fn number(value: &Option) -> Option { + value.as_ref().and_then(Value::as_f64) +} + +fn ratio(a: Value, b: Option) -> Value { + match (a, b) { + (Value::Error(error), _) => Value::Error(error), + (_, Some(Value::Error(error))) => Value::Error(error), + (Value::Number(_), Some(Value::Number(0.0))) => Value::Error(CellError::Div0), + (Value::Number(x), Some(Value::Number(y))) => Value::number(x / y), + (value, _) => value, + } +} + +fn running_total(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value { + let Some((first, _)) = context.row_path.split_first() else { return raw }; + if !matches!(raw, Value::Number(_)) { + return raw; + } + let mut total = 0.0; + for item in context.level0 { + let mut path = context.row_path.to_vec(); + if let Some(first_item) = path.first_mut() { + *first_item = *item; + } + total += number(&lookup.get(&path, context.col_path, context.value_index)).unwrap_or(0.0); + if item == first { + break; + } + } + Value::number(total) +} + +fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value { + let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty }; + let Some(me) = raw.as_f64() else { return raw }; + let siblings = context.siblings.get(parent).cloned().unwrap_or_default(); + let mut rank = 1u64; + for sibling in siblings { + let mut path = parent.to_vec(); + path.push(sibling); + if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) { + rank += 1; + } + } + Value::number(rank as f64) +} + +fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value { + match definition.show_as { + PivotShowAs::Normal => raw, + PivotShowAs::PercentOfGrandTotal => ratio(raw, lookup.get(&[], &[], context.value_index)), + PivotShowAs::PercentOfColumnTotal => ratio(raw, lookup.get(&[], context.col_path, context.value_index)), + PivotShowAs::PercentOfRowTotal => ratio(raw, lookup.get(context.row_path, &[], context.value_index)), + PivotShowAs::RunningTotal => running_total(raw, lookup, context), + PivotShowAs::Rank => ranked_value(raw, lookup, context), + } +} + +type ReportFilter<'a> = (usize, &'a PivotFilter, PivotDateGroup, bool); + +fn report_filters<'a>(pt: &'a PivotTable, src: &Source) -> (Vec>, Vec>) { + let axis_group = + |name: &str| pt.rows.iter().chain(pt.columns.iter()).find(|field| field.source_col.eq_ignore_ascii_case(name)).map(|field| field.date_group); + let filters: Vec<_> = pt + .filters + .iter() + .filter_map(|filter| { + let column = src.col(&filter.source_col)?; + let group = axis_group(&filter.source_col); + Some((column, filter, group.unwrap_or_default(), group.is_none())) + }) + .collect(); + let shown = filters.iter().copied().filter(|filter| filter.3).collect(); + (filters, shown) +} + +fn values_position(pt: &PivotTable, src: &Source, value_count: usize) -> Option { + if !pt.values_on_rows || value_count <= 1 { + return None; + } + let position = pt.values_position.unwrap_or(pt.rows.len()); + Some(pt.rows.iter().take(position).filter(|field| src.col(&field.source_col).is_some()).count()) +} + +fn calculated_source_columns(vals: &[(ValSrc, &PivotValue)], calc: &CalcSet, src: &Source) -> Vec { + let mut columns = Vec::new(); + for (source, _) in vals { + let ValSrc::Calc(index) = source else { continue }; + for name in calc.source_refs(*index) { + if let Some(column) = src.col(&name) + && !columns.contains(&column) + { + columns.push(column); + } + } + } + columns +} + +fn data_columns(specs: &[ColSpec], has_column_fields: bool, on_rows: bool, value_count: usize) -> Result)>, String> { + let mut columns = Vec::new(); + for (spec_index, _) in specs.iter().enumerate() { + if on_rows { + columns.push((spec_index, None)); + } else if value_count == 0 { + if has_column_fields { + columns.push((spec_index, None)); + } + } else { + columns.extend((0..value_count).map(|value_index| (spec_index, Some(value_index)))); + } + if columns.len() > MAX_COLS as usize { + return Err("The PivotTable report will not fit on the sheet.".into()); + } + } + Ok(columns) +} + +pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { + let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); + let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); + let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); + // Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`). + let calc = CalcSet::new(pt, &src.headers); + let vals: Vec<(ValSrc, &PivotValue)> = pt + .values + .iter() + .filter_map(|v| match src.col(&v.source_col) { + Some(c) => Some((ValSrc::Col(c), v)), + None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)), + }) + .collect(); + // Source columns summed for the calculated fields, accumulated after the value fields. + let extra = calculated_source_columns(&vals, &calc, src); + if rows.len() + cols.len() > MAX_AXIS_FIELDS { + return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields.")); + } + if vals.len() > MAX_VALUE_FIELDS { + return Err(format!("A PivotTable can have at most {MAX_VALUE_FIELDS} value fields.")); + } + let (filters, shown_filters) = report_filters(pt, src); + + let k = vals.len(); + let empty = rows.is_empty() && cols.is_empty() && vals.is_empty(); + // Σ Values in Rows (it exists with two or more value fields): its depth among the row fields + // found in the source. + let vpos = values_position(pt, src, k); + let on_rows = vpos.is_some(); + let row_levels = rows.len() + usize::from(on_rows); + + // ---- aggregate + let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); + let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); + let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); + let aggregate = aggregate_source( + AggregateInput { + wb, + pt, + src, + rows: &rows, + cols: &cols, + vals: &vals, + extra: &extra, + filters: &filters, + filter_fmts: &filter_fmts, + row_fmts: &row_fmts, + col_fmts: &col_fmts, + empty, + }, + &mut probe, + )?; + let AggregateData { row_items, col_items, aggs, row_set, col_set, col_leaves: _ } = aggregate; + + // ---- axes + let row_fields: Vec<&PivotField> = rows.iter().map(|(_, f)| *f).collect(); + let col_fields: Vec<&PivotField> = cols.iter().map(|(_, f)| *f).collect(); + let row_paths: Vec<&[u32]> = row_set.iter().map(Vec::as_slice).collect(); + let col_paths: Vec<&[u32]> = col_set.iter().map(Vec::as_slice).collect(); + let row_tree = build_tree(&row_paths, 0, &row_fields, &row_items); + let col_tree = build_tree(&col_paths, 0, &col_fields, &col_items); + let mut siblings: HashMap, Vec> = HashMap::new(); + collect_siblings(&row_tree, &mut Vec::new(), &mut siblings); + let level0: Vec = row_tree.iter().map(|n| n.item).collect(); + + let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); + let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; + let mut lines: Vec = Vec::new(); + if row_levels == 0 { + lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); + } else { + if pt.layout == PivotLayout::Tabular { + ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); + } else { + ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); + } + // With only Σ Values in Rows its items are the totals already. + if pt.grand_totals_cols && !rows.is_empty() { + let grand = |text: String, vi: Option| Line { + kind: LineKind::Grand, + leaf: false, + path: vec![], + values: true, + labels: vec![(0, Value::text(text), 0, None)], + vi, + }; + if on_rows { + lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); + } else { + lines.push(grand("Grand Total".into(), None)); + } + } + } + let mut specs: Vec = Vec::new(); + if cols.is_empty() { + specs.push(ColSpec { kind: ColKind::Leaf, path: vec![] }); + } else { + flatten_cols(&col_tree, 0, &col_fields, &col_items, pt.subtotals, &mut Vec::new(), &mut specs); + if pt.grand_totals_rows { + specs.push(ColSpec { kind: ColKind::Grand, path: vec![] }); + } + } + let data_cols = data_columns(&specs, !cols.is_empty(), on_rows, k)?; + + let compact = pt.layout == PivotLayout::Compact; + let l = if row_levels == 0 && cols.is_empty() { + 0 + } else if compact { + 1 + } else { + row_levels.max(1) + }; + let mut width = l + data_cols.len(); + if !shown_filters.is_empty() { + width = width.max(2); + } + if empty { + width = width.max(1); + } + let width = width.max(1); + let mut g = Grid { rows: vec![], kinds: vec![], width }; + + // ---- report filters + for (c, f, _, _) in &shown_filters { + let r = g.push_row(RowKind::Filter)?; + let name = src.headers.get(*c).cloned().unwrap_or_default(); + let shown = match &f.selected { + None => "(All)".to_string(), + Some(v) if v.len() == 1 => v.first().cloned().unwrap_or_default(), + Some(_) => "(Multiple Items)".to_string(), + }; + g.set(r, 0, Value::text(name), CellKind::Filter, 0, None); + g.set(r, 1, Value::text(shown), CellKind::Filter, 0, None); + } + if !shown_filters.is_empty() { + g.push_row(RowKind::Blank)?; + } + let body_offset = g.rows.len() as u32; + + if empty { + let r = g.push_row(RowKind::Placeholder)?; + g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None); + return Ok(Output { + width: g.width as u32, + grid: g.rows, + row_kinds: g.kinds, + body_offset, + chart_rows: vec![], + chart_cols: vec![], + label_col: None, + }); + } + + write_headers( + &mut g, + HeaderInput { + vals: &vals, + row_fields: &row_fields, + col_fields: &col_fields, + col_items: &col_items, + col_fmts: &col_fmts, + vpos, + data_cols: &data_cols, + specs: &specs, + row_levels, + compact, + on_rows, + k, + l, + headers_on: pt.show_headers, + }, + )?; + let val_name = |vi: usize| vals.get(vi).map(|(_, value)| value.name.clone()).unwrap_or_default(); + + // ---- body + let data_fmt: Vec> = vals + .iter() + .map(|(vs, v)| match v.show_as { + PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => { + Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into())) + } + PivotShowAs::Rank => v.number_format.clone(), + _ => v.number_format.clone().or_else(|| match v.func { + PivotFunc::Count | PivotFunc::CountNumbers => None, + _ => { + let f = match vs { + ValSrc::Col(c) => fmt_of(*c), + ValSrc::Calc(_) => "General".to_string(), + }; + (f != "General").then_some(f) + } + }), + }) + .collect(); + let lookup = ValueLookup { wb, source: src, calc: &calc, vals: &vals, extra: &extra, aggs: &aggs, value_count: k }; + let mut chart_rows: Vec = Vec::new(); + let chart_cols: Vec<(u32, String)> = data_cols + .iter() + .enumerate() + .filter_map(|(j, (si, vi))| { + let spec = specs.get(*si)?; + if spec.kind != ColKind::Leaf { + return None; + } + let mut parts: Vec = + spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect(); + if let Some(vi) = vi + && (k > 1 || parts.is_empty()) + { + parts.push(val_name(*vi)); + } else if parts.is_empty() { + parts.push("Total".into()); + } + Some(((l + j) as u32, parts.join(" - "))) + }) + .filter(|_| k > 0) + .collect(); + let label_col = if row_levels == 0 { + None + } else if compact { + Some(0) + } else { + Some(row_levels.saturating_sub(1) as u32) + }; + for line in &lines { + let rk = match line.kind { + LineKind::Item(b) => RowKind::Item(b), + LineKind::Subtotal => RowKind::Subtotal, + LineKind::Grand => RowKind::Grand, + }; + let ck = match line.kind { + LineKind::Item(_) => (CellKind::RowLabel, CellKind::Data), + LineKind::Subtotal => (CellKind::Subtotal, CellKind::Subtotal), + LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal), + }; + let r = g.push_row(rk)?; + if line.leaf && k > 0 { + chart_rows.push(r as u32); + } + for (c, v, indent, fmt) in &line.labels { + g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone()); + } + if !line.values { + continue; + } + let rp = line.path.as_slice(); + for (j, (si, col_vi)) in data_cols.iter().enumerate() { + let line_vi = col_vi.or(line.vi); + let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue }; + let Some((_, vdef)) = vals.get(*vi) else { continue }; + let cp = spec.path.as_slice(); + let Some(raw) = lookup.get(rp, cp, *vi) else { continue }; + let context = ShowContext { row_path: rp, col_path: cp, value_index: *vi, level0: &level0, siblings: &siblings }; + let shown = show_value(vdef, raw, &lookup, &context); + if let Some(pr) = probe.as_deref_mut() { + if pr.at == Some((r, l + j)) { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } + if let Some(cells) = pr.cells.as_mut() { + cells.insert((r, l + j)); + } + } + g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); + } + } + Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col }) +} diff --git a/crates/engine/src/pivot/items.rs b/crates/engine/src/pivot/items.rs new file mode 100644 index 000000000..1a39c59d9 --- /dev/null +++ b/crates/engine/src/pivot/items.rs @@ -0,0 +1,268 @@ +use super::{CellError, HashMap, MONTHS, Ordering, PivotDateGroup, PivotField, PivotFunc, PivotSort, Source, Value, Workbook, datetime_from_serial}; + +// ---------------------------------------------------------------- items + +#[derive(Clone, Debug)] +pub(super) struct Item { + pub(super) value: Value, + pub(super) label: String, + class: u8, + num: f64, + text: String, + first: usize, + /// The value is a number shown with the source format. + pub(super) formatted: bool, +} + +#[derive(Default)] +pub(super) struct FieldItems { + items: Vec, + index: HashMap, +} + +fn cmp_items(a: &Item, b: &Item) -> Ordering { + a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text)) +} + +/// (identity key, item) for a source value under a field's date grouping. +fn make_item(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> (String, Item) { + let it = |value: Value, label: String, class: u8, num: f64, formatted: bool| Item { + text: label.to_lowercase(), + value, + label, + class, + num, + first, + formatted, + }; + match v { + Value::Empty => ("b".into(), it(Value::text("(blank)"), "(blank)".into(), 4, 0.0, false)), + Value::Number(n) => { + if group != PivotDateGroup::None + && let Some(dt) = datetime_from_serial(wb.date_system, *n) + { + let mon = MONTHS.get(dt.month.saturating_sub(1) as usize).copied().unwrap_or("Jan"); + let (label, key) = match group { + PivotDateGroup::Years => (dt.year.to_string(), dt.year as f64), + PivotDateGroup::Quarters => { + let q = (dt.month.saturating_sub(1)) / 3 + 1; + (format!("Qtr{q}"), q as f64) + } + PivotDateGroup::Months => (mon.to_string(), dt.month as f64), + _ => (format!("{}-{mon}", dt.day), (dt.month * 100 + dt.day) as f64), + }; + return (format!("g{key}"), it(Value::text(label.as_str()), label, 0, key, false)); + } + let label = crate::display::format(v, fmt, wb).text; + (format!("n{}", n.to_bits()), it(v.clone(), label, 0, *n, true)) + } + Value::Text(t) => (format!("t{}", t.to_lowercase()), it(v.clone(), t.to_string(), 1, 0.0, false)), + Value::Bool(b) => { + let l = if *b { "TRUE" } else { "FALSE" }; + (format!("l{l}"), it(Value::Bool(*b), l.into(), 2, *b as u8 as f64, false)) + } + Value::Error(e) => (format!("e{}", e.as_str()), it(v.clone(), e.as_str().into(), 3, 0.0, false)), + Value::Array(_) => make_item(wb, &v.scalar(), group, fmt, first), + } +} + +impl FieldItems { + pub(super) fn intern(&mut self, wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> u32 { + let (key, item) = make_item(wb, v, group, fmt, first); + if let Some(i) = self.index.get(&key) { + return *i; + } + let i = self.items.len() as u32; + self.items.push(item); + self.index.insert(key, i); + i + } + pub(super) fn get(&self, i: u32) -> Option<&Item> { + self.items.get(i as usize) + } + pub(super) fn label(&self, i: u32) -> String { + self.get(i).map(|x| x.label.clone()).unwrap_or_default() + } +} + +/// The label of a source value under a date grouping (for filters and field lists). +pub fn item_label(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str) -> String { + make_item(wb, v, group, fmt, 0).1.label +} + +/// Distinct item labels of a source column, sorted like the field list shows them. +pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDateGroup, limit: usize) -> Vec { + let mut fi = FieldItems::default(); + let fmt = src.formats.get(col).map(String::as_str).unwrap_or("General"); + for (i, r) in src.rows.iter().enumerate() { + if let Some(v) = r.get(col) { + fi.intern(wb, v, group, fmt, i); + } + if fi.items.len() > 100_000 { + break; + } + } + let mut items = fi.items; + items.sort_by(cmp_items); + items.into_iter().take(limit).map(|i| i.label).collect() +} + +// ---------------------------------------------------------------- aggregation + +#[derive(Clone, Debug, Default)] +pub(super) struct Acc { + count: u64, + nums: u64, + sum: f64, + prod: f64, + min: f64, + max: f64, + mean: f64, + m2: f64, + err: Option, +} + +impl Acc { + pub(super) fn add(&mut self, v: &Value) { + match v { + Value::Empty => {} + Value::Number(n) => { + let n = *n; + self.count += 1; + self.nums += 1; + self.sum += n; + if self.nums == 1 { + self.prod = n; + self.min = n; + self.max = n; + } else { + self.prod *= n; + self.min = self.min.min(n); + self.max = self.max.max(n); + } + let d = n - self.mean; + self.mean += d / self.nums as f64; + self.m2 += d * (n - self.mean); + } + Value::Error(e) => { + self.count += 1; + if self.err.is_none() { + self.err = Some(*e); + } + } + _ => self.count += 1, + } + } + pub(super) fn result(&self, f: PivotFunc) -> Value { + let n = self.nums as f64; + match f { + PivotFunc::Count => return Value::number(self.count as f64), + PivotFunc::CountNumbers => return Value::number(n), + _ => {} + } + if let Some(e) = self.err { + return Value::Error(e); + } + let div0 = Value::Error(CellError::Div0); + match f { + PivotFunc::Sum => Value::number(self.sum), + PivotFunc::Average => { + if self.nums == 0 { + div0 + } else { + Value::number(self.sum / n) + } + } + PivotFunc::Max => Value::number(if self.nums == 0 { 0.0 } else { self.max }), + PivotFunc::Min => Value::number(if self.nums == 0 { 0.0 } else { self.min }), + PivotFunc::Product => Value::number(if self.nums == 0 { 0.0 } else { self.prod }), + PivotFunc::StdDev | PivotFunc::Var => { + if self.nums < 2 { + div0 + } else { + let var = (self.m2 / (n - 1.0)).max(0.0); + Value::number(if f == PivotFunc::Var { var } else { var.sqrt() }) + } + } + PivotFunc::StdDevP | PivotFunc::VarP => { + if self.nums < 1 { + div0 + } else { + let var = (self.m2 / n).max(0.0); + Value::number(if f == PivotFunc::VarP { var } else { var.sqrt() }) + } + } + PivotFunc::Count | PivotFunc::CountNumbers => Value::Empty, + } + } +} + +/// Excel's caption verb for a function ("Sum of Sales"). +pub fn func_caption(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "Sum", + PivotFunc::Count => "Count", + PivotFunc::Average => "Average", + PivotFunc::Max => "Max", + PivotFunc::Min => "Min", + PivotFunc::Product => "Product", + PivotFunc::CountNumbers => "Count Numbers", + PivotFunc::StdDev => "StdDev", + PivotFunc::StdDevP => "StdDevp", + PivotFunc::Var => "Var", + PivotFunc::VarP => "Varp", + } +} + +// ---------------------------------------------------------------- trees + +#[derive(Clone, Debug)] +pub(super) struct Node { + pub(super) item: u32, + pub(super) children: Vec, +} + +pub(super) fn build_tree(paths: &[&[u32]], depth: usize, fields: &[&PivotField], items: &[FieldItems]) -> Vec { + let (Some(field), Some(fi)) = (fields.get(depth), items.get(depth)) else { return vec![] }; + let mut order: Vec = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + for p in paths { + if let Some(&it) = p.get(depth) { + let g = groups.entry(it).or_default(); + if g.is_empty() { + order.push(it); + } + g.push(p); + } + } + let mut nodes: Vec = order + .into_iter() + .map(|it| { + let ps = groups.remove(&it).unwrap_or_default(); + Node { item: it, children: build_tree(&ps, depth + 1, fields, items) } + }) + .collect(); + let by = |a: &Node, b: &Node| match (fi.get(a.item), fi.get(b.item)) { + (Some(x), Some(y)) => match field.sort { + PivotSort::Asc => cmp_items(x, y), + PivotSort::Desc => cmp_items(y, x), + PivotSort::None => x.first.cmp(&y.first), + }, + _ => Ordering::Equal, + }; + nodes.sort_by(by); + nodes +} + +pub(super) fn is_collapsed(field: &PivotField, label: &str) -> bool { + field.collapsed_items.iter().any(|c| c.eq_ignore_ascii_case(label)) +} + +pub(super) fn collect_siblings(nodes: &[Node], prefix: &mut Vec, out: &mut HashMap, Vec>) { + out.insert(prefix.clone(), nodes.iter().map(|n| n.item).collect()); + for n in nodes { + prefix.push(n.item); + collect_siblings(&n.children, prefix, out); + prefix.pop(); + } +} diff --git a/crates/engine/src/pivot/output.rs b/crates/engine/src/pivot/output.rs new file mode 100644 index 000000000..c3d6ee88d --- /dev/null +++ b/crates/engine/src/pivot/output.rs @@ -0,0 +1,440 @@ +use super::items::{Acc, FieldItems, Node, is_collapsed}; +use super::{ + BorderLine, BorderStyle, Color, Fill, HAlign, HashMap, MAX_COLS, MAX_OUTPUT_CELLS, MAX_ROWS, NumFmt, PivotField, PivotLayout, PivotTable, Style, + Value, +}; + +// ---------------------------------------------------------------- output + +/// What a report cell is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum CellKind { + Empty, + Header, + RowLabel, + ColLabel, + Data, + Subtotal, + GrandTotal, + Filter, + Placeholder, +} + +/// What a report row is (for row-wide styling). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum RowKind { + Filter, + Blank, + Header, + /// Item row; `true` for a parent item (bold). + Item(bool), + Subtotal, + Grand, + Placeholder, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutCell { + pub value: Value, + pub kind: CellKind, + pub indent: u8, + pub num_fmt: Option, +} + +impl OutCell { + fn empty() -> OutCell { + OutCell { value: Value::Empty, kind: CellKind::Empty, indent: 0, num_fmt: None } + } +} + +/// A computed report: a dense grid relative to its top-left, with row kinds. +#[derive(Clone, Debug)] +pub struct Output { + pub grid: Vec>, + pub row_kinds: Vec, + /// Rows above the body (report filters plus a blank row). + pub body_offset: u32, + pub width: u32, + /// Grid rows of the innermost (or collapsed) items: a PivotChart's categories. + pub chart_rows: Vec, + /// Grid columns of item (not total) values with their series names: a PivotChart's series. + pub chart_cols: Vec<(u32, String)>, + /// Grid column holding the innermost row labels (`None` without row fields). + pub label_col: Option, +} + +impl Output { + pub fn height(&self) -> u32 { + self.grid.len() as u32 + } + /// Value at (row, col) relative to the top-left. + pub fn value(&self, r: usize, c: usize) -> Value { + self.grid.get(r).and_then(|row| row.get(c)).map(|x| x.value.clone()).unwrap_or_default() + } +} + +pub(super) struct Grid { + pub(super) rows: Vec>, + pub(super) kinds: Vec, + pub(super) width: usize, +} + +impl Grid { + pub(super) fn push_row(&mut self, kind: RowKind) -> Result { + if (self.rows.len() as u64 + 1) * self.width.max(1) as u64 > MAX_OUTPUT_CELLS || self.rows.len() as u64 >= MAX_ROWS as u64 { + return Err("The PivotTable report is too large.".into()); + } + self.rows.push(vec![OutCell::empty(); self.width]); + self.kinds.push(kind); + Ok(self.rows.len() - 1) + } + pub(super) fn set(&mut self, r: usize, c: usize, value: Value, kind: CellKind, indent: u8, fmt: Option) { + if let Some(cell) = self.rows.get_mut(r).and_then(|row| row.get_mut(c)) { + *cell = OutCell { value, kind, indent, num_fmt: fmt }; + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum LineKind { + Item(bool), + Subtotal, + Grand, +} + +pub(super) struct Line { + pub(super) kind: LineKind, + /// A category of a PivotChart: an innermost (or collapsed) item. + pub(super) leaf: bool, + pub(super) path: Vec, + pub(super) values: bool, + /// (column, value, indent, number format) + pub(super) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + pub(super) vi: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum ColKind { + Leaf, + Subtotal, + Grand, +} + +pub(super) struct ColSpec { + pub(super) kind: ColKind, + pub(super) path: Vec, +} + +pub(super) struct Ctx<'a> { + pub(super) pt: &'a PivotTable, + pub(super) rows: Vec<&'a PivotField>, + pub(super) row_items: Vec, + pub(super) row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + pub(super) vpos: Option, + /// Value field captions. + pub(super) val_names: Vec, +} + +impl Ctx<'_> { + fn label_cell(&self, depth: usize, item: u32) -> (Value, Option, String) { + let Some(it) = self.row_items.get(depth).and_then(|fi| fi.get(item)) else { return (Value::Empty, None, String::new()) }; + let fmt = if it.formatted { self.row_fmts.get(depth).cloned() } else { None }; + (it.value.clone(), fmt, it.label.clone()) + } + + /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. + fn level_cell(&self, level: usize) -> (usize, u8) { + if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } + } + + /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). + fn values_here(&self, depth: usize, vi: Option) -> bool { + vi.is_none() && self.vpos == Some(depth) + } + + /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). + fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { + for (vi, name) in self.val_names.iter().enumerate() { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.to_vec(), + values: true, + labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], + vi: Some(vi), + }); + } + } + + /// The Σ Values level at row field depth `depth`: one item per value field, each followed by + /// the row fields below it (`nodes`) or, innermost, holding that value. + fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + let (col, indent) = self.level_cell(depth); + for (vi, name) in self.val_names.iter().enumerate() { + let labels = vec![(col, Value::text(name.as_str()), indent, None)]; + let leaf = nodes.is_empty(); + out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); + if !leaf { + self.flatten(nodes, depth, Some(vi), prefix, out); + } + } + } + + pub(super) fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { + if self.values_here(depth, vi) { + self.flatten_values(nodes, depth, prefix, out); + return; + } + let n = self.rows.len(); + // Σ Values still to come below this level: totals need one line per value field. + let values_below = vi.is_none() && self.vpos.is_some(); + let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); + for node in nodes { + prefix.push(node.item); + let (v, fmt, label) = self.label_cell(depth, node.item); + let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); + let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); + if leaf && !values_below { + out.push(Line { + kind: LineKind::Item(depth + 1 < n), + leaf: true, + path: prefix.clone(), + values: true, + labels: vec![(col, v, indent, fmt)], + vi, + }); + } else if leaf { + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + // Outline gives each level its own column, so the captions go in the Values + // column even when the levels between are collapsed away. + let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; + self.flatten_values(&[], level, prefix, out); + } else if values_below { + // Subtotals per value field, below the group (one line can't hold them all). + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, None, prefix, out); + if self.pt.subtotals { + self.value_subtotals(col, indent, &label, prefix, out); + } + } else { + let top = self.pt.subtotals && self.pt.subtotals_top; + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: top, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, vi, prefix, out); + if self.pt.subtotals && !self.pt.subtotals_top { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.clone(), + values: true, + labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], + vi, + }); + } + } + prefix.pop(); + } + } + + /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. + fn flatten_tabular_values( + &self, + nodes: &[Node], + depth: usize, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; + labels.push((depth, Value::text(name.as_str()), 0, None)); + if nodes.is_empty() { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); + } else { + self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); + } + } + } + + pub(super) fn flatten_tabular( + &self, + nodes: &[Node], + depth: usize, + vi: Option, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + if self.values_here(depth, vi) { + self.flatten_tabular_values(nodes, depth, prefix, pending, out); + return; + } + let n = self.rows.len(); + let values_below = vi.is_none() && self.vpos.is_some(); + let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); + for (i, node) in nodes.iter().enumerate() { + prefix.push(node.item); + let (v, fmt, label) = self.label_cell(depth, node.item); + let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; + labels.push((col, v, 0, fmt)); + let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); + if depth + 1 >= n || collapsed || node.children.is_empty() { + if values_below { + self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); + } else { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); + } + } else { + self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); + if self.pt.subtotals && values_below { + self.value_subtotals(col, 0, &label, prefix, out); + } else if self.pt.subtotals { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.clone(), + values: true, + labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], + vi, + }); + } + } + prefix.pop(); + } + } +} + +pub(super) fn flatten_cols( + nodes: &[Node], + depth: usize, + fields: &[&PivotField], + items: &[FieldItems], + subtotals: bool, + prefix: &mut Vec, + out: &mut Vec, +) { + let n = fields.len(); + for node in nodes { + prefix.push(node.item); + let label = items.get(depth).map(|fi| fi.label(node.item)).unwrap_or_default(); + let collapsed = depth + 1 < n && fields.get(depth).is_some_and(|f| is_collapsed(f, &label)); + if depth + 1 >= n || collapsed || node.children.is_empty() { + out.push(ColSpec { kind: ColKind::Leaf, path: prefix.clone() }); + } else { + flatten_cols(&node.children, depth + 1, fields, items, subtotals, prefix, out); + if subtotals { + out.push(ColSpec { kind: ColKind::Subtotal, path: prefix.clone() }); + } + } + prefix.pop(); + } +} + +pub(super) type AggMap = HashMap<(Vec, Vec), Vec>; + +/// Where a value field's numbers come from. +#[derive(Clone, Copy)] +pub(super) enum ValSrc { + /// A source column. + Col(usize), + /// A calculated field (index into the PivotTable's calculated fields). + Calc(usize), +} + +/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields +/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. +pub(super) fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { + (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize +} + +// ---------------------------------------------------------------- styling and writing + +/// Colours for a PivotTable style name (our own palette, keyed by the familiar +/// `PivotStyleLight1…28 / Medium1…28 / Dark1…28` names). +pub(super) struct Look { + header_fill: Color, + header_font: Color, + line: Color, + filter_fill: Color, +} + +pub(super) fn look(style: &str) -> Look { + let rest = style.strip_prefix("PivotStyle").unwrap_or(style); + let (fam, n) = ["Light", "Medium", "Dark"] + .iter() + .find_map(|f| rest.strip_prefix(f).map(|num| (*f, num.parse::().unwrap_or(16)))) + .unwrap_or(("Light", 16)); + let k = n.saturating_sub(1) % 7; + let acc = if k == 0 { None } else { Some(3 + k as u8) }; + let tone = |tint: i16| match acc { + Some(a) => Color::Theme(a, tint), + None => Color::Theme(1, tint.clamp(-1000, 1000)), + }; + match fam { + "Dark" => Look { header_fill: Color::Theme(1, 0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(600) }, + "Medium" => Look { header_fill: tone(0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(800) }, + _ => Look { header_fill: tone(800), header_font: Color::Auto, line: tone(400), filter_fill: tone(800) }, + } +} + +pub(super) fn cell_style(lk: &Look, rk: RowKind, cell: &OutCell, last_header: bool) -> Style { + let mut s = Style::default(); + match rk { + RowKind::Header => { + s.font.bold = true; + s.fill = Fill::solid(lk.header_fill); + s.font.color = lk.header_font; + if last_header { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: lk.line }; + } + } + RowKind::Filter => { + s.fill = Fill::solid(lk.filter_fill); + s.font.bold = last_header; + } + RowKind::Item(parent) => s.font.bold = parent, + RowKind::Subtotal => s.font.bold = true, + RowKind::Grand => { + s.font.bold = true; + s.borders.top = BorderLine { style: BorderStyle::Thin, color: lk.line }; + } + RowKind::Placeholder => { + s.font.italic = true; + s.font.color = Color::Theme(1, 500); + } + RowKind::Blank => {} + } + if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel | CellKind::Subtotal | CellKind::GrandTotal) && !cell.value.is_number() { + s.align.h = HAlign::Left; + } + if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel) && cell.value.is_number() { + s.align.h = HAlign::Left; + } + s.align.indent = cell.indent.min(15); + if let Some(f) = &cell.num_fmt { + s.num_fmt = NumFmt::new(f); + } + s +} diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 7804be6c9..269d11aed 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -8,6 +8,7 @@ use serde::Serialize; use serde_json::Value as Json; +use std::collections::BTreeMap; use crate::Selection; @@ -18,6 +19,11 @@ pub const RECORD_LIMIT: usize = 100_000; /// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; +/// Commands never recorded: recovery actions and the debugger, not steps of the person's work. A +/// recording that replayed Stop Action would stop the run it is a step of, and the debugger runs +/// on its own, never as a step of a script or macro. +pub const NOT_RECORDED: [&str; 2] = ["automate.stopAction", "macro.debug"]; + /// One recorded command and where it ran. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -34,33 +40,120 @@ pub struct RecordedStep { pub params: Json, } +/// Numbers each recording, so an undo group's mark from an earlier one isn't read as this one's. +static NEXT_RECORDING: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + /// The steps recorded since recording started. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct Recorder { pub steps: Vec, /// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`]. /// Once a step is dropped, every later one is too, so `steps` is always the beginning of /// what ran, never a sequence with gaps. pub dropped: usize, - /// The size of `steps` serialized as JSON. + /// The size of `steps` and `ran` serialized as JSON. bytes: usize, + /// What a step ran, by its index in `steps`, for a command that runs steps stored in the + /// workbook (`automate.runScript`, `macro.run`): the commands as they ran, so a recording + /// keeps them even when the script or macro is edited or deleted before the recording stops. + /// A run without an entry is left out when the recording is used. + pub ran: BTreeMap>, + /// Which recording this is, and the number the next kept step gets: each step's number is in + /// `seqs`, beside it, so a mark stays right however many steps rollbacks take out. + generation: u64, + next_seq: u64, + seqs: Vec, +} + +impl Default for Recorder { + fn default() -> Self { + let generation = NEXT_RECORDING.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Recorder { steps: Vec::new(), dropped: 0, bytes: 0, ran: BTreeMap::new(), generation, next_seq: 0, seqs: Vec::new() } + } +} + +/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel +/// records `Bold = True`: playing the step back sets that state rather than flipping it again. +pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json { + if let Some(on @ Json::Bool(_)) = result.get("on") { + if params.is_null() { + params = Json::Object(serde_json::Map::new()); + } + if let Json::Object(p) = &mut params + && !p.contains_key("on") + { + p.insert("on".into(), on.clone()); + } + } + params } impl Recorder { - /// Adds a step, or counts it as dropped once the buffer is full. - pub fn push(&mut self, step: RecordedStep) { + /// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept. + pub fn push(&mut self, step: RecordedStep) -> bool { // Once a step has been dropped, or the step count is full, the rest are counted without // being serialized (a `file.open` step holds a whole file). if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { self.dropped = self.dropped.saturating_add(1); - return; + return false; } let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { self.bytes = self.bytes.saturating_add(size); self.steps.push(step); + self.seqs.push(self.next_seq); + self.next_seq = self.next_seq.saturating_add(1); + true } else { self.dropped = self.dropped.saturating_add(1); + false } } + + /// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it, + /// the step stays without it, and the recording leaves the run out and lists it as skipped. + pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) { + let Some(index) = self.steps.len().checked_sub(1) else { return }; + let size = serde_json::to_vec(&steps).map_or(0, |v| v.len()); + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { + self.bytes = self.bytes.saturating_add(size); + self.ran.insert(index, steps); + } + } + + /// Where the recording is now, for [`Recorder::cut_back`]: this recording and the number its + /// next step gets. + pub(crate) fn mark(&self) -> (u64, u64) { + (self.generation, self.next_seq) + } + + /// Takes back the steps recorded in the document with uid `doc` since `mark` (an undo group + /// on it was rolled back, so playing them back would make edits it no longer has). Steps in + /// other documents stay: their edits do too. Steps the buffer dropped stay counted. + /// What a step ran ([`Recorder::ran`]) goes with its step, and stays with a kept one under its + /// new index. A mark from another recording (the recording started again inside the group) + /// means every step of this one came after it. + pub(crate) fn cut_back(&mut self, (generation, seq): (u64, u64), doc: u64) { + let from = if generation == self.generation { seq } else { 0 }; + let gone = |i: usize, step: &RecordedStep| step.doc == doc && self.seqs.get(i).is_some_and(|&n| n >= from); + if !self.steps.iter().enumerate().any(|(i, s)| gone(i, s)) { + return; + } + let mut ran = std::mem::take(&mut self.ran); + let (mut steps, mut seqs) = (Vec::with_capacity(self.steps.len()), Vec::with_capacity(self.seqs.len())); + for (i, step) in self.steps.iter().enumerate() { + if gone(i, step) { + continue; + } + if let Some(r) = ran.remove(&i) { + self.ran.insert(steps.len(), r); + } + steps.push(step.clone()); + seqs.push(self.seqs.get(i).copied().unwrap_or(0)); + } + (self.steps, self.seqs) = (steps, seqs); + let size = |v: Result, serde_json::Error>| v.map_or(0, |v| v.len()); + self.bytes = self.steps.iter().map(|s| size(serde_json::to_vec(s))).sum::() + + self.ran.values().map(|r| size(serde_json::to_vec(r))).sum::(); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c0adf6002..86e8645d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1081,6 +1081,48 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// Delete and the Clear commands on a filtered selection clear only the rows showing, as in +/// Excel (#1419); rows hidden by hand are cleared with the rest. +#[test] +fn clearing_a_filtered_selection_leaves_the_rows_the_filter_hides() { + let mut s = s(); + let rows = [["Name", "Status"], ["John", "Active"], ["Mary", "Inactive"], ["David", "Active"], ["Sarah", "Inactive"], ["Michael", "Active"]]; + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("home.bold", json!({"range": "A2:A6", "on": true})).unwrap(); + s.execute("review.newNote", json!({"cell": "A3", "text": "note", "author": "Example"})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + s.execute("data.filterBy", json!({"column": "B", "values": ["Active"]})).unwrap(); + let bold = |s: &Session, a: &str| { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(a).unwrap())).font.bold + }; + + s.execute("edit.clearContents", json!({"range": "A2:A6"})).unwrap(); + for a in ["A2", "A4", "A6"] { + assert_eq!(v(&s, a), Value::Empty, "{a} shows and is cleared"); + } + assert_eq!(v(&s, "A3"), Value::from("Mary")); + assert_eq!(v(&s, "A5"), Value::from("Sarah")); + + s.execute("edit.clearFormats", json!({"range": "A2:A6"})).unwrap(); + assert!(!bold(&s, "A2")); + assert!(bold(&s, "A3"), "a filtered-out row keeps its format"); + + s.execute("edit.clearAll", json!({"range": "A2:B6"})).unwrap(); + assert_eq!(v(&s, "B2"), Value::Empty); + assert_eq!(v(&s, "B3"), Value::from("Inactive")); + assert_eq!(v(&s, "A5"), Value::from("Sarah")); + assert!(s.doc().unwrap().wb.active().unwrap().comments.contains_key(&CellRef::parse("A3").unwrap())); + + // Without the filter, a row hidden by hand is cleared like the rows around it. + s.execute("data.clearFilter", json!({})).unwrap(); + s.execute("home.hideRows", json!({"rows": "5:5"})).unwrap(); + s.execute("edit.clearContents", json!({"range": "A3:A5"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Empty); + assert_eq!(v(&s, "A5"), Value::Empty); +} + /// Top 10 keeps the top or bottom N items or percent, ties at the threshold included. #[test] fn filter_top10_items_and_percent() { @@ -1965,6 +2007,15 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() { assert!(e.to_string().contains("pushed off the end"), "{e}"); } +#[test] +fn inserting_a_column_next_to_far_right_data_names_gridcraft_in_the_refusal() { + let mut sess = s(); + sess.execute("cell.set", json!({"cell": "XFD1", "input": "2"})).unwrap(); + let e = sess.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap_err(); + assert!(e.to_string().contains("GridCraft cannot shift nonblank cells"), "{e}"); + assert!(!e.to_string().contains("Excel"), "{e}"); +} + #[test] fn inserting_cells_refuses_to_push_values_off_the_sheet() { for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] { @@ -1973,6 +2024,7 @@ fn inserting_cells_refuses_to_push_values_off_the_sheet() { sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap(); let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err(); assert!(e.to_string().contains("loss of data"), "{e}"); + assert!(e.to_string().contains("GridCraft") && !e.to_string().contains("Excel"), "{e}"); assert_eq!(v(&sess, "A1"), Value::Number(1.0)); assert_eq!(v(&sess, edge), Value::Number(2.0)); // A value that still fits moves to the last valid cell. @@ -2406,8 +2458,7 @@ fn ods_import_keeps_cached_values_and_reports_limits() { #[test] #[cfg(not(target_arch = "wasm32"))] fn ods_import_never_reuses_source_as_save_target() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let bytes = ods_fixture(); @@ -2437,8 +2488,7 @@ fn ods_import_never_reuses_source_as_save_target() { #[test] #[cfg(not(target_arch = "wasm32"))] fn saving_an_opened_semicolon_csv_keeps_semicolons() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-csv-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let path = dir.join("cut list.csv"); @@ -2460,6 +2510,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the +/// raw values for automation: full precision, ISO dates and times. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { + let mut s = s(); + for (cell, input, code) in [ + ("A1", "1234.5", "$#,##0.00"), + ("B1", "0.123456789", "0.00"), + ("C1", "46023", "d-mmm-yy"), + ("D1", "46023.5", "yyyy-mm-dd hh:mm"), + ("E1", "0.75", "h:mm"), + ("F1", "TRUE", "General"), + ("G1", "a,b", "General"), + ("H1", "=1/0", "General"), + ("I1", "1", "h:mm"), + ("J1", "1.5", "h:mm"), + ("K1", "46023", "yyyy-mm-dd hh:mm"), + ("L1", "1.5", "[h]:mm"), + ("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap(); + } + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let displayed = dir.join("displayed.csv"); + s.execute("file.exportCsv", json!({"path": displayed})).unwrap(); + s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap(); + let raw = dir.join("raw.csv"); + s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap(); + let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); + let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(); + // The default export is byte for byte what Save As CSV writes, and `values: false` is the default. + assert_eq!(std::fs::read(&displayed).unwrap(), default); + assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default); + let default = String::from_utf8(default).unwrap(); + assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}"); + assert_eq!( + std::fs::read_to_string(&raw).unwrap(), + "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n" + ); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target). fn linked_workbook(link: &str) -> Vec { use std::io::{Cursor, Write}; @@ -2512,8 +2609,7 @@ fn link_target(path: &std::path::Path) -> String { #[test] #[cfg(not(target_arch = "wasm32"))] fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id())); let b = a.join("b"); std::fs::create_dir_all(&b).unwrap(); @@ -2541,8 +2637,7 @@ fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() { #[test] #[cfg(not(target_arch = "wasm32"))] fn saving_to_an_unknown_extension_is_refused() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let mut s = s(); @@ -2566,8 +2661,7 @@ fn saving_to_an_unknown_extension_is_refused() { #[test] #[cfg(not(target_arch = "wasm32"))] fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let path = dir.join("budget.dat"); @@ -2841,6 +2935,24 @@ fn checkboxes_keep_values_toggle_and_delete_in_two_passes() { assert_ne!(fmt_of(&s, "B2"), "checkbox"); } +/// A checked box in a row the filter hides doesn't hold back Delete on the boxes showing, and +/// stays as it is (#1419). +#[test] +fn delete_on_filtered_checkboxes_skips_the_hidden_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Status"], ["on"], ["off"], ["on"]]})).unwrap(); + s.execute("insert.checkbox", json!({"range": "B2:B4"})).unwrap(); + s.execute("cell.toggleCheckbox", json!({"cell": "B3"})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + s.execute("data.filter", json!({})).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["on"]})).unwrap(); + s.execute("edit.clearContents", json!({"range": "B2:B4"})).unwrap(); + assert_ne!(fmt_of(&s, "B2"), "checkbox", "the unchecked boxes showing are removed at once"); + assert_ne!(fmt_of(&s, "B4"), "checkbox"); + assert_eq!(fmt_of(&s, "B3"), "checkbox"); + assert_eq!(v(&s, "B3"), Value::Bool(true)); +} + #[test] fn checkboxes_survive_xlsx_as_cell_controls() { let mut s = s(); @@ -3240,8 +3352,7 @@ fn xlsb_import_keeps_cached_values_and_reports_limits() { #[test] #[cfg(not(target_arch = "wasm32"))] fn xlsb_import_never_reuses_source_as_save_target() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let bytes = xlsb_fixture(); @@ -4410,8 +4521,7 @@ fn encrypted_workbooks_open_with_their_password() { #[cfg(not(target_arch = "wasm32"))] fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { const PW: &str = "synthetic-pw-5522"; - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let path = dir.join("Secret.xlsx"); @@ -4544,8 +4654,7 @@ fn csv_as_text_keeps_identifier_columns_as_written() { #[test] #[cfg(not(target_arch = "wasm32"))] fn cell_filename_follows_the_file_the_workbook_lives_in() { - #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let nonce = unique_nonce(); let dir = std::env::temp_dir().join(format!("gridcraft-cellname-{}-{nonce}", std::process::id())); std::fs::create_dir(&dir).unwrap(); let name = "=CELL(\"filename\",A1)"; @@ -5093,6 +5202,7 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod macros; mod python_cells; mod python_commands; mod python_files; @@ -5120,3 +5230,10 @@ fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() { assert_eq!(v(&saved, "B1"), Value::Number(6.0)); assert_eq!(v(&saved, "C1"), Value::Number(7.0)); } + +/// A per-test unique suffix for temp dir names. +#[cfg(not(target_arch = "wasm32"))] +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) +} diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs new file mode 100644 index 000000000..aa17080fa --- /dev/null +++ b/crates/engine/src/tests/macros.rs @@ -0,0 +1,2436 @@ +//! Macros: running, input checks, the footprint dry run, the policy and the limits. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gridcraft_core::CellRef; +use serde_json::{Value as Json, json}; + +use super::{s, v}; +use crate::{EngineError, Session}; + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect() +} + +fn set(s: &mut Session, cell: &str, input: &str) { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); +} + +/// Puts a macro into the active workbook (replacing one with the same name). +pub(super) fn put(s: &mut Session, m: Json) { + let m = crate::macros::parse_macro(&m).unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.retain(|x| !x.name.eq_ignore_ascii_case(&m.name)); + wb.macros.push(m); + d.wb = Arc::new(wb); + d.saved = d.wb.clone(); +} + +/// Names in B2:B5 and amounts in C2:C5 under a header row. +fn data(s: &mut Session) { + for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "120"), ("B4", "Bo"), ("C4", "80"), ("B5", "Cy"), ("C5", "200")] { + set(s, cell, input); + } +} + +/// A macro that writes a label and a total next to its input and bolds the header. +fn totals() -> Json { + json!({ + "name": "Totals", + "inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}], + "output": {"anchor": "Input.right(2).cell(1,2)"}, + "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "Total"}}, + {"command": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}}, + {"command": "home.bold", "params": {"range": "Input.row(1)"}} + ] + }) +} + +fn bold(s: &Session, a: &str) -> bool { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(gridcraft_core::CellRef::parse(a).unwrap())).font.bold +} + +#[test] +fn a_run_resolves_tokens_and_is_one_undo_step() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let before = undo_labels(&s); + let r = s.execute("macro.run", json!({"name": "totals", "inputs": {"Input": "B2:C5"}})).unwrap(); + assert_eq!(r["ran"], 3); + assert_eq!(r["inputs"], json!({"Input": "B2:C5"})); + assert_eq!(v(&s, "E2").display(), "Total"); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().input_text(gridcraft_core::CellRef::parse("E3").unwrap()), "=SUM(C3:C5)"); + assert!(bold(&s, "B2") && bold(&s, "C2")); + let after = undo_labels(&s); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().unwrap(), "Run Macro: Totals"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E2").is_empty() && v(&s, "E3").is_empty() && !bold(&s, "B2")); + assert_eq!(undo_labels(&s), before); + // The selection is the default input. + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.run", json!({"name": "Totals"})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn inputs_are_checked_with_messages_that_name_the_cells() { + let mut s = s(); + data(&mut s); + set(&mut s, "C4", "eighty"); + set(&mut s, "C5", "TRUE"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input column 2 (Amount) must hold numbers; C4 holds text, C5 holds TRUE or FALSE."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing ran"); + + // Shape: width, rows, a single cell, blanks, dates. + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "header": true, "rows": [1, 2], "cols": [3, 3], "nonEmpty": true, "colTypes": ["any", "date"]}); + put(&mut s, m); + set(&mut s, "B4", ""); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + for needle in [ + "Input needs at most 2 data rows; B2:C5 has 3.", + "Input needs exactly 3 columns; B2:C5 has 2.", + "Input column 2 (Amount) must hold dates; C3 holds a number that isn't a date, C4 holds text, C5 holds TRUE or FALSE.", + "Input must have no blank cells; B4 is blank.", + ] { + assert!(err.contains(needle), "missing `{needle}` in {err}"); + } + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "kind": "cell"}); + put(&mut s, m); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input must be a single cell; B2:C5 is 8 cells."), "{err}"); + // A second input with no range, and a bad reference. + put(&mut s, json!({"name": "Two", "inputs": [{"name": "Input"}, {"name": "Rates"}], "steps": []})); + let err = s.execute("macro.run", json!({"name": "Two"})).unwrap_err().to_string(); + assert!(err.contains("Rates needs a range"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Two", "inputs": {"Rates": "Nowhere!A1"}})).unwrap_err().to_string(); + assert!(err.contains("Rates: There is no sheet named 'Nowhere'."), "{err}"); + // Whole columns shrink to the cells in use. + put( + &mut s, + json!({"name": "Col", "inputs": [{"name": "Input", "rows": [1, 10]}], "steps": [{"command": "cell.set", "params": {"cell": "Input.below", "input": "end"}}], "output": {"onConflict": "overwrite"}}), + ); + s.execute("macro.run", json!({"name": "Col", "inputs": "B:B"})).unwrap(); + assert_eq!(v(&s, "B6").display(), "end"); + assert!(s.execute("macro.run", json!({"name": "nope"})).unwrap_err().to_string().contains("no macro called 'nope'")); +} + +#[test] +fn an_error_rolls_everything_back_and_names_the_step() { + let mut s = s(); + data(&mut s); + let mut m = totals(); + m["output"]["onConflict"] = json!("overwrite"); + m["steps"][2] = json!({"type": "repeat", "over": "Input.data", "body": [ + {"command": "home.italic", "params": {"range": "Row"}}, + {"command": "cell.set", "params": {"cell": "Row.cell(1,9)", "input": "=1/"}} + ]}); + put(&mut s, m); + let (before, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.starts_with("Macro 'Totals' stopped. Step 3.2 (cell.set):"), "{err}"); + assert!(err.ends_with("Nothing was changed."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(undo_labels(&s), undo); + assert!(s.doc().unwrap().redo.is_empty()); +} + +#[test] +fn conflicts_ask_fail_or_overwrite() { + let mut s = s(); + data(&mut s); + set(&mut s, "E3", "keep me"); + // A formula that reads what the macro writes: its value changes, but it isn't written. + set(&mut s, "H1", "=E3"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("would write over cells that hold data (E3 on Sheet1)"), "{q}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "programmatic runs default to failing"); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).unwrap_err().to_string(); + assert!(err.contains("Nothing was changed"), "{err}"); + assert!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err()); + + // macro.check shows the footprint and the conflict, and changes nothing. + let journal = s.journal.len(); + let sel = s.doc().unwrap().selection.clone(); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); + // Under ask the run would ask first, so it wouldn't go ahead as it is. + assert_eq!((&c["ok"], &c["onConflict"]), (&json!(false), &json!("ask"))); + assert_eq!(c["errors"], json!(["It would write over cells that hold data (E3 on Sheet1); the run asks first."])); + assert_eq!(c["conflicts"], 1); + assert_eq!(c["output"], "E2"); + assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); + assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + // With the answer given, or with the rule overwrite, it would. + for p in [json!({"overwrite": true}), json!({"onConflict": "overwrite"})] { + let mut p = p; + (p["name"], p["inputs"]) = (json!("Totals"), json!("B2:C5")); + let c = s.execute("macro.check", p.clone()).unwrap(); + assert_eq!((&c["ok"], &c["onConflict"], c.get("errors")), (&json!(true), &json!("overwrite"), None), "{p}"); + } + + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(v(&s, "H1").display(), "400"); +} + +/// The value of `cell` on the sheet called `sheet`. +fn on(s: &Session, sheet: &str, cell: &str) -> String { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap()).display() +} + +#[test] +fn inputs_and_the_output_follow_their_cells_when_a_step_moves_them() { + // A sheet inserted in front: the input is still on Sheet1, not on whatever sheet is first now. + let mut s = s(); + let steps = json!([ + {"command": "home.insertSheet", "params": {"name": "Summary", "before": 0}}, + {"command": "cell.set", "params": {"cell": "Input", "input": "x"}} + ]); + put( + &mut s, + json!({"name": "Front", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "overwrite"}, "steps": steps.clone()}), + ); + s.execute("macro.run", json!({"name": "Front", "inputs": "Sheet1!B2"})).unwrap(); + assert_eq!((on(&s, "Sheet1", "B2"), on(&s, "Summary", "B2")), ("x".into(), "".into())); + + // Data in the input: the step writes into its own input, which isn't data written over, on + // the input's sheet wherever it moved. + let mut s = super::s(); + set(&mut s, "B2", "held"); + put(&mut s, json!({"name": "Front", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "fail"}, "steps": steps})); + s.execute("macro.run", json!({"name": "Front", "inputs": "Sheet1!B2"})).unwrap(); + assert_eq!(on(&s, "Sheet1", "B2"), "x"); + + // Rows inserted above: the input and the output move down with their cells. + let mut s = super::s(); + set(&mut s, "B5", "in"); + let steps = json!([ + {"command": "home.insertRows", "params": {"rows": "2:3"}}, + {"command": "cell.set", "params": {"cell": "Input", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Output", "input": "y"}} + ]); + put( + &mut s, + json!({"name": "Down", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"anchor": "D5", "onConflict": "overwrite"}, "steps": steps}), + ); + s.execute("macro.run", json!({"name": "Down", "inputs": "B5"})).unwrap(); + assert_eq!((v(&s, "B7").display(), v(&s, "D7").display()), ("x".into(), "y".into())); + assert_eq!((v(&s, "B5").display(), v(&s, "D5").display()), ("".into(), "".into())); + + // A step that deletes an input's cells stops the run. + let mut s = super::s(); + set(&mut s, "B5", "in"); + let steps = json!([{"command": "home.deleteRows", "params": {"rows": "5"}}, {"command": "cell.set", "params": {"cell": "Input", "input": "x"}}]); + put(&mut s, json!({"name": "Gone", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "overwrite"}, "steps": steps})); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Gone", "inputs": "B5"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (home.deleteRows): It deleted the cells of Input."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + + // A step inside a Repeat that changes the size of the range it goes over stops the run. + let mut s = super::s(); + let body = json!([{"command": "home.insertRows", "params": {"rows": "2"}}]); + put(&mut s, json!({"name": "Grow", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A3", "body": body}]})); + let err = s.execute("macro.run", json!({"name": "Grow"})).unwrap_err().to_string(); + assert!(err.contains("Step 1.1 (home.insertRows): It changed the size of the range Repeat 1 goes over."), "{err}"); +} + +#[test] +fn shifting_makes_room_and_is_checked_again() { + for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] { + let mut s = s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + set(&mut s, "E3", "old 2"); + put(&mut s, totals()); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap(); + assert_eq!(r["shifted"], rule); + assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}"); + assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}"); + // The shift and the run are one undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "E2").display(), "old 1", "{rule}"); + assert!(v(&s, moved[1]).is_empty(), "{rule}"); + } + // A cell written with what it already holds isn't a conflict, but it moves with the others, + // so rows stay together. + let mut s = super::s(); + for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] { + set(&mut s, cell, input); + } + put( + &mut s, + json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [ + {"command": "cell.set", "params": {"cell": "F2", "input": "a"}}, + {"command": "cell.set", "params": {"cell": "G2", "input": "b"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap(); + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"]))); + s.execute("macro.run", json!({"name": "Pair"})).unwrap(); + let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display()); + assert_eq!(row(&s, 2), ("a".into(), "b".into())); + assert_eq!(row(&s, 3), ("x".into(), "b".into())); + assert_eq!(row(&s, 4), ("f3".into(), "g3".into())); + // An anchor that moves with the shifted data still lands on it: the second check refuses. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap(); + put( + &mut s, + json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); + assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // The check shifts and checks again as the run does, and puts everything back, the document's + // revision included. + let journal = s.journal.len(); + let revision = s.doc().unwrap().revision; + let c = s.execute("macro.check", json!({"name": "Named"})).unwrap(); + assert_eq!((&c["ok"], &c["errors"]), (&json!(false), &json!(["Shifting the data doesn't make room (E3 on Sheet1)."])), "{c}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!((s.journal.len(), s.doc().unwrap().revision), (journal, revision)); + // Where the shift does make room, the check says the run would go ahead. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + put(&mut s, totals()); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((&c["ok"], c.get("errors")), (&json!(true), None), "{c}"); +} + +#[test] +fn stop_action_stops_a_macro_being_debugged() { + // Stop Action rolls back the open undo groups, the debugger's among them; the run ends with + // its group, and the answer says so. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let (start, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(v(&s, "E2").display(), "Total"); + let r = s.execute("automate.stopAction", json!({})).unwrap(); + assert_eq!((r["stopped"].as_bool(), r["macro"].as_str()), (Some(true), Some("Totals")), "{r}"); + assert!(r["message"].as_str().unwrap().contains("was stopped and rolled back"), "{r}"); + assert!(s.macro_debug.is_none(), "the debugger ended with its group"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!(undo_labels(&s), undo); + assert!(!s.in_group(s.doc().unwrap().uid)); + set(&mut s, "A1", "free"); + assert!(s.execute("macro.debug", json!({"action": "step"})).unwrap_err().to_string().contains("No macro is being debugged")); + // Stop Action on another workbook leaves the debugger alone. + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5"})).unwrap(); + s.new_workbook(); + let r = s.execute("automate.stopAction", json!({})).unwrap(); + assert_eq!(r, json!({"stopped": false})); + assert!(s.macro_debug.is_some()); +} + +#[test] +fn the_debugger_runs_on_its_own_and_is_never_recorded() { + // As a step of a script, the debugger would outlive the script's rollback of its groups. + let mut s = s(); + stepper(&mut s); + let start = s.doc().unwrap().wb.clone(); + let commands = json!([ + {"command": "macro.debug", "params": {"action": "start", "name": "Walk", "inputs": "B2:C5"}}, + {"command": "cell.set", "params": {"cell": "A1", "input": "after"}} + ]); + s.execute("automate.newScript", json!({"name": "S", "commands": commands})).unwrap(); + let after_script = s.doc().unwrap().wb.clone(); + let e = s.execute("automate.runScript", json!({"name": "S"})).unwrap_err().to_string(); + assert!(e.contains("runs on its own"), "{e}"); + assert!(s.macro_debug.is_none()); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &after_script)); + assert!(!s.in_group(s.doc().unwrap().uid)); + set(&mut s, "A1", "free"); + assert!(!Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // Neither recorder keeps it. + s.start_recording(); + s.execute("automate.recordActions", json!({})).unwrap(); + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "stop"})); + let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "R"})).unwrap(); + assert_eq!(saved["steps"], 0, "{saved}"); + let recorded = s.stop_recording().unwrap(); + assert!(!recorded.steps.iter().any(|st| st.id == "macro.debug"), "{:?}", recorded.steps); +} + +#[test] +fn a_debugger_whose_group_was_closed_elsewhere_locks_nothing() { + let mut s = s(); + stepper(&mut s); + let uid = s.doc().unwrap().uid; + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // As if a command had rolled back the open groups without telling the debugger. + assert!(s.abort_groups(uid)); + set(&mut s, "A1", "free"); + assert!(s.macro_debug.is_none(), "the lock let the edit through and ended the debugger"); + // A debugger action says the run had ended; nothing is reported as kept or rolled back. + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + assert!(s.abort_groups(uid)); + let e = s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap_err().to_string(); + assert!(e.contains("no longer being debugged"), "{e}"); + assert!(s.macro_debug.is_none()); + // Starting again works at once. + debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"})); + assert!(s.abort_groups(uid)); + assert_eq!(debug(&mut s, json!({"action": "start", "name": "Walk", "inputs": "B2:C5"}))["state"], "paused"); +} + +/// Whether `a` on sheet `sheet` is bold. +fn bold_on(s: &Session, sheet: &str, a: &str) -> bool { + let d = s.doc().unwrap(); + let sh = d.wb.sheet(d.wb.sheet_index(sheet).unwrap()).unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).font.bold +} + +#[test] +fn between_steps_only_looking_around_runs_and_the_run_continues_where_it_was() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + put( + &mut s, + json!({"name": "Sel", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "selection.set", "params": {"range": "C3"}}, + {"command": "home.bold", "params": {}} + ]}), + ); + debug(&mut s, json!({"action": "start", "name": "Sel"})); + debug(&mut s, json!({"action": "step"})); + // Looking around runs: another sheet, another selection, Go To, Find, Copy, zoom. + s.execute("sheet.activate", json!({"sheet": "Other"})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "B2"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("view.zoom", json!({"percent": 150})).unwrap(); + assert!(s.execute("selection.stats", json!({})).is_ok(), "queries run"); + // Anything else waits, undoable or not, saving included. + let path = std::env::temp_dir().join(format!("gc-macro-debug-save-{}.xlsx", std::process::id())); + let busy = |r: Result| matches!(&r, Err(EngineError::Disabled(_, why)) if why == crate::macros::debug::BUSY); + assert!(busy(s.execute("view.customViews", json!({"save": "Mine"})))); + assert!(busy(s.execute("view.pageLayout", json!({})))); + assert!(busy(s.execute("file.saveAs", json!({"path": path.to_string_lossy()})))); + assert!(busy(s.execute("file.save", json!({})))); + assert!(busy(s.execute("automate.recordActions", json!({})))); + assert!(busy(s.execute("cell.set", json!({"cell": "A1", "input": "x"})))); + // A check would dry-run the macro's steps, which the lock refuses: it waits too. + assert!(busy(s.execute("macro.check", json!({"name": "Sel"})))); + assert!(!path.exists(), "nothing was saved"); + // The next step runs on the run's sheet and selection, as macro.run would. + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "finished", "{st}"); + assert!(bold_on(&s, "Sheet1", "C3")); + assert!(!bold_on(&s, "Other", "B2") && !bold_on(&s, "Other", "A1")); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet(), d.selection.active), (0, CellRef::parse("C3").unwrap())); +} + +#[test] +fn a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on() { + crate::register_test_command("test.debugInnerPanics", |_, _| panic!("inner")); + // A step whose command catches a nested command's panic as an error and goes on editing. + crate::register_test_command("test.debugSwallowsPanic", |s, _| { + let _ = s.execute("test.debugInnerPanics", json!({})); + s.execute("cell.set", json!({"cell": "B1", "input": "after"})) + }); + let mut s = s(); + put( + &mut s, + json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"command": "test.debugSwallowsPanic", "params": {}} + ]}), + ); + let undo = undo_labels(&s); + debug(&mut s, json!({"action": "start", "name": "M"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "finished", "{st}"); + assert_eq!((v(&s, "A1").display(), v(&s, "B1").display()), ("x".into(), "after".into())); + let after = undo_labels(&s); + assert_eq!((after.len(), after.last().map(String::as_str)), (undo.len() + 1, Some("Run Macro: M")), "{after:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); +} + +#[test] +fn debugger_parameters_are_checked_before_anything_changes() { + let mut s = s(); + stepper(&mut s); + let start = |bps: Json| json!({"action": "start", "name": "Walk", "inputs": "B2:C5", "breakpoints": bps}); + let e = s.execute("macro.debug", start(json!(["9"]))).unwrap_err().to_string(); + assert!(e.contains("has no step 9 for a breakpoint"), "{e}"); + let many: Vec = (0..10_001).map(|_| "1".to_string()).collect(); + let e = s.execute("macro.debug", start(json!(many))).unwrap_err().to_string(); + assert!(e.contains("at most 10000"), "{e}"); + assert!(s.macro_debug.is_none() && !s.in_group(s.doc().unwrap().uid)); + debug(&mut s, start(json!([]))); + // `keep` is true or false: "true" would have read as false and rolled the run back. + assert!(s.execute("macro.debug", json!({"action": "stop", "keep": "true"})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "step", "breakpointsOnly": "yes"})).is_err()); + assert!(s.execute("macro.debug", json!({"action": 5})).is_err()); + // An unknown action or step path changes nothing, breakpoints included. + assert!(s.execute("macro.debug", json!({"action": "leap", "breakpoints": ["2"]})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "step", "breakpoints": ["2.x"]})).is_err()); + assert!(s.execute("macro.debug", json!({"action": "runTo", "step": "9", "breakpoints": ["2"]})).is_err()); + let st = debug(&mut s, json!({"action": "state"})); + assert_eq!((st["current"]["step"].as_str(), &st["breakpoints"]), (Some("1"), &json!([])), "{st}"); + // `breakpointsOnly` alone applies to the list the run has (none here): the flag on 3.1 no + // longer stops it. + let st = debug(&mut s, json!({"action": "continue", "breakpointsOnly": true})); + assert_eq!(st["state"], "finished", "{st}"); +} + +#[test] +fn the_debuggers_answers_count_its_own_steps_and_say_what_stays() { + // `executed` counts the run's steps, not the footprint's dry run before it. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let st = debug(&mut s, json!({"action": "start", "name": "Totals", "inputs": "B2:C5"})); + assert_eq!(st["executed"], 0, "{st}"); + assert_eq!(debug(&mut s, json!({"action": "step"}))["executed"], 1); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["state"].as_str(), st["executed"].as_u64()), (Some("finished"), Some(3)), "{st}"); + // A shift at the start is reported, as macro.run reports it. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + put(&mut s, totals()); + let st = debug(&mut s, json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "onConflict": "shiftDown"})); + assert_eq!(st["shifted"], "shiftDown", "{st}"); + assert_eq!(debug(&mut s, json!({"action": "stop"}))["shifted"], "shiftDown"); + // The files the run wrote stay when the workbook is put back: a failed step, a Stop step + // that rolls back, and stopping without keeping each say so. + let dir = std::env::temp_dir().join(format!("gc-macro-debug-files-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let export = json!({"command": "file.exportCsv", "params": {"path": format!("{}/out.csv", dir.to_string_lossy())}}); + for (last, how) in [ + (json!({"command": "cell.set", "params": {"cell": "Nowhere!A2", "input": "two"}}), "failed"), + (json!({"type": "stop", "message": "undo", "rollback": true}), "stopped"), + (json!({"command": "cell.set", "params": {"cell": "A2", "input": "two"}}), "stop"), + ] { + let mut s = super::s(); + put(&mut s, json!({"name": "F", "output": {"onConflict": "overwrite"}, "permissions": ["file.exportCsv"], "steps": [export, last]})); + debug(&mut s, json!({"action": "start", "name": "F", "approve": true})); + debug(&mut s, json!({"action": "step"})); + let st = if how == "stop" { debug(&mut s, json!({"action": "stop"})) } else { debug(&mut s, json!({"action": "step"})) }; + let msg = st["message"].as_str().unwrap_or_default(); + assert!(msg.contains("the file step 1 (file.exportCsv) wrote stays") && !msg.contains("Nothing was changed"), "{how}: {st}"); + assert_eq!(st["wroteFiles"], json!(["step 1 (file.exportCsv)"]), "{how}: {st}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Sheet3"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + put( + &mut s, + json!({"name": "Sheets", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "sheet.rename", "params": {"sheet": "Sheet2", "name": "Renamed"}}, + {"command": "home.deleteSheet", "params": {"sheet": "Sheet3"}}, + {"command": "cell.set", "params": {"cell": "A1", "input": "end"}} + ]}), + ); + debug(&mut s, json!({"action": "start", "name": "Sheets"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + let changed = st["changed"].as_array().unwrap(); + assert!(changed.contains(&json!("Renamed (renamed from Sheet2)")), "{st}"); + assert!(changed.contains(&json!("Sheet3 (deleted sheet)")), "{st}"); + assert!(!changed.iter().any(|c| c.as_str().is_some_and(|c| c.contains("(new sheet)"))), "{st}"); + // As if a step had written cells that make more ranges than the answer lists, on two sheets. + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + for sh in wb.sheets.iter_mut() { + let sh = Arc::make_mut(sh); + for r in 0..600u32 { + sh.set_value(CellRef::new(r + 10, r % 2), gridcraft_core::Value::Number(1.0)); + } + } + d.wb = Arc::new(wb); + let st = debug(&mut s, json!({"action": "state"})); + assert_eq!(st["changed"].as_array().unwrap().len(), 500, "{}", st["changed"].as_array().unwrap().len()); + assert_eq!(st["changedCells"], 1200); + debug(&mut s, json!({"action": "stop"})); +} + +#[test] +fn the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Cols", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "repeat", "over": "B:C", "of": "columns", "body": [{"command": "cell.set", "params": {"cell": "Z1", "input": "x"}}]} + ]}), + ); + let st = debug(&mut s, json!({"action": "start", "name": "Cols"})); + assert_eq!(st["current"]["over"], "B2:C5", "{st}"); + debug(&mut s, json!({"action": "stop"})); +} + +#[test] +fn closing_the_debugged_workbook_ends_the_debugger() { + // `file.close` is not an edit, so the debugger's lock lets it through; the group went with + // the document, and so must the debugger, or the next workbook could not debug anything and + // the state would report that no document is open. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.macro_debug.is_some()); + s.execute("file.close", json!({"force": true})).unwrap(); + assert!(s.macro_debug.is_none(), "the debugger ended with its workbook"); + s.new_workbook(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.execute("macro.debug", json!({})).is_ok(), "the new workbook can be debugged"); + s.execute("macro.debug", json!({"action": "stop"})).unwrap(); +} + +#[test] +fn a_shift_that_would_move_an_input_is_refused() { + // A shift inserts cells over the conflicting block, which moves the block's columns (or rows) + // from there on. When one of the macro's own inputs lies in the way, its data would move and + // shear while the run keeps the old coordinates, so the shift is refused and nothing changes. + let mut s = s(); + data(&mut s); + set(&mut s, "B1", "Old title"); + put( + &mut s, + json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed"); + assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into())); + // Writing left of the input and shifting right would move it too. + put( + &mut s, + json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}} + ]}), + ); + set(&mut s, "A3", "in the way"); + let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // The output counts as well. + put( + &mut s, + json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // A shift that moves nothing of the macro's own still works, over the block of every written + // cell, so a row the macro writes stays together. + set(&mut s, "H1", "elsewhere"); + put( + &mut s, + json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "H1", "input": "over"}}, + {"command": "cell.set", "params": {"cell": "H9", "input": "far"}} + ]}), + ); + s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into())); + assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); +} + +#[test] +fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() { + // The touch log keeps at most a million writes; past that, a write that kept a cell's value + // is missing from the footprint, so a shift could split a row the macro writes. The footprint + // says so and the shift is refused (overwrite, with no dry run, still runs). + let mut s = s(); + set(&mut s, "A1", "x"); + set(&mut s, "E2", "old"); + put( + &mut s, + json!({"name": "Flood", "steps": [ + {"command": "edit.fillDown", "params": {"range": "A1:A1000001"}}, + {"command": "cell.set", "params": {"cell": "E2", "input": "new"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap(); + assert_eq!(c["footprint"]["logFull"], true, "{c}"); + let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("more cells than the footprint can follow"), "{err}"); + assert_eq!(v(&s, "E2").display(), "old", "nothing changed"); +} + +#[test] +fn a_dry_run_leaves_a_clean_workbook_clean() { + // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean + // document; the dry run's rollback puts the workbook back, so the mark must come back too. + let mut s = s(); + data(&mut s); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(!s.doc().unwrap().is_dirty()); + put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]})); + s.execute("macro.check", json!({"name": "Look"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing"); + // A run refused by the conflict rule changed nothing either. + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err()); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[test] +fn a_dry_run_cut_short_by_a_panic_leaves_journaling_on() { + // A panic inside a command that started a dry run, nested in another command: the panic + // guard of the nested call puts the pause and the touch log back, so the session journals + // the person's commands again. + crate::cmd::register_undoable_test_command("test.dryRunThenPanic", |s, _| { + s.journal_paused = s.journal_paused.saturating_add(1); + if let Ok(d) = s.doc_mut() { + d.touched = Some(Vec::new()); + } + panic!("in a dry run"); + }); + let mut s = s(); + let err = s.execute_nested("test.dryRunThenPanic", json!({})).unwrap_err().to_string(); + assert!(err.contains("in a dry run"), "{err}"); + assert_eq!((s.journal_paused, s.doc().unwrap().touched.is_none()), (0, true)); + let n = s.journal.len(); + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + assert_eq!(s.journal.len(), n + 1, "journaled again"); +} + +#[test] +fn a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were() { + // A dry run journals nothing: journaling its nested commands would push a full journal past + // its limit and evict the person's own oldest entries, and move Record Actions' start. + let mut s = s(); + data(&mut s); + for i in 0..crate::JOURNAL_MAX_ENTRIES { + s.execute("selection.set", json!({"range": format!("A{}", i % 50 + 1)})).unwrap(); + } + s.recording = Some(s.journal.len().saturating_sub(100)); + put( + &mut s, + json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ + {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} + ]}]}), + ); + let (journal, recording) = (s.journal.clone(), s.recording); + let c = s.execute("macro.check", json!({"name": "Long"})).unwrap(); + assert!(c.get("footprint").is_some(), "{c}"); + assert_eq!((s.journal.len(), s.recording), (journal.len(), recording)); + assert!(s.journal == journal, "the journal is as it was"); + // The check itself is a query and isn't journaled; a command after it is. + s.execute("selection.set", json!({"range": "B1"})).unwrap(); + assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("selection.set")); +} + +#[test] +fn each_step_is_checked_for_writes_over_data() { + let dir = std::env::temp_dir().join(format!("gc-macro-guard-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("out.csv").to_string_lossy().to_string(); + let export = json!({"command": "file.exportCsv", "params": {"path": csv}}); + let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}}); + let late = json!({"command": "cell.set", "params": {"cell": "B1", "input": "over"}}); + // A file step or Solver, then a step that writes over B1: the run stops there. + for first in [&export, &solver] { + let mut s = super::s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + set(&mut s, "B1", "keep"); + put(&mut s, json!({"name": "Late", "inputs": [{"name": "Input"}], "permissions": ["file.exportCsv"], "steps": [first, late]})); + let start = s.doc().unwrap().wb.clone(); + for rule in ["fail", "ask"] { + let r = s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "onConflict": rule, "approve": true})); + let e = match (rule, r) { + ("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e, + (_, r) => panic!("{first} {rule}: {r:?}"), + }; + assert!(e.contains("Step 2 (cell.set): It would write over data (B1 on Sheet1)"), "{e}"); + // The workbook is put back; a file the run wrote stays, and the message says so. + let left = + if first == &export { "The workbook was put back; the file step 1 (file.exportCsv) wrote stays." } else { "Nothing was changed." }; + assert!(e.ends_with(left), "{e}"); + assert_eq!(v(&s, "B1").display(), "keep", "{first} {rule}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{first} {rule}: rolled back"); + } + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "B1").display(), "over"); + } + // A step that writes over what the macro itself wrote isn't a conflict. + let mut s = super::s(); + let again = |input: &str| json!({"command": "cell.set", "params": {"cell": "E1", "input": input}}); + put( + &mut s, + json!({"name": "Twice", "permissions": ["file.exportCsv"], "output": {"onConflict": "fail"}, "steps": [again("a"), export, again("b")]}), + ); + s.execute("macro.run", json!({"name": "Twice", "approve": true})).unwrap(); + assert_eq!(v(&s, "E1").display(), "b"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_run_that_takes_another_path_than_its_check_is_still_checked() { + // RAND gives the run other values than the dry run before it, so an If can go the other way: + // the write the footprint didn't see must still not go over data. + let mut s = s(); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "A1", "input": "=RAND()"}}, + {"type": "if", "cell": "A1", "op": "gt", "value": 0.5, "then": [{"command": "cell.set", "params": {"cell": "B1", "input": "over"}}]} + ]); + put(&mut s, json!({"name": "Coin", "output": {"onConflict": "fail"}, "steps": steps})); + let mut outcomes = BTreeSet::new(); + for _ in 0..40 { + set(&mut s, "B1", "keep"); + let r = s.execute("macro.run", json!({"name": "Coin"})); + assert_eq!(v(&s, "B1").display(), "keep", "{r:?}"); + outcomes.insert(match r { + Ok(_) => "ran", + Err(e) if e.to_string().contains("would write over") => "refused", + Err(e) => panic!("{e}"), + }); + } + assert_eq!(outcomes.len(), 2, "both paths were taken: {outcomes:?}"); +} + +#[test] +fn run_parameters_that_would_be_ignored_are_refused() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + for (p, says) in [ + (json!({"inputs": {"Inptu": "B2:C5"}}), "it has no input called 'Inptu' (its inputs: Input)."), + (json!({"inputs": {"Input": 5}}), "give the input Input as reference text"), + (json!({"inputs": ["B2:C5"]}), "give `inputs` as reference text"), + (json!({"inputs": "B2:C5", "onConflict": 1}), "`onConflict` must be"), + (json!({"inputs": "B2:C5", "overwrite": "yes"}), "`overwrite` must be true or false."), + (json!({"inputs": "B2:C5", "approve": 1}), "`approve` must be true or false."), + (json!({"inputs": "B2:C5", "timeLimit": "30"}), "`timeLimit` must be a number of seconds above 0."), + (json!({"inputs": "B2:C5", "timeLimit": 0}), "`timeLimit` must be a number of seconds above 0."), + ] { + let mut p = p; + p["name"] = json!("Totals"); + let err = s.execute("macro.run", p.clone()).unwrap_err().to_string(); + assert!(err.contains(&format!("Macro 'Totals': {says}")), "{p}: {err}"); + } + put(&mut s, json!({"name": "None", "steps": []})); + let err = s.execute("macro.run", json!({"name": "None", "inputs": "A1"})).unwrap_err().to_string(); + assert!(err.contains("Macro 'None': it takes no inputs."), "{err}"); + // `null` is the same as not given, and an input's name matches in any case. + let p = json!({"name": "Totals", "inputs": {"input": "B2:C5"}, "onConflict": null, "overwrite": true, "timeLimit": null}); + s.execute("macro.run", p).unwrap(); +} + +#[test] +fn a_run_passes_a_bounded_amount_of_parameters() { + // A step with a 3 MB parameter in a Repeat of 100 rows would hand its command 300 MB; the run + // stops at 256 MB, which matters on the web build, where there is no time limit. + let mut s = s(); + let big = "x".repeat(3_000_000); + let body = json!([{"command": "home.bold", "params": {"range": "A1", "note": big}}]); + put(&mut s, json!({"name": "Big", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A100", "body": body}]})); + let err = s.execute("macro.run", json!({"name": "Big"})).unwrap_err().to_string(); + assert!(err.contains("passed more than 256 MB of parameters to its steps"), "{err}"); + // 50 rows are 150 MB: the run alone stays under the limit, the dry run and the run together + // go past it, as they share the step budget. + let body = json!([{"command": "home.bold", "params": {"range": "A1", "note": "x".repeat(3_000_000)}}]); + for (rule, ok) in [("overwrite", true), ("fail", false)] { + let repeat = json!({"type": "repeat", "over": "A1:A50", "body": body}); + put(&mut s, json!({"name": "Half", "output": {"onConflict": rule}, "steps": [repeat]})); + assert_eq!(s.execute("macro.run", json!({"name": "Half"})).is_ok(), ok, "{rule}"); + } +} + +#[test] +fn the_check_says_whether_the_run_would_go_ahead() { + let mut s = s(); + // A Stop step that rolls the run back. + put(&mut s, json!({"name": "Halt", "output": {"onConflict": "fail"}, "steps": [{"type": "stop", "message": "Not today.", "rollback": true}]})); + let c = s.execute("macro.check", json!({"name": "Halt"})).unwrap(); + assert_eq!((&c["ok"], &c["errors"]), (&json!(false), &json!(["A Stop step ends the run and rolls it back: Not today."]))); + // A shift that would move an input. + set(&mut s, "B1", "taken"); + set(&mut s, "B3", "in"); + let step = json!({"command": "cell.set", "params": {"cell": "B1", "input": "new"}}); + put(&mut s, json!({"name": "Move", "inputs": [{"name": "Input", "kind": "cell"}], "output": {"onConflict": "shiftDown"}, "steps": [step]})); + let c = s.execute("macro.check", json!({"name": "Move", "inputs": "B3"})).unwrap(); + assert_eq!(c["ok"], false); + assert!(c["errors"][0].as_str().unwrap().starts_with("Shifting the data would move Input (Sheet1!B3)."), "{c}"); + // A macro whose dry run takes 90,000 steps passes its own dry run, but the run spends the + // budget twice under fail, so the check says it wouldn't finish. + let spin = json!([{"type": "repeat", "over": "A1:A300", "body": [{"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]}]}]); + for (rule, ok) in [("fail", false), ("overwrite", true)] { + put(&mut s, json!({"name": "Spin", "output": {"onConflict": rule}, "steps": spin})); + let c = s.execute("macro.check", json!({"name": "Spin"})).unwrap(); + assert_eq!(c["ok"], ok, "{rule}: {c}"); + assert_eq!(s.execute("macro.run", json!({"name": "Spin"})).is_ok(), ok, "{rule}: the run agrees"); + } + let c = s.execute("macro.check", json!({"name": "Spin", "onConflict": "fail"})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().starts_with("The run would execute more than 100000 steps, counting the check before it"), "{c}"); + // Parameters the run would refuse, the check reports. + let c = s.execute("macro.check", json!({"name": "Spin", "onConflict": 5})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("`onConflict` must be"), "{c}"); +} + +#[test] +fn the_question_after_a_partial_footprint_says_what_the_answer_covers() { + let dir = std::env::temp_dir().join(format!("gc-macro-unseen-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("out.csv").to_string_lossy().to_string(); + let mut s = s(); + set(&mut s, "B1", "keep"); + set(&mut s, "C1", "also"); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "B1", "input": "over"}}, + {"command": "file.exportCsv", "params": {"path": csv}}, + {"command": "cell.set", "params": {"cell": "C1", "input": "over"}} + ]); + put(&mut s, json!({"name": "Two", "permissions": ["file.exportCsv"], "steps": steps})); + let r = s.execute("macro.run", json!({"name": "Two", "approve": true})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("(B1 on Sheet1). The check stopped at step 2 (file.exportCsv writes a file), so overwriting also lets the steps after it write over data unchecked."), "{q}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_dry_run_puts_back_the_go_to_list() { + let mut s = s(); + let step = json!({"command": "edit.goTo", "params": {"reference": "D4", "record": true}}); + put(&mut s, json!({"name": "Go", "steps": [step]})); + s.execute("macro.check", json!({"name": "Go"})).unwrap(); + assert_eq!(s.doc().unwrap().go_to_recent, Vec::::new()); +} + +#[test] +fn the_answer_to_the_question_counts_however_ask_was_given() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let p = json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "ask"}); + assert!(matches!(s.execute("macro.run", p.clone()), Err(EngineError::Confirm(_)))); + let mut answered = p; + answered["overwrite"] = json!(true); + s.execute("macro.run", answered).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn the_step_limit_is_one_budget_across_the_dry_run_and_the_run() { + // 300 x 300 passes is under the limit for one phase; the dry run and the run together are not. + let mut s = s(); + put( + &mut s, + json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [ + {"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("more than 100000 steps"), "{err}"); + // With no dry run (overwrite), the same macro fits. + s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap(); +} + +#[test] +fn the_footprint_covers_other_sheets_objects_and_edges() { + let mut s = s(); + data(&mut s); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + set(&mut s, "A1", "note"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap(); + let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id; + put( + &mut s, + json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}}, + {"command": "home.insertSheet", "params": {"name": "Extra"}}, + {"command": "sheet.activate", "params": {"sheet": "Sheet1"}}, + {"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}}, + {"command": "object.delete", "params": {"kind": "chart", "id": chart}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap(); + let fp = &c["footprint"]; + assert_eq!( + fp["sheets"], + json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}]) + ); + assert_eq!(fp["sheetsAdded"], json!(["Extra"])); + assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1))); + assert_eq!(c["conflicts"], 2, "the deleted chart and the note"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away"); + let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string(); + assert!(err.contains("(A1 on Notes; 1 object)"), "{err}"); + + // Running past the edge of the sheet fails before anything changes. + put( + &mut s, + json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}), + ); + let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}"); + let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap(); + assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true))); +} + +#[test] +fn repeat_if_and_stop() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Flag", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "repeat", "over": "Input.data", "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, + "then": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "big"}}], + "else": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "small"}}]}, + {"type": "if", "cell": "Row.cell(1,1)", "op": "eq", "value": "bo", "then": [{"type": "stop", "message": "Found Bo."}]} + ]}, + {"command": "cell.set", "params": {"cell": "A1", "input": "not reached"}} + ]}), + ); + let r = s.execute("macro.run", json!({"name": "Flag", "inputs": "B2:C5"})).unwrap(); + assert_eq!(r["stopped"], "Found Bo."); + assert_eq!((v(&s, "D3").display(), v(&s, "D4").display()), ("big".into(), "small".into())); + assert!(v(&s, "D5").is_empty() && v(&s, "A1").is_empty()); + assert_eq!(undo_labels(&s).last().unwrap(), "Run Macro: Flag", "a stop keeps the changes"); + // A Stop that rolls back is an error that changes nothing. + put( + &mut s, + json!({"name": "Guard", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"type": "if", "cell": "C3", "op": "notEmpty", "then": [{"type": "stop", "message": "C3 must be empty.", "rollback": true}]} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Guard"})).unwrap_err().to_string(); + assert_eq!(err, "Macro 'Guard' stopped: C3 must be empty. Nothing was changed."); + assert!(v(&s, "A1").is_empty()); + // Comparisons. + for (op, value, want) in [ + ("eq", json!(120), true), + ("eq", json!("120"), true), + ("ne", json!(120), false), + ("ge", json!(120), true), + ("lt", json!(120.5), true), + ("le", json!(100), false), + ("contains", json!("12"), true), + ("empty", json!(null), false), + ] { + put( + &mut s, + json!({"name": "Cmp", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "if", "cell": "C3", "op": op, "value": value, "then": [{"command": "cell.set", "params": {"cell": "A9", "input": "yes"}}], "else": [{"command": "cell.set", "params": {"cell": "A9", "input": "no"}}]} + ]}), + ); + s.execute("macro.run", json!({"name": "Cmp"})).unwrap(); + assert_eq!(v(&s, "A9").display(), if want { "yes" } else { "no" }, "{op} {value}"); + } + // If needs one cell. + put(&mut s, json!({"name": "Many", "steps": [{"type": "if", "cell": "A1:A2", "op": "empty"}]})); + assert!(s.execute("macro.run", json!({"name": "Many"})).unwrap_err().to_string().contains("If looks at one cell")); + // Disabled steps are skipped. + put( + &mut s, + json!({"name": "Off", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A8", "input": "x"}, "enabled": false}]}), + ); + assert_eq!(s.execute("macro.run", json!({"name": "Off"})).unwrap()["ran"], 0); +} + +#[test] +fn macros_are_bounded() { + let mut s = s(); + put(&mut s, json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A20", "max": 10, "body": [{"type": "stop"}]}]})); + let err = s.execute("macro.run", json!({"name": "Long"})).unwrap_err().to_string(); + assert!(err.contains("Repeat over A1:A20 has 20 rows, more than its limit of 10."), "{err}"); + // 400 × 400 passes is over the step limit. + put( + &mut s, + json!({"name": "Spin", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ + {"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("ran more than 100000 steps"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); + assert!(err.contains("ran longer than"), "{err}"); + // The footprint's dry run keeps to the time limit too, and so does the check's. + put( + &mut s, + json!({"name": "Spin", "output": {"onConflict": "fail"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ + {"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); + assert!(err.contains("ran longer than"), "{err}"); + let c = s.execute("macro.check", json!({"name": "Spin", "timeLimit": 0.001})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("ran longer than"), "{c}"); +} + +#[test] +fn a_token_in_a_path_needs_the_permission_and_approval() { + let mut s = s(); + let dir = std::env::temp_dir().join(format!("gc-macro-token-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().count(); + let in_dir = format!("{}/{{{{Input}}}}", dir.to_string_lossy()); + for path in [json!({"$ref": "Input"}), json!(in_dir), json!(" ")] { + let step = json!({"command": "file.exportCsv", "params": {"path": path}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step]})); + // Without the permission nothing runs, not even the check's dry run. + let err = s.execute("macro.run", json!({"name": "Export"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (file.exportCsv) writes a file. Add file.exportCsv to the macro's permissions"), "{path}: {err}"); + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert!(c["errors"][0].as_str().unwrap().contains("writes a file"), "{path}: {c}"); + assert!(c.get("footprint").is_none()); + // With it, the person approves first; the check doesn't run it before then. + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + let r = s.execute("macro.run", json!({"name": "Export"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{path}: {r:?}") }; + assert!(q.contains("file.exportCsv, which writes a file"), "{q}"); + let c = s.execute("macro.check", json!({"name": "Export"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs file.exportCsv, which writes a file"]), "{path}"); + assert!(c.get("footprint").is_none()); + assert_eq!(files(&dir), 0, "{path}"); + assert!(!std::path::Path::new("A1.csv").exists() && !std::path::Path::new(" .csv").exists()); + } + // Once approved, the check's dry run stops before the step, and only the run writes the file. + let step = json!({"command": "file.exportCsv", "params": {"path": in_dir}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + s.execute("macro.run", json!({"name": "Export", "approve": true, "inputs": "B2"})).unwrap(); + assert_eq!(files(&dir), 1); + std::fs::remove_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let c = s.execute("macro.check", json!({"name": "Export", "inputs": "B2"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1", "{c}"); + assert_eq!(files(&dir), 0, "the check writes nothing"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_debugger_runs_a_token_path_only_with_the_permission_and_approval() { + let mut s = s(); + let dir = std::env::temp_dir().join(format!("gc-macro-debug-token-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().count(); + let step = json!({"command": "file.exportCsv", "params": {"path": format!("{}/{{{{Input}}}}", dir.to_string_lossy())}}); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step]})); + let err = s.execute("macro.debug", json!({"action": "start", "name": "Export"})).unwrap_err().to_string(); + assert!(err.contains("writes a file"), "{err}"); + put(&mut s, json!({"name": "Export", "inputs": [{"name": "Input"}], "steps": [step], "permissions": ["file.exportCsv"]})); + assert!(matches!(s.execute("macro.debug", json!({"action": "start", "name": "Export"})), Err(EngineError::Approve(_)))); + assert_eq!(files(&dir), 0); + // Approved, it pauses before the step and writes the file only when the step runs. + s.execute("macro.debug", json!({"action": "start", "name": "Export", "approve": true, "inputs": "B2", "breakpoints": ["1"]})).unwrap(); + assert_eq!(files(&dir), 0, "paused before the step"); + s.execute("macro.debug", json!({"action": "step"})).unwrap(); + assert_eq!(files(&dir), 1, "the debugger runs the approved step"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_policy_keeps_macros_away_from_files_and_the_app() { + let mut s = s(); + for (id, params) in [ + ("file.saveAs", json!({"path": "/tmp/x.xlsx"})), + ("file.close", json!({})), + ("app.language.set", json!({"language": "de"})), + ("automate.runScript", json!({"name": "Script 1"})), + ("edit.undo", json!({})), + ("macro.run", json!({"name": "Self"})), + ("view.newWindow", json!({})), + ("view.hideWindow", json!({})), + ] { + put( + &mut s, + json!({"name": "Bad", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, {"command": id, "params": params}]}), + ); + let err = s.execute("macro.run", json!({"name": "Bad"})).unwrap_err().to_string(); + assert!(err.contains(&format!("Step 2 ({id}) can't run in a macro")), "{err}"); + assert!(v(&s, "A1").is_empty(), "{id}: nothing ran"); + } + // A disabled step isn't checked. + put(&mut s, json!({"name": "Bad", "steps": [{"command": "file.close", "enabled": false}]})); + s.execute("macro.run", json!({"name": "Bad"})).unwrap(); + + // Reading a file needs the permission, then the person's approval (once per macro content). + let dir = std::env::temp_dir().join(format!("gc-macro-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, "a,b\n1,2\n").unwrap(); + let step = json!({"command": "data.fromTextCsv", "params": {"path": csv.to_string_lossy(), "sheet": "Imported"}}); + put(&mut s, json!({"name": "Import", "steps": [step]})); + let err = s.execute("macro.run", json!({"name": "Import"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (data.fromTextCsv) reads a file. Add data.fromTextCsv to the macro's permissions"), "{err}"); + put(&mut s, json!({"name": "Import", "steps": [step], "permissions": ["data.fromTextCsv"]})); + let r = s.execute("macro.run", json!({"name": "Import"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Import'? It runs data.fromTextCsv, which reads a file."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"])); + assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry"); + s.execute("macro.run", json!({"name": "Import", "approve": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("macro.run", json!({"name": "Import"})).unwrap(); + // The dry run skips the file step and says so. + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1"); + // Changing the macro asks again. + put(&mut s, json!({"name": "Import", "description": "changed", "steps": [step], "permissions": ["data.fromTextCsv"]})); + assert!(matches!(s.execute("macro.run", json!({"name": "Import"})), Err(EngineError::Approve(_)))); + let _ = std::fs::remove_dir_all(&dir); + + // Steps that would open a dialog fail instead. + put(&mut s, json!({"name": "Pic", "output": {"onConflict": "overwrite"}, "steps": [{"command": "insert.picture"}]})); + let err = s.execute("macro.run", json!({"name": "Pic"})).unwrap_err().to_string(); + assert!(err.contains("would open the insertPicture dialog"), "{err}"); + assert!(s.ui_requests.is_empty()); + // A step that would start editing a cell, or put a name in the edit, runs without it. + let (use_name, insert) = (json!({"name": "Rate"}), json!({"name": "SUM"})); + let steps = json!([{"command": "formulas.useInFormula", "params": use_name}, {"command": "formulas.insertFunction", "params": insert}]); + put(&mut s, json!({"name": "Edit", "output": {"onConflict": "overwrite"}, "steps": steps})); + s.execute("macro.run", json!({"name": "Edit"})).unwrap(); + assert!(s.ui_requests.is_empty()); +} + +#[test] +fn macros_from_a_file_ask_before_running() { + let mut s = s(); + let mut wb = gridcraft_model::Workbook::new(); + wb.macros.push(crate::macros::parse_macro(&json!({"name": "Theirs", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "ran"}}]})).unwrap()); + let bytes = serde_json::to_vec(&wb).unwrap(); + s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile); + let r = s.execute("macro.run", json!({"name": "Theirs"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Theirs'? It came with a workbook you opened."); + assert!(v(&s, "A1").is_empty()); + s.execute("macro.run", json!({"name": "Theirs", "approve": true})).unwrap(); + assert_eq!(v(&s, "A1").display(), "ran"); + // Approved once per macro content: the second run doesn't ask. + s.execute("macro.run", json!({"name": "Theirs"})).unwrap(); + // The approval holds the macro's whole content, not a hash two macros could share. + let m = s.doc().unwrap().wb.macros[0].clone(); + let doc = s.doc().unwrap().uid; + let key: (u64, String) = (doc, crate::macros::macro_json(&m).to_string()); + assert!(s.macro_approvals.contains(&key)); + // The JSON is canonical: the same macro with its keys in another order is the same macro. + let a = crate::macros::parse_macro(&json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap(); + let b = crate::macros::parse_macro(&json!({"steps": [{"params": {"input": "x", "cell": "A1"}, "command": "cell.set"}], "name": "M"})).unwrap(); + assert_eq!(crate::macros::fingerprint(&a), crate::macros::fingerprint(&b)); +} + +#[test] +fn a_partial_footprint_doesnt_clear_later_writes() { + let dir = std::env::temp_dir().join(format!("gc-macro-partial-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("out.csv").to_string_lossy().to_string(); + let export = json!({"command": "file.exportCsv", "params": {"path": csv}}); + let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}}); + let late = json!({"command": "cell.set", "params": {"cell": "B1", "input": "over"}}); + // The dry run stops at an approved file step or at Solver; the step after it writes over B1. + for first in [&export, &solver] { + let mut s = super::s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + set(&mut s, "B1", "keep"); + put(&mut s, json!({"name": "Late", "inputs": [{"name": "Input"}], "permissions": ["file.exportCsv"], "steps": [first, late]})); + let start = s.doc().unwrap().wb.clone(); + let c = s.execute("macro.check", json!({"name": "Late", "inputs": "A1"})).unwrap(); + if c.get("needsApproval").is_none() { + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["partial"]["step"]), (Some(0), &json!("1")), "{c}"); + } + for rule in ["fail", "ask"] { + let r = s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "onConflict": rule, "approve": true})); + let e = match (rule, r) { + ("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e, + (_, r) => panic!("{first} {rule}: {r:?}"), + }; + assert!(e.contains("Step 2 (cell.set): It would write over data (B1 on Sheet1)"), "{e}"); + assert_eq!(v(&s, "B1").display(), "keep", "{first} {rule}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{first} {rule}: rolled back"); + } + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Late", "inputs": "A1", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "B1").display(), "over"); + } + // A step that writes over what the macro itself wrote isn't a conflict. + let mut s = super::s(); + let again = |input: &str| json!({"command": "cell.set", "params": {"cell": "E1", "input": input}}); + put( + &mut s, + json!({"name": "Twice", "permissions": ["file.exportCsv"], "output": {"onConflict": "fail"}, "steps": [again("a"), export, again("b")]}), + ); + s.execute("macro.run", json!({"name": "Twice", "approve": true})).unwrap(); + assert_eq!(v(&s, "E1").display(), "b"); + // After a shift, the steps past the file step are checked too. + let mut s = super::s(); + set(&mut s, "C1", "old"); + set(&mut s, "D1", "keep"); + let step = |cell: &str| json!({"command": "cell.set", "params": {"cell": cell, "input": "new"}}); + put( + &mut s, + json!({"name": "Shift", "permissions": ["file.exportCsv"], "output": {"onConflict": "shiftDown"}, "steps": [step("C1"), export, step("D1")]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Shift", "approve": true})).unwrap_err().to_string(); + assert!(err.contains("Step 3 (cell.set): It would write over data (D1 on Sheet1)"), "{err}"); + assert_eq!((v(&s, "C1").display(), v(&s, "D1").display()), ("old".into(), "keep".into())); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "the shift is rolled back too"); + let _ = std::fs::remove_dir_all(&dir); +} + +// ---------------------------------------------------------------- debugger + +fn debug(s: &mut Session, p: Json) -> Json { + s.execute("macro.debug", p).unwrap() +} + +/// Four steps: two plain ones, a Repeat over the data rows (with a breakpoint on its body), and +/// a final one. +fn stepper(s: &mut Session) { + data(s); + put( + s, + json!({"name": "Walk", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "E1", "input": "start"}}, + {"command": "cell.set", "params": {"cell": "Input.right.cell(1,1)", "input": "flag"}}, + {"type": "repeat", "over": "Input.data", "body": [ + {"command": "cell.set", "params": {"cell": "Row.right", "input": "seen"}, "breakpoint": true} + ]}, + {"command": "cell.set", "params": {"cell": "E9", "input": "end"}} + ]}), + ); +} + +#[test] +fn the_debugger_steps_continues_and_runs_to_a_step_as_one_undo_step() { + let mut s = s(); + stepper(&mut s); + let undo = undo_labels(&s); + let st = debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "action": "start"})); + assert_eq!(st["state"], "paused"); + assert_eq!( + st["current"], + json!({"step": "1", "kind": "command", "command": "cell.set", "params": {"cell": "E1", "input": "start"}, "breakpoint": false}) + ); + assert_eq!(st["executed"], 0); + + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["current"]["step"], "2"); + assert_eq!(st["current"]["params"]["cell"], "D2", "tokens resolve for the next step"); + assert_eq!(st["lastParams"], json!({"cell": "E1", "input": "start"})); + assert_eq!(st["changed"], json!(["Sheet1!E1"])); + assert_eq!(v(&s, "E1").display(), "start"); + + // Other edits wait; selecting cells and queries don't. + let r = s.execute("cell.set", json!({"cell": "A1", "input": "x"})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == "Finish or stop the macro first"), "{r:?}"); + assert!(s.execute("macro.run", json!({"name": "Walk"})).is_err()); + assert!(s.execute("edit.undo", json!({})).is_err()); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + assert!(s.execute("macro.debug", json!({"name": "Walk", "action": "start"})).unwrap_err().to_string().contains("is being debugged")); + + // Continue stops at the breakpoint inside the Repeat, once per row. + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("3.1"), Some(true))); + assert_eq!(st["current"]["iteration"], json!({"at": 1, "of": 3})); + assert_eq!(st["current"]["params"]["cell"], "D3"); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["current"]["iteration"], json!({"at": 2, "of": 3})); + // Run To stops at a breakpoint on the way, then at its step. + let st = debug(&mut s, json!({"action": "runTo", "step": "4", "breakpoints": []})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("3.1"), Some(true))); + assert_eq!(st["current"]["iteration"], json!({"at": 3, "of": 3})); + let st = debug(&mut s, json!({"action": "runTo", "step": "4"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("4"), Some(false))); + assert_eq!((v(&s, "D4").display(), v(&s, "D5").display()), ("seen".into(), "seen".into())); + // Breakpoints from the call: none left before the end. + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["state"], "finished"); + assert_eq!(st["executed"], 7); + assert!(st["changed"].as_array().unwrap().contains(&json!("Sheet1!E9"))); + assert_eq!(v(&s, "E9").display(), "end"); + let after = undo_labels(&s); + assert_eq!(after.len(), undo.len() + 1, "{after:?}"); + assert_eq!(after.last().unwrap(), "Run Macro: Walk"); + // Edits work again; one undo takes the whole run back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty() && v(&s, "D3").is_empty() && v(&s, "E9").is_empty()); + set(&mut s, "A1", "free"); +} + +#[test] +fn a_breakpoint_list_can_replace_the_steps_own() { + let mut s = s(); + stepper(&mut s); + // Only step 4 stops the run: the flag on 3.1 is ignored. + let st = debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["4"], "breakpointsOnly": true})); + assert_eq!(st["current"]["step"], "1"); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("4"), Some(true))); + debug(&mut s, json!({"action": "stop"})); + // Clearing the list mid-run (an editor removing its last breakpoint) runs to the end. + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["3.1"], "breakpointsOnly": true})); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!(st["current"]["step"], "3.1"); + let st = debug(&mut s, json!({"action": "continue", "breakpoints": [], "breakpointsOnly": true})); + assert_eq!(st["state"], "finished"); +} + +#[test] +fn stopping_the_debugger_keeps_or_rolls_back() { + for keep in [false, true] { + let mut s = s(); + stepper(&mut s); + let (start, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5", "breakpoints": ["2"]})); + let st = debug(&mut s, json!({"action": "continue"})); + assert_eq!((st["current"]["step"].as_str(), st["atBreakpoint"].as_bool()), (Some("2"), Some(true))); + let st = debug(&mut s, json!({"action": "stop", "keep": keep})); + assert_eq!((st["state"].as_str(), st["kept"].as_bool()), (Some("stopped"), Some(keep))); + if keep { + assert_eq!(v(&s, "E1").display(), "start"); + assert_eq!(undo_labels(&s).len(), undo.len() + 1); + assert_eq!(st["changed"], json!(["Sheet1!E1"])); + } else { + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!(undo_labels(&s), undo); + } + set(&mut s, "A1", "free"); + assert!(s.execute("macro.debug", json!({"action": "step"})).unwrap_err().to_string().contains("No macro is being debugged")); + } +} + +#[test] +fn a_debugged_run_after_a_partial_footprint_stops_before_writing_over_data() { + let mut s = s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + set(&mut s, "B1", "keep"); + let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}}); + let late = json!({"command": "cell.set", "params": {"cell": "B1", "input": "over"}}); + put(&mut s, json!({"name": "Late", "inputs": [{"name": "Input"}], "output": {"onConflict": "fail"}, "steps": [solver, late]})); + let start = s.doc().unwrap().wb.clone(); + debug(&mut s, json!({"name": "Late", "action": "start", "inputs": "A1"})); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "failed", "{st}"); + assert!(st["message"].as_str().unwrap().contains("It would write over data (B1 on Sheet1)"), "{st}"); + assert_eq!(v(&s, "B1").display(), "keep"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); +} + +#[test] +fn a_failing_step_ends_the_debugged_run_and_rolls_it_back() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Breaks", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "one"}}, + {"command": "cell.set", "params": {"cell": "Nowhere!A2", "input": "two"}}, + {"type": "stop", "message": "never"} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + debug(&mut s, json!({"name": "Breaks"})); + assert!(s.execute("macro.debug", json!({"action": "runTo", "step": "9"})).unwrap_err().to_string().contains("has no step 9")); + assert!(s.execute("macro.debug", json!({"action": "leap"})).is_err()); + debug(&mut s, json!({"action": "step"})); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!(st["state"], "failed"); + assert_eq!(st["error"]["step"], "2"); + assert!(st["message"].as_str().unwrap().starts_with("Step 2 (cell.set):"), "{st}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + set(&mut s, "B1", "free again"); + // A run that ends at once (a Stop first) finishes on start. + put(&mut s, json!({"name": "Quick", "steps": [{"type": "stop", "message": "bye"}]})); + let st = debug(&mut s, json!({"name": "Quick"})); + assert_eq!(st["state"], "paused"); + let st = debug(&mut s, json!({"action": "step"})); + assert_eq!((st["state"].as_str(), st["stopped"].as_str()), (Some("stopped"), Some("bye"))); + put(&mut s, json!({"name": "Empty", "steps": []})); + assert_eq!(debug(&mut s, json!({"name": "Empty"}))["state"], "finished"); +} + +// ---------------------------------------------------------------- commands and the recorder + +#[test] +fn macros_are_saved_listed_renamed_and_deleted() { + let mut s = s(); + s.execute("macro.save", json!({"macro": totals()})).unwrap(); + // The fields can also come at the top level. + let r = s + .execute("macro.save", json!({"name": "Second", "description": "two", "shortcut": "Ctrl+Shift+Q", "steps": [{"command": "home.bold"}]})) + .unwrap(); + assert_eq!(r, json!({"name": "Second", "steps": 1})); + let list = s.execute("macro.list", json!({})).unwrap(); + assert_eq!(list[0], json!({"name": "Totals", "description": "", "steps": 3, "inputs": ["Input"], "onConflict": "ask", "permissions": []})); + assert_eq!(list[1]["shortcut"], "Ctrl+Shift+Q"); + let got = s.execute("macro.get", json!({"name": "totals"})).unwrap(); + assert_eq!(crate::macros::parse_macro(&got).unwrap(), crate::macros::parse_macro(&totals()).unwrap()); + assert!(s.execute("macro.save", json!({"macro": totals(), "replace": false})).unwrap_err().to_string().contains("already exists")); + assert!(s.execute("macro.save", json!({"macro": {"name": "Bad", "steps": [{"type": "warp"}]}})).is_err()); + assert!(s.execute("macro.rename", json!({"name": "Second", "to": "TOTALS"})).unwrap_err().to_string().contains("already exists")); + s.execute("macro.rename", json!({"name": "Second", "to": "Third"})).unwrap(); + s.execute("macro.rename", json!({"name": "third", "to": "THIRD"})).unwrap(); + s.execute("macro.delete", json!({"name": "Totals"})).unwrap(); + let names: Vec = + s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["THIRD"]); + assert!(s.execute("macro.delete", json!({"name": "Totals"})).unwrap_err().to_string().contains("no macro called")); + // Saving, renaming and deleting are undoable. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2); + // Macros are kept apart from Automate scripts: View › Macros still lists the scripts. + assert_eq!(s.execute("view.macros", json!({})).unwrap(), json!([])); + assert!(s.execute("macro.rename", json!({"name": "THIRD", "to": "a\nb"})).is_err()); +} + +#[test] +fn macros_and_automate_scripts_live_side_by_side() { + let mut s = s(); + let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "from script"}}]); + s.execute("automate.newScript", json!({"name": "Inner", "commands": steps})).unwrap(); + s.execute("macro.save", json!({"name": "Inner", "steps": [{"command": "cell.set", "params": {"cell": "C1", "input": "from macro"}}], "output": {"onConflict": "overwrite"}})).unwrap(); + // Recording a script run keeps the script's steps, so the macro stands on its own. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + s.execute("automate.runScript", json!({"name": "Inner"})).unwrap(); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "Empty"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Recorded"})).unwrap(); + assert_eq!(r["skipped"], json!(["automate.recordActions", "automate.recordActions"])); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Recorded"})).unwrap()).unwrap(); + let ids: Vec = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect(); + assert_eq!(ids, ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"B1\",\"input\":\"from script\"}"]); + // Both lists keep their own entries, under the same name if need be. + let scripts = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(scripts, json!([{"name": "Empty", "steps": 0, "trusted": true}, {"name": "Inner", "steps": 1, "trusted": true}])); + let macros: Vec = s.execute("macro.list", json!({})).unwrap().as_array().unwrap().iter().map(|m| m["name"].clone()).collect(); + assert_eq!(macros, [json!("Inner"), json!("Recorded")]); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("automate.runScript", json!({"name": "Inner"})).unwrap(); + s.execute("macro.run", json!({"name": "Inner"})).unwrap(); + assert_eq!((v(&s, "B1").display(), v(&s, "C1").display()), ("from script".into(), "from macro".into())); + // Both survive an xlsx round trip unchanged. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "both.xlsx", "base64": b64})).unwrap(); + // Opened from a file, the scripts are not trusted yet (#2178); their names and steps are the same. + let names_and_steps = |v: &Json| v.as_array().unwrap().iter().map(|s| (s["name"].clone(), s["steps"].clone())).collect::>(); + assert_eq!(names_and_steps(&s.execute("automate.allScripts", json!({})).unwrap()), names_and_steps(&scripts)); + assert_eq!(s.execute("macro.list", json!({})).unwrap().as_array().unwrap().len(), 2); +} + +#[test] +fn a_relative_recording_plays_back_on_another_block() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Plain", "steps": [{"command": "cell.set", "params": {"cell": "A20", "input": "plain"}}], "output": {"onConflict": "overwrite"}}), + ); + put(&mut s, json!({"name": "Shaped", "inputs": [{"name": "Input"}], "steps": []})); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + let r = s.execute("macro.record", json!({})).unwrap(); + assert_eq!(r, json!({"recording": true, "relative": true, "input": "B2:C5"})); + // Clicking a cell and typing, a formula over the amounts, bold header, italic on a selection. + s.execute("selection.set", json!({"cell": "E2"})).unwrap(); + set(&mut s, "E2", "Total"); + set(&mut s, "E3", "=SUM(C3:C5)"); + s.execute("home.bold", json!({"range": "B2:C2"})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + s.execute("selection.set", json!({"cell": "C4"})).unwrap(); + s.execute("home.italic", json!({})).unwrap(); + set(&mut s, "C6", "end"); + // Not kept: commands macros don't run, and a macro with inputs; a plain macro's steps are + // inlined. + s.execute("window.activate", json!({"index": 0})).unwrap(); + s.execute("macro.run", json!({"name": "Plain"})).unwrap(); + s.execute("macro.run", json!({"name": "Shaped", "inputs": "A1"})).unwrap(); + // The journal can be trimmed meanwhile. + s.journal.clear(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Sum it", "description": "made by recording"})).unwrap(); + assert!(!s.is_recording()); + assert_eq!(r["saved"], "Sum it"); + assert_eq!(r["skipped"], json!(["window.activate", "macro.run"])); + assert_eq!(r["inputs"], json!(["Input"])); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Sum it"})).unwrap()).unwrap(); + assert_eq!(m.description, "made by recording"); + let input = &m.inputs[0]; + assert!(input.header); + assert_eq!((input.cols, input.col_types.clone()), ([2, 2], vec![gridcraft_model::ColType::Text, gridcraft_model::ColType::Number])); + let steps: Vec<(String, Json)> = m + .steps + .iter() + .map(|st| match &st.kind { + gridcraft_model::StepKind::Command { id, params } => (id.clone(), params.clone()), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + steps, + vec![ + ("selection.set".to_string(), json!({"cell": "Input.cell(1,4)"})), + ("cell.set".into(), json!({"cell": "Input.cell(1,4)", "input": "Total"})), + ("cell.set".into(), json!({"cell": "Input.cell(2,4)", "input": "=SUM({{Input.rows(2..).col(2)}})"})), + ("home.bold".into(), json!({"range": "Input.row(1)", "on": true})), + ("selection.set".into(), json!({"cell": "Input.cell(3,2)"})), + ("home.italic".into(), json!({"on": true})), + ("cell.set".into(), json!({"cell": "Input.col(2).below", "input": "end"})), + ("cell.set".into(), json!({"cell": "A20", "input": "plain"})), + ] + ); + // Play it back on a longer block somewhere else. + for (cell, input) in [ + ("G10", "Who"), + ("H10", "Paid"), + ("G11", "A"), + ("H11", "1"), + ("G12", "B"), + ("H12", "2"), + ("G13", "C"), + ("H13", "3"), + ("G14", "D"), + ("H14", "4"), + ] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap(); + assert_eq!((v(&s, "J10").display(), v(&s, "J11").display()), ("Total".into(), "10".into())); + assert!(bold(&s, "G10") && bold(&s, "H10")); + assert_eq!(v(&s, "H15").display(), "end"); + // Toggles were recorded with the state they set: a second run keeps the header bold. + s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14", "onConflict": "overwrite"})).unwrap(); + assert!(bold(&s, "G10") && bold(&s, "H10")); + let d = s.doc().unwrap(); + assert!(d.wb.styles.get(d.wb.active().unwrap().style_id(gridcraft_core::CellRef::parse("H12").unwrap())).font.italic); + // The input is checked like any other: text in the amounts is refused. + set(&mut s, "H12", "two"); + let err = s.execute("macro.run", json!({"name": "Sum it", "inputs": "G10:H14"})).unwrap_err().to_string(); + assert!(err.contains("Input column 2 (Paid) must hold numbers; H12 holds text."), "{err}"); +} + +#[test] +fn an_absolute_recording_keeps_its_references() { + let mut s = s(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + set(&mut s, "B2", "x"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + set(&mut s, "C3", "y"); + let r = s.execute("macro.record", json!({})).unwrap(); + assert_eq!(r["saved"], "Macro1"); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Macro1"})).unwrap()).unwrap(); + assert!(m.inputs.is_empty()); + let ids: Vec = crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect(); + assert_eq!( + ids, + [ + "home.insertSheet {\"name\":\"Two\"}", + "sheet.activate {\"sheet\":\"Two\"}", + "selection.set {\"active\":\"A1\",\"range\":\"A1\"}", + "cell.set {\"cell\":\"B2\",\"input\":\"x\"}", + "sheet.activate {\"sheet\":\"Sheet1\"}", + "selection.set {\"active\":\"A1\",\"range\":\"A1\"}", + "cell.set {\"cell\":\"C3\",\"input\":\"y\"}", + ] + ); + // Stopping when nothing records says so. + assert_eq!(s.execute("macro.record", json!({"stop": true})).unwrap(), json!({"recording": false})); +} + +#[test] +fn only_the_workbooks_a_debugged_run_touched_wait() { + let mut s = s(); + stepper(&mut s); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // Another workbook stays editable, macros included. + s.new_workbook(); + set(&mut s, "A1", "elsewhere"); + put( + &mut s, + json!({"name": "Here", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A2", "input": "ran"}}]}), + ); + s.execute("macro.run", json!({"name": "Here"})).unwrap(); + assert_eq!(v(&s, "A2").display(), "ran"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A2").is_empty()); + // The debugged one waits. + s.set_active(0); + let r = s.execute("cell.set", json!({"cell": "A1", "input": "x"})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why == "Finish or stop the macro first"), "{r:?}"); + // The run carries on in its own workbook whichever one is in front. + s.set_active(1); + let st = debug(&mut s, json!({"action": "continue", "breakpoints": []})); + assert_eq!(st["state"], "paused"); + assert_eq!(s.active_index(), 0); + debug(&mut s, json!({"action": "stop"})); + set(&mut s, "A1", "free"); +} + +/// The commands of saved macro `name`, as `id params`. +fn recorded(s: &mut Session, name: &str) -> Vec { + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": name})).unwrap()).unwrap(); + crate::macros::command_steps(&m.steps).iter().map(|(_, id, p)| format!("{id} {p}")).collect() +} + +#[test] +fn a_recording_is_kept_when_saving_it_fails() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + // A name the macro check refuses: still recording, and a good name saves what was recorded. + assert!(s.execute("macro.record", json!({"stop": true, "name": "Bad\u{1}name"})).is_err()); + assert!(s.is_recording()); + set(&mut s, "A2", "more"); + let r = s.execute("macro.record", json!({"stop": true, "name": "Good"})).unwrap(); + assert_eq!(r["saved"], "Good"); + assert!(!s.is_recording()); + assert_eq!(recorded(&mut s, "Good"), ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}", "cell.set {\"cell\":\"A2\",\"input\":\"more\"}"]); + + // A workbook at its macro limit: freeing a slot and stopping again saves it. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "B1", "full"); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + let filler = wb.macros[0].clone(); + while wb.macros.len() < gridcraft_model::MAX_MACROS { + let mut m = filler.clone(); + m.name = format!("Filler{}", wb.macros.len()); + wb.macros.push(m); + } + d.wb = Arc::new(wb); + let err = s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap_err().to_string(); + assert!(err.contains("at most"), "{err}"); + assert!(s.is_recording()); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.pop(); + d.wb = Arc::new(wb); + s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap(); + assert_eq!(recorded(&mut s, "Late"), ["cell.set {\"cell\":\"B1\",\"input\":\"full\"}"]); +} + +#[test] +fn a_relative_recording_keeps_references_on_another_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("home.bold", json!({"range": "B2:C2"})).unwrap(); + // On the other sheet, C4 and C3:C5 are that sheet's cells, not the input's. + s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap(); + set(&mut s, "C4", "x"); + set(&mut s, "D2", "=SUM(C3:C5)"); + s.execute("macro.record", json!({"stop": true, "name": "Both"})).unwrap(); + let steps = recorded(&mut s, "Both"); + assert!(steps.contains(&"cell.set {\"cell\":\"C4\",\"input\":\"x\"}".to_string()), "{steps:?}"); + assert!(steps.contains(&"cell.set {\"cell\":\"D2\",\"input\":\"=SUM(C3:C5)\"}".to_string()), "{steps:?}"); + // Played back on another block of Sheet1, it writes Two's cells. + set(&mut s, "C4", ""); + set(&mut s, "D2", ""); + set(&mut s, "C3", "5"); + set(&mut s, "C5", "7"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "100"), ("G12", "B"), ("H12", "200"), ("G13", "C"), ("H13", "300")] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Both", "inputs": "G10:H13", "onConflict": "overwrite"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(bold(&s, "G10") && bold(&s, "H10")); + // Nothing was written into or beside the input. + assert_eq!((v(&s, "H12").display(), v(&s, "I10").display()), ("200".into(), String::new())); + s.execute("sheet.activate", json!({"sheet": "Two"})).unwrap(); + assert_eq!((v(&s, "C4").display(), v(&s, "D2").display()), ("x".into(), "12".into())); +} + +#[test] +fn a_step_that_names_another_sheet_keeps_its_references() { + // `sheet` names where the step runs whatever sheet is active, so its C4 is that sheet's C4. + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("cell.set", json!({"sheet": "Two", "cell": "C4", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"sheet": "Sheet1", "cell": "C4", "input": "90"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Named"})).unwrap(); + let steps = recorded(&mut s, "Named"); + assert!(steps.contains(&"cell.set {\"cell\":\"C4\",\"input\":\"x\",\"sheet\":\"Two\"}".to_string()), "{steps:?}"); + assert!(steps.contains(&"cell.set {\"cell\":\"Input.cell(3,2)\",\"input\":\"90\",\"sheet\":\"Sheet1\"}".to_string()), "{steps:?}"); +} + +#[test] +fn inlining_recorded_runs_stops_at_the_step_limit() { + // A recorded run of a long macro adds all of its steps; the conversion gathers at most the + // macro's step limit and counts the rest, instead of expanding every run first. + let mut s = s(); + let steps: Vec = (0..6_000).map(|_| json!({"command": "cell.set", "params": {"cell": "A1", "input": "x"}})).collect(); + put(&mut s, json!({"name": "Big", "output": {"onConflict": "overwrite"}, "steps": steps})); + s.execute("macro.record", json!({})).unwrap(); + s.execute("macro.run", json!({"name": "Big"})).unwrap(); + s.execute("macro.run", json!({"name": "Big"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Twice"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(10_000), Some(2_000)), "{r}"); +} + +#[test] +fn a_recording_can_be_cancelled() { + let mut s = s(); + data(&mut s); + let before = s.execute("macro.list", json!({})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E2", "x"); + let r = s.execute("macro.record", json!({"cancel": true})).unwrap(); + assert_eq!(r, json!({"recording": false, "cancelled": true})); + assert!(!s.is_recording() && s.macro_recording.is_none()); + assert_eq!(s.execute("macro.list", json!({})).unwrap(), before, "nothing was saved"); + assert_eq!(s.execute("macro.record", json!({"cancel": true})).unwrap()["cancelled"], false); + // Recording works again afterwards. + s.execute("macro.record", json!({})).unwrap(); + assert!(s.is_recording()); + s.execute("macro.record", json!({"cancel": true})).unwrap(); +} + +#[test] +fn formulas_are_tokenized_by_reading_their_references() { + // An example as large as a sheet has a million rows: each reference in the formula is read + // and matched on its own, instead of a list of every column and row of the example. + let mut s = s(); + s.execute("selection.set", json!({"range": "A1:Z1000000"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "AB1", "input": "=SUM(A1:A1000000)+SUM(A1:A3)+B2"})).unwrap(); + s.execute("cell.set", json!({"cell": "AB2", "input": "=SUM(A1:Z1000000)&\"A1:A1000000\""})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Wide"})).unwrap(); + let steps = recorded(&mut s, "Wide"); + // A single cell inside a formula stays as it is: only the input, its columns and its rows + // become tokens there. + assert!(steps.iter().any(|st| st.contains("=SUM({{Input.col(1)}})+SUM(A1:A3)+B2")), "{steps:?}"); + assert!(steps.iter().any(|st| st.contains("=SUM({{Input}})&\\\"A1:A1000000\\\"")), "{steps:?}"); +} + +#[test] +fn a_selection_of_several_areas_keeps_its_active_cell_as_it_is() { + let mut s = s(); + data(&mut s); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + // Several areas picked with the mouse: no command, so the recording adds a selection.set + // with the active cell before the next step. + let (a, b) = (gridcraft_core::RangeRef::parse("B2:C5").unwrap(), gridcraft_core::RangeRef::parse("E2:E5").unwrap()); + s.doc_mut().unwrap().selection = crate::Selection { active: b.start, anchor: b.start, ranges: vec![a, b] }; + s.execute("home.bold", json!({})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Areas"})).unwrap(); + let steps = recorded(&mut s, "Areas"); + assert!(steps.contains(&"selection.set {\"active\":\"E2\",\"range\":\"B2:C5,E2:E5\"}".to_string()), "{steps:?}"); + assert!(steps.iter().all(|st| !st.contains("\"active\":\"Input")), "{steps:?}"); +} + +#[test] +fn steps_recorded_in_another_workbook_are_counted() { + let mut s = s(); + data(&mut s); + let home = s.active_index(); + s.execute("macro.record", json!({})).unwrap(); + s.new_workbook(); + set(&mut s, "A1", "elsewhere"); + set(&mut s, "A2", "too"); + s.set_active(home); + set(&mut s, "E2", "here"); + let r = s.execute("macro.record", json!({"stop": true, "name": "Home"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(1), Some(2)), "{r}"); +} + +#[test] +fn a_recorded_macro_run_is_inlined_only_when_its_steps_stand_alone() { + let mut s = s(); + put( + &mut s, + json!({"name": "Branchy", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "if", "cell": "A1", "op": "empty", + "then": [{"command": "cell.set", "params": {"cell": "B1", "input": "empty"}}], + "else": [{"command": "cell.set", "params": {"cell": "B1", "input": "full"}}]} + ]}), + ); + put( + &mut s, + json!({"name": "Placed", "output": {"anchor": "D1", "onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "out"}} + ]}), + ); + put( + &mut s, + json!({"name": "Plain", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "C1", "input": "plain"}}, + {"command": "cell.set", "params": {"cell": "C2", "input": "off"}, "enabled": false} + ]}), + ); + s.execute("macro.record", json!({"relative": false})).unwrap(); + for name in ["Branchy", "Placed", "Plain"] { + s.execute("macro.run", json!({"name": name})).unwrap(); + } + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["macro.run", "macro.run"])); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"plain\"}"]); +} + +#[test] +fn a_recorded_script_run_leaves_out_the_steps_a_macro_may_not_run() { + let mut s = s(); + let steps = json!([ + {"command": "cell.set", "params": {"cell": "B1", "input": "kept"}}, + {"command": "app.commands", "params": {}}, + {"command": "macro.list", "params": {}} + ]); + s.execute("automate.newScript", json!({"name": "Mixed", "commands": steps})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Mixed"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["app.commands", "macro.list"])); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"kept\"}"]); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); +} + +#[test] +fn a_recorded_script_run_keeps_the_steps_that_ran() { + // One script is edited and another deleted before the recording stops: the macro holds the + // steps as they ran, not the scripts as they stand when the recording is saved. + let mut s = s(); + let step = |input: &str| json!([{"command": "cell.set", "params": {"cell": "B1", "input": input}}]); + s.execute("automate.newScript", json!({"name": "Edited", "commands": step("first")})).unwrap(); + s.execute("automate.newScript", json!({"name": "Gone", "commands": step("kept")})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Edited"})).unwrap(); + s.execute("automate.runScript", json!({"name": "Gone"})).unwrap(); + s.execute("automate.newScript", json!({"name": "Edited", "commands": step("changed")})).unwrap(); + s.execute("automate.deleteScript", json!({"name": "Gone"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + let steps = recorded(&mut s, "Rec"); + assert_eq!( + steps.get(..2), + Some(&["cell.set {\"cell\":\"B1\",\"input\":\"first\"}".to_string(), "cell.set {\"cell\":\"B1\",\"input\":\"kept\"}".to_string()][..]), + "{steps:?}" + ); + assert!(!r["skipped"].as_array().unwrap().contains(&json!("automate.runScript")), "{r}"); +} + +#[test] +fn a_recording_is_saved_in_the_workbook_where_it_started() { + let mut s = s(); + data(&mut s); + let first = s.doc().unwrap().uid; + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E2", "Total"); + s.new_workbook(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Here"})).unwrap(); + assert_eq!(r["saved"], "Here"); + // The active workbook stays active and gets nothing. + assert_ne!(s.doc().unwrap().uid, first); + assert!(s.doc().unwrap().wb.macros.is_empty()); + s.set_active(0); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Here"})).unwrap()).unwrap(); + // The input's shape comes from the workbook where recording started. + assert!(m.inputs[0].header); + assert_eq!(recorded(&mut s, "Here"), ["cell.set {\"cell\":\"Input.cell(1,4)\",\"input\":\"Total\"}"]); + + // When that workbook was closed, stopping says so and ends the recording. + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "E3", "x"); + s.set_active(1); + s.close_document(0); + let err = s.execute("macro.record", json!({"stop": true, "name": "Gone"})).unwrap_err().to_string(); + assert!(err.contains("was closed"), "{err}"); + assert!(!s.is_recording()); + assert!(s.doc().unwrap().wb.macros.is_empty()); +} + +#[test] +fn a_name_given_when_recording_stops_never_replaces_a_macro() { + let mut s = s(); + let original = json!({"command": "cell.set", "params": {"cell": "Z9", "input": "mine"}}); + put(&mut s, json!({"name": "Tidy", "description": "keep me", "steps": [original]})); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "recorded"); + let r = s.execute("macro.record", json!({"stop": true, "name": "tidy"})).unwrap(); + assert_eq!(r["saved"], "tidy (2)"); + let kept = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Tidy"})).unwrap()).unwrap(); + assert_eq!((kept.name.as_str(), kept.description.as_str(), kept.steps.len()), ("Tidy", "keep me", 1)); + assert_eq!(recorded(&mut s, "Tidy"), ["cell.set {\"cell\":\"Z9\",\"input\":\"mine\"}"]); + assert_eq!(recorded(&mut s, "tidy (2)"), ["cell.set {\"cell\":\"A1\",\"input\":\"recorded\"}"]); +} + +#[test] +fn steps_past_the_macro_limit_are_counted_as_dropped() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + let (doc, selection) = (s.doc().unwrap().uid, s.doc().unwrap().selection.clone()); + let rec = s.recorder.as_mut().unwrap(); + for i in 0..gridcraft_model::MAX_MACRO_STEPS + 5 { + rec.push(crate::record::RecordedStep { + doc, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: selection.clone(), + id: "cell.set".into(), + params: json!({"cell": "A1", "input": i.to_string()}), + }); + } + rec.dropped = 3; + let r = s.execute("macro.record", json!({"stop": true, "name": "Long"})).unwrap(); + assert_eq!(r["steps"], gridcraft_model::MAX_MACRO_STEPS); + assert_eq!(r["dropped"], 8, "{r}"); +} + +#[test] +fn a_recording_waits_while_a_macro_in_its_workbook_is_debugged() { + let mut s = s(); + stepper(&mut s); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "Z1", "typed"); + debug(&mut s, json!({"name": "Walk", "inputs": "B2:C5"})); + debug(&mut s, json!({"action": "step"})); + // Saved now, the macro would go into the run's undo group, and stopping the run without + // keeping its changes would delete it again. + let r = s.execute("macro.record", json!({"stop": true, "name": "Typed"})); + assert!(matches!(&r, Err(EngineError::Disabled(id, why)) if id == "macro.record" && why == crate::macros::debug::BUSY), "{r:?}"); + assert!(s.is_recording()); + debug(&mut s, json!({"action": "stop"})); + let r = s.execute("macro.record", json!({"stop": true, "name": "Typed"})).unwrap(); + assert_eq!(r["saved"], "Typed"); + assert_eq!(recorded(&mut s, "Typed"), ["cell.set {\"cell\":\"Z1\",\"input\":\"typed\"}"]); +} + +/// Opens a JSON workbook whose `macros` field holds `macros`, as a file the person opened. +fn open_with_macros(s: &mut Session, macros: Vec) -> Vec { + let mut file = serde_json::to_value(gridcraft_model::Workbook::new()).unwrap(); + file["macros"] = json!(macros); + let r = s.execute("file.open", json!({"name": "kept.json", "base64": crate::io::base64_encode(file.to_string().as_bytes())})).unwrap(); + r["warnings"].as_array().map(|w| w.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect()).unwrap_or_default() +} + +#[test] +fn kept_macros_count_toward_the_limit_and_keep_their_names() { + // Macros in a newer format count toward the limit when the file opens again, so they count + // when one is added; their names are taken. + let mut s = s(); + let newer = |name: &str| json!({"name": name, "v": gridcraft_model::MACRO_VERSION + 1}); + let mut list: Vec = (0..gridcraft_model::MAX_MACROS - 2).map(|i| json!({"name": format!("M{i}")})).collect(); + list.extend([newer("Future"), newer("Macro1")]); + open_with_macros(&mut s, list); + assert_eq!((s.doc().unwrap().wb.macros.len(), s.doc().unwrap().wb.macros_unread.len()), (gridcraft_model::MAX_MACROS - 2, 2)); + let err = s.execute("macro.save", json!({"name": "New"})).unwrap_err().to_string(); + assert!(err.contains("at most 1000 macros") && err.contains("2 of the 1000"), "{err}"); + s.execute("macro.delete", json!({"name": "M0"})).unwrap(); + for (cmd, p) in [("macro.save", json!({"name": " future "})), ("macro.rename", json!({"name": "M1", "to": "MACRO1"}))] { + let err = s.execute(cmd, p).unwrap_err().to_string(); + assert!(err.contains("newer version"), "{cmd}: {err}"); + } + // Record Macro refuses a kept macro's name when recording starts. + let err = s.execute("macro.record", json!({"name": "future"})).unwrap_err().to_string(); + assert!(err.contains("newer version") && !s.is_recording(), "{err}"); + // A recording takes the next free name, around the kept one, and fills the last place. + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + assert_eq!(s.execute("macro.record", json!({"stop": true})).unwrap()["saved"], "Macro2"); + assert_eq!(crate::macros::unique_name(&s.doc().unwrap().wb, "Future"), "Future (2)"); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A2", "more"); + let err = s.execute("macro.record", json!({"stop": true, "name": "Late"})).unwrap_err().to_string(); + assert!(err.contains("2 of the 1000"), "{err}"); + s.execute("macro.record", json!({"cancel": true})).unwrap(); + // Saved and opened again: every macro, the kept ones too, is still there. + for format in ["xlsx", "json"] { + let b64 = s.execute("file.saveBytes", json!({"format": format})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": format!("again.{format}"), "base64": b64})).unwrap(); + let wb = &s.doc().unwrap().wb; + assert_eq!((wb.macros.len(), wb.macros_unread.len()), (gridcraft_model::MAX_MACROS - 2, 2), "{format}"); + assert_eq!(*wb.macros_unread, [newer("Future"), newer("Macro1")], "{format}"); + } +} + +#[test] +fn re_saving_a_large_macro_keeps_the_undo_history_within_its_budget() { + // Each save keeps a copy of the macro list in the undo step before it. The budget counts + // the copies' parameters, so it trims the history instead of keeping every copy. + let mut s = s(); + let big = "x".repeat(3_900_000); + let saves = 36; + for i in 0..saves { + let m = json!({"name": "Big", "description": i.to_string(), "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": big}}]}); + s.execute("macro.save", json!({"macro": m, "replace": true})).unwrap(); + } + let d = s.doc().unwrap(); + let kept: usize = d.undo.iter().map(|e| e.retained_bytes).sum(); + assert!(kept <= crate::UNDO_BYTES_LIMIT, "{kept}"); + assert!(d.undo.len() < saves, "{} undo steps", d.undo.len()); + assert!(d.undo.iter().skip(1).all(|e| e.retained_bytes > big.len()), "each kept copy is charged for its parameters"); +} + +#[test] +fn the_recorded_input_is_inferred_as_it_was_when_recording_started() { + // The recording turns text numbers into numbers; the macro is for data like the data it was + // recorded on, so the input's column types are the ones it had when recording started. + let mut s = s(); + for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "'120"), ("B4", "Bo"), ("C4", "'80")] { + set(&mut s, cell, input); + } + s.execute("selection.set", json!({"range": "B2:C4"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "C3", "120"); + set(&mut s, "C4", "80"); + s.execute("macro.record", json!({"stop": true, "name": "Numbers"})).unwrap(); + let m = crate::macros::parse_macro(&s.execute("macro.get", json!({"name": "Numbers"})).unwrap()).unwrap(); + assert!(!m.inputs[0].col_types.contains(&gridcraft_model::ColType::Number), "{:?}", m.inputs[0]); + // Played back on data like the original, text numbers and all. + for (cell, input) in [("G10", "Who"), ("H10", "Paid"), ("G11", "A"), ("H11", "'1"), ("G12", "B"), ("H12", "'2")] { + set(&mut s, cell, input); + } + s.execute("macro.run", json!({"name": "Numbers", "inputs": "G10:H12", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "H11"), gridcraft_core::Value::Number(120.0)); +} + +#[test] +fn a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording() { + // Played back, a menu-style step with no parameters would stop the run at its dialog; the + // script's edit is kept. A run whose steps the recording couldn't keep is left out, not read + // from the workbook as the script stands at the end. + let mut s = s(); + let steps = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]); + s.execute("automate.newScript", json!({"name": "EditThenAsk", "commands": steps})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"B1\",\"input\":\"1\"}"]); + set(&mut s, "B1", ""); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "B1").display(), "1"); + assert!(!s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "EditThenAsk"})).unwrap(); + s.recorder.as_mut().unwrap().ran.clear(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Without"})).unwrap(); + assert_eq!(r["skipped"], json!(["automate.runScript"])); + assert!(recorded(&mut s, "Without").is_empty()); +} + +#[test] +fn a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording() { + // What a recorded script run ran goes with its step when a group rollback takes the step + // back, and a kept run keeps what it ran under its new place. + let mut s = s(); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "B1", "gone"); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + s.execute("automate.stopAction", json!({})).unwrap(); + let rec = s.recorder.as_ref().unwrap(); + assert_eq!((rec.steps.len(), rec.ran.keys().copied().collect::>()), (1, vec![0])); + s.execute("automate.runScript", json!({"name": "Mark"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + let a1 = "cell.set {\"cell\":\"A1\",\"input\":\"m\"}"; + assert_eq!(recorded(&mut s, "Rec"), [a1, a1]); +} + +#[test] +fn a_script_run_records_the_state_its_toggles_set() { + let mut s = s(); + s.execute( + "automate.newScript", + json!({"name": "Bolder", "commands": [{"command": "home.bold", "params": {"range": "A1"}}, {"command": "home.italic"}]}), + ) + .unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Bolder"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(recorded(&mut s, "Rec"), ["home.bold {\"on\":true,\"range\":\"A1\"}", "home.italic {\"on\":true}"]); + // Played back twice, A1 stays bold: the step sets the state rather than flipping it. + for _ in 0..2 { + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert!(bold(&s, "A1")); + } +} + +#[test] +fn renaming_checks_the_macro_size() { + // A longer name can take a macro past the size a file holds: renaming is refused, as saving is. + let mut s = s(); + let mut m = json!({"name": "A", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": ""}}]}); + let size = |m: &Json| crate::macros::macro_json(&crate::macros::parse_macro(m).unwrap()).to_string().chars().count(); + let pad = crate::cmd::stash::MAX_ITEM_CHARS - size(&m); + m["steps"][0]["params"]["input"] = json!("x".repeat(pad)); + assert_eq!(size(&m), crate::cmd::stash::MAX_ITEM_CHARS); + s.execute("macro.save", json!({"macro": m})).unwrap(); + let err = s.execute("macro.rename", json!({"name": "A", "to": "Longer"})).unwrap_err().to_string(); + assert!(err.contains("too large"), "{err}"); + s.execute("macro.rename", json!({"name": "A", "to": "B"})).unwrap(); + crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); +} + +#[test] +fn a_recorded_macro_run_keeps_the_steps_that_ran() { + // The macro is changed and another deleted before the recording stops: the recording holds + // the steps as they ran. + let mut s = s(); + let one = |cell: &str, input: &str| json!({"output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": cell, "input": input}}]}); + for (name, cell, input) in [("Changed", "C1", "first"), ("Gone", "C2", "kept")] { + let mut m = one(cell, input); + m["name"] = json!(name); + put(&mut s, m); + } + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("macro.run", json!({"name": "Changed"})).unwrap(); + s.execute("macro.run", json!({"name": "Gone"})).unwrap(); + let mut changed = one("C1", "changed"); + changed["name"] = json!("Changed"); + s.execute("macro.save", json!({"macro": changed, "replace": true})).unwrap(); + s.execute("macro.delete", json!({"name": "Gone"})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["macro.save", "macro.delete"]), "{r}"); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"C1\",\"input\":\"first\"}", "cell.set {\"cell\":\"C2\",\"input\":\"kept\"}"]); +} + +#[test] +fn recorded_text_that_reads_as_a_token_plays_back_as_text() { + // Braces typed into a cell and a defined name called Output are text and a name when + // recorded; played back they stay so, instead of being read as tokens. + let mut s = s(); + data(&mut s); + s.execute("formulas.defineName", json!({"name": "Output", "refersTo": "=Sheet1!$H$1"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "A1", "Dear {{name}}"); + set(&mut s, "E3", "=SUM(C3:C5)&\"{{x\""); + s.execute("edit.goTo", json!({"reference": "Output"})).unwrap(); + s.execute("home.bold", json!({})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Letters"})).unwrap(); + for cell in ["A1", "E3"] { + set(&mut s, cell, ""); + } + s.execute("home.bold", json!({"range": "H1"})).unwrap(); + assert!(!bold(&s, "H1")); + s.execute("macro.run", json!({"name": "Letters", "inputs": "B2:C5", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "Dear {{name}}"); + assert_eq!(v(&s, "E3").display(), "400{{x"); + assert!(bold(&s, "H1")); + let steps = recorded(&mut s, "Letters"); + assert!(steps.contains(&"edit.goTo {\"reference\":{\"$text\":\"Output\"}}".to_string()), "{steps:?}"); + assert!( + steps.iter().any(|st| st.contains("=SUM({{Input.rows(2..).col(2)}})&\\\"{{{{x\\\"")), + "the formula's own reference is still a token: {steps:?}" + ); +} + +#[test] +fn a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking() { + // The person recorded it: it doesn't ask as a macro that came with the file does. + let mut s = s(); + open_with_macros(&mut s, vec![]); + assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "mine"); + s.execute("macro.record", json!({"stop": true, "name": "Mine"})).unwrap(); + set(&mut s, "A1", ""); + s.execute("macro.run", json!({"name": "Mine", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "mine"); + // Changed, it asks again. + s.execute("macro.save", json!({"name": "Mine", "replace": true, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})) + .unwrap(); + assert!(matches!(s.execute("macro.run", json!({"name": "Mine"})), Err(EngineError::Approve(_)))); +} + +#[test] +fn a_recording_past_the_macro_size_is_cut_and_counted() { + // Three steps of 1.5 million characters each don't fit one macro: the third is left out and + // counted in `dropped`, and the rest is saved. + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + let (doc, selection) = (s.doc().unwrap().uid, s.doc().unwrap().selection.clone()); + let rec = s.recorder.as_mut().unwrap(); + for i in 0..3 { + rec.push(crate::record::RecordedStep { + doc, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: selection.clone(), + id: "cell.set".into(), + params: json!({"cell": format!("A{}", i + 1), "input": "x".repeat(1_500_000)}), + }); + } + let r = s.execute("macro.record", json!({"stop": true, "name": "Large"})).unwrap(); + assert_eq!((r["steps"].as_u64(), r["dropped"].as_u64()), (Some(2), Some(1)), "{r}"); + assert!(!s.is_recording()); + crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); +} + +#[test] +fn record_refuses_parameters_it_would_ignore() { + let mut s = s(); + // `name`, `description` and `shortcut` count when starting (Record Macro); wrong kinds don't. + for p in [json!({"name": 5}), json!({"shortcut": true}), json!({"relative": "no"}), json!({"stop": 1})] { + assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); + assert!(!s.is_recording(), "{p}"); + } + s.execute("macro.record", json!({"relative": false, "name": null})).unwrap(); + for p in [json!({"relative": true}), json!({"shortcut": "K"}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { + assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); + assert!(s.is_recording(), "{p}"); + } + assert_eq!(s.execute("macro.record", json!({"cancel": true})).unwrap()["cancelled"], true); +} + +#[test] +fn saving_over_a_macro_needs_replace() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Tidy", "description": "first"})).unwrap(); + let err = s.execute("macro.save", json!({"name": "TIDY", "description": "second"})).unwrap_err().to_string(); + assert!(err.contains("'Tidy' already exists") && err.contains("replace: true"), "{err}"); + assert!(s.execute("macro.save", json!({"name": "Tidy", "replace": "yes"})).is_err()); + assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "first"); + s.execute("macro.save", json!({"name": "TIDY", "description": "second", "replace": true})).unwrap(); + assert_eq!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["description"], "second"); +} + +#[test] +fn undo_while_recording_keeps_the_undone_step_as_excel_does() { + let mut s = s(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + set(&mut s, "A1", "typed"); + s.execute("edit.undo", json!({})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(r["skipped"], json!(["edit.undo"]), "{r}"); + assert_eq!(recorded(&mut s, "Rec"), ["cell.set {\"cell\":\"A1\",\"input\":\"typed\"}"]); + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert_eq!(v(&s, "A1").display(), "typed"); +} + +#[test] +fn record_checks_a_name_shortcut_and_description_when_it_starts() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})).unwrap(); + for (p, needle) in [ + (json!({"name": "taken"}), "already exists"), + (json!({"name": "a\nb"}), "line breaks"), + (json!({"name": "x".repeat(300)}), "at most"), + (json!({"name": "New", "shortcut": "p"}), "already runs macro"), + (json!({"name": "New", "shortcut": "Ctrl+Shift+Z"}), "Redo"), + (json!({"name": "New", "shortcut": "F5"}), "one letter"), + (json!({"name": "New", "description": "d".repeat(100_000)}), "at most"), + ] { + let e = s.execute("macro.record", p.clone()).unwrap_err().to_string(); + assert!(e.contains(needle), "{p}: {e}"); + assert!(!s.is_recording(), "{p}"); + } + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "j", "description": "Tidies"})); + // J is Ungroup on a Mac, so it is reserved; M is free. + assert!(r.is_err_and(|e| e.to_string().contains("Ungroup"))); + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "m", "description": "Tidies"})).unwrap(); + assert_eq!(r["name"], json!("Tidy")); + set(&mut s, "A1", "1"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], json!("Tidy")); + let m = s.execute("macro.get", json!({"name": "Tidy"})).unwrap(); + assert_eq!((m["shortcut"].clone(), m["description"].clone()), (json!("Ctrl+Shift+M"), json!("Tidies"))); + // Empty params record under the next MacroN, as before, and never open a dialog. + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({})).unwrap(); + assert!(r["saved"].as_str().is_some_and(|n| n.starts_with("Macro")), "{r}"); + assert!(s.take_ui_requests().is_empty(), "no dialog"); +} + +#[test] +fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { + use crate::macros::{parse_shortcut, shortcut_conflict, shortcut_text}; + assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M')); + assert_eq!(parse_shortcut("⌘⇧k"), Some('K')); + assert_eq!(parse_shortcut(" q "), Some('Q')); + assert_eq!(parse_shortcut("cmd+shift+r"), Some('R')); + // Other modifiers are refused, not read as Ctrl+Shift. + for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é", "Ctrl+Alt+M", "Shift+M", "Alt+M", "Ctrl+M"] { + assert_eq!(parse_shortcut(bad), None, "{bad}"); + } + assert_eq!(shortcut_text('m'), "Ctrl+Shift+M"); + let mut wb = gridcraft_model::Workbook::default(); + wb.macros.push(gridcraft_model::Macro { name: "A".into(), shortcut: Some("Ctrl+Shift+M".into()), ..Default::default() }); + wb.macros.push(gridcraft_model::Macro { name: "B".into(), ..Default::default() }); + assert!(shortcut_conflict(&wb, "B", 's').is_some_and(|m| m.contains("Save As"))); + assert!(shortcut_conflict(&wb, "B", 'm').is_some_and(|m| m.contains("“A”"))); + // A macro keeps its own letter. + assert_eq!(shortcut_conflict(&wb, "a", 'M'), None); + assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None); +} + +#[test] +fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() { + let mut s = s(); + s.execute("macro.record", json!({"name": "Tidy", "shortcut": "m"})).unwrap(); + set(&mut s, "A1", "1"); + // Another macro takes Ctrl+Shift+M before recording stops. + s.execute("macro.save", json!({"name": "Other", "shortcut": "Ctrl+Shift+M", "steps": []})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], "Tidy"); + assert_eq!(r["shortcutDropped"], "Ctrl+Shift+M", "{r}"); + assert!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["shortcut"].is_null()); + // A free one is kept and not reported. + s.execute("macro.record", json!({"name": "Keep", "shortcut": "q"})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert!(r.get("shortcutDropped").is_none(), "{r}"); + assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q"); +} diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 70cbb61cf..9491c3773 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -820,6 +820,91 @@ fn a_call_that_only_opens_a_dialog_is_not_recorded() { assert_eq!(s.stop_recording().unwrap().dropped, 0); } +#[test] +fn stop_action_runs_only_on_its_own_and_is_not_recorded() { + // A step of an action in progress may not stop the action: nested, the command is refused + // and the action's group stays. Nor is Stop Action a step of the person's work: a recording + // leaves it out, so playing the recording back never stops the run it is part of. + let mut s = s(); + s.start_recording(); + let r: crate::Result<()> = s.grouped("Outer", |s| { + set(s, "A1", "one"); + let err = s.execute_nested("automate.stopAction", json!({})).unwrap_err().to_string(); + assert!(err.contains("on its own"), "{err}"); + set(s, "A2", "two"); + Ok(()) + }); + r.unwrap(); + assert_eq!(v(&s, "A2").display(), "two"); + assert_eq!(undo_labels(&s), ["Outer"], "the action kept its group"); + // On its own it stops a group left open; the recording keeps neither the stop nor the edits + // the stop rolled back, so playing it back makes only the edits the workbook has. + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "A3", "three"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert!(v(&s, "A3").is_empty()); + assert_eq!(recorded_ids(&s), ["cell.set", "cell.set"]); + // An edit recorded in another workbook while the group was open stays, as its edit does. + let first = s.active_index(); + let _left = s.group_begin("Left open").unwrap(); + set(&mut s, "A4", "four"); + s.new_workbook(); + set(&mut s, "B1", "other"); + s.set_active(first); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"], "the other workbook's edit stays"); + assert_eq!(s.recorder.as_ref().unwrap().steps.last().map(|st| st.params["cell"].clone()), Some(json!("B1"))); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + +#[test] +fn rollbacks_take_back_their_own_recorded_steps_in_any_order() { + // Groups open on two workbooks, each with a recorded edit: rolling back the first takes out + // only its edit, and the second's mark still finds its own after that. + let mut s = s(); + s.start_recording(); + let a = s.active_index(); + let _in_a = s.group_begin("A").unwrap(); + set(&mut s, "A1", "a"); + s.new_workbook(); + let b = s.active_index(); + let _in_b = s.group_begin("B").unwrap(); + set(&mut s, "B1", "b"); + s.set_active(a); + s.execute("automate.stopAction", json!({})).unwrap(); + let cells = |s: &Session| s.recorder.as_ref().unwrap().steps.iter().map(|st| st.params["cell"].clone()).collect::>(); + assert_eq!(cells(&s), [json!("B1")]); + s.set_active(b); + s.execute("automate.stopAction", json!({})).unwrap(); + assert!(cells(&s).is_empty()); + // A recording started again inside a group: every step of it came after the group began. + let _again = s.group_begin("Again").unwrap(); + s.start_recording(); + set(&mut s, "C1", "c"); + s.execute("automate.stopAction", json!({})).unwrap(); + assert!(cells(&s).is_empty(), "{:?}", cells(&s)); +} + +#[test] +fn a_run_that_edits_and_opens_a_dialog_is_recorded() { + // A script with an edit before a menu-style step: the edit happened, so the run is a step of + // the recording (the person's confirming call is another). Only a call that opened a dialog + // and changed nothing is left out. + let mut s = s(); + let edit_then_dialog = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]); + let dialog_only = json!([{"command": "home.formatCells", "params": {}}]); + s.execute("automate.newScript", json!({"name": "Edit", "commands": edit_then_dialog})).unwrap(); + s.execute("automate.newScript", json!({"name": "Ask", "commands": dialog_only})).unwrap(); + s.start_recording(); + s.execute("automate.runScript", json!({"name": "Ask"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s)); + s.execute("automate.runScript", json!({"name": "Edit"})).unwrap(); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..)))); + assert_eq!(recorded_ids(&s), ["automate.runScript"], "the edit happened, so the run is a step"); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 4c91e58ac..20898e152 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -23,9 +23,11 @@ mod pivot_unknown_func; mod pivotchart_link; mod range_fill_formats; mod row_autofit; +mod sheet_copy_spill; mod sheet_name_quoting; mod sparse_clear_invalidation; mod stored_list_shape; mod table_resize_overlap; mod table_total_row_last_row; +mod workbook_author; mod wrap_row_height; diff --git a/crates/engine/tests/it/sheet_copy_spill.rs b/crates/engine/tests/it/sheet_copy_spill.rs new file mode 100644 index 000000000..aed68baf9 --- /dev/null +++ b/crates/engine/tests/it/sheet_copy_spill.rs @@ -0,0 +1,33 @@ +use gridcraft_core::{CellRef, Value}; +use gridcraft_engine::Session; +use gridcraft_engine::calc::Calc; +use serde_json::{Value as Json, json}; + +fn run(s: &mut Session, command: &str, params: Json) { + s.execute(command, params).unwrap(); +} + +fn value(s: &Session, sheet: &str, cell: &str) -> Value { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap()) +} + +/// Issue #2053: the copy of a sheet holding a spill keeps the spill's values, in memory and after a full recalc. +#[test] +fn copied_sheet_keeps_spill_values_through_full_recalc() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"sample": "grades"})); + run(&mut s, "home.mergeCells", json!({"range": "F:H"})); + run(&mut s, "cell.set", json!({"cell": "J54", "input": "=SEQUENCE(5)*C6"})); + run(&mut s, "selection.set", json!({"range": "15:15"})); + run(&mut s, "sheet.move", json!({"copy": true, "sheet": 0, "to": 1})); + run(&mut s, "cell.set", json!({"array": true, "cell": "D3", "input": "=SUM('Grades (2)'!J54#)"})); + + let before = value(&s, "Grades (2)", "J55"); + assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}"); + + let mut fresh = (*s.doc().unwrap().wb).clone(); + Calc::new().recalc_all(&mut fresh); + let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap()); + assert_eq!(before, after); +} diff --git a/crates/engine/tests/it/workbook_author.rs b/crates/engine/tests/it/workbook_author.rs new file mode 100644 index 000000000..7af8b5d68 --- /dev/null +++ b/crates/engine/tests/it/workbook_author.rs @@ -0,0 +1,50 @@ +//! Regression tests for the default author of newly created workbooks. + +use gridcraft_engine::{DocState, Session, Trust, io, model::Workbook}; + +#[test] +fn new_workbook_stamps_the_configured_user_name_at_creation() { + let mut session = Session::new(); + + session.prefs.user_name = "Ada Lovelace".into(); + let first = session.new_workbook(); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); + + // Changing the preference must not rewrite an existing workbook. + session.prefs.user_name = "Grace Hopper".into(); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); + + // A new workbook uses the preference as it exists at creation. + let second = session.new_workbook(); + + assert_eq!(session.documents()[second].wb.props.author, "Grace Hopper"); + + assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace"); +} + +#[test] +fn opening_and_resaving_preserves_the_existing_workbook_author() { + // An existing XLSX has its own author metadata. + let mut source = Workbook::new(); + source.props.author = "Original Spreadsheet Author".into(); + + let bytes = io::save_bytes(&source, "source.xlsx").unwrap(); + let (opened, _) = io::open_bytes("source.xlsx", &bytes).unwrap(); + + let mut session = Session::new(); + session.prefs.user_name = "Current GridCraft User".into(); + + session.add_document(DocState::new(opened, None, "source".into(), Trust::FromFile)); + + // Opening an existing workbook must not replace its author. + assert_eq!(session.doc().unwrap().wb.props.author, "Original Spreadsheet Author"); + + // The original author must survive another XLSX save and reopen. + let saved = io::save_bytes(&session.doc().unwrap().wb, "saved.xlsx").unwrap(); + + let (reopened, _) = io::open_bytes("saved.xlsx", &saved).unwrap(); + + assert_eq!(reopened.props.author, "Original Spreadsheet Author"); +} diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index 332cb6eeb..2799b5b6f 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -67,6 +67,198 @@ fn starts_range_end(c: Option) -> bool { c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$') } +fn byte_pos(src: &str, chars: &[(usize, char)], index: usize) -> usize { + chars.get(index).map(|c| c.0).unwrap_or(src.len()) +} + +fn char_at(chars: &[(usize, char)], index: usize) -> Option { + chars.get(index).map(|c| c.1) +} + +fn emit(src: &str, chars: &[(usize, char)], out: &mut Vec, tok: Tok, start: usize, end_index: usize) { + out.push(Token { tok, start, end: byte_pos(src, chars, end_index) }); +} + +fn quoted(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let quote = char_at(chars, i).unwrap_or_default(); + let mut value = String::new(); + let mut j = i + 1; + loop { + match char_at(chars, j) { + None => { + let msg = if quote == '"' { "unterminated text" } else { "unterminated sheet name" }; + return Err(LexError { msg: msg.into(), pos: start }); + } + Some(c) if c == quote && char_at(chars, j + 1) == Some(quote) => { + value.push(quote); + j += 2; + } + Some(c) if c == quote => { + j += 1; + break; + } + Some(c) => { + value.push(c); + j += 1; + } + } + } + if quote == '"' { + emit(src, chars, out, Tok::Text(value), start, j); + return Ok(j); + } + if char_at(chars, j) != Some('!') { + return Err(LexError { msg: "expected ! after sheet name".into(), pos: byte_pos(src, chars, j) }); + } + let (first, last) = match value.split_once(':') { + Some((first, last)) => (first.to_string(), Some(last.to_string())), + None => (value, None), + }; + emit(src, chars, out, Tok::Sheet(first, last), start, j + 1); + Ok(j + 1) +} + +fn error_literal(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let rest = src.get(start..).unwrap_or(""); + let error = CellError::ALL + .iter() + .copied() + .find(|error| rest.get(..error.as_str().len()).is_some_and(|text| text.eq_ignore_ascii_case(error.as_str()))) + .ok_or_else(|| LexError { msg: "unknown error literal".into(), pos: start })?; + let end = i + error.as_str().chars().count(); + emit(src, chars, out, Tok::Error(error), start, end); + Ok(end) +} + +fn structured_ref(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { + let mut depth = 0; + let mut j = i; + loop { + match char_at(chars, j) { + None => return Err(LexError { msg: "unbalanced [".into(), pos: start }), + Some('\'') => j += 2, + Some('[') => { + depth += 1; + j += 1; + } + Some(']') => { + depth -= 1; + j += 1; + if depth == 0 { + break; + } + } + Some(_) => j += 1, + } + } + let inner = src.get(byte_pos(src, chars, i + 1)..byte_pos(src, chars, j.saturating_sub(1))).unwrap_or("").to_string(); + emit(src, chars, out, Tok::Struct(inner), start, j); + Ok(j) +} + +fn number(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, i: usize, start: usize, out: &mut Vec) -> Result { + let ch = |index| char_at(chars, index); + let mut j = i; + while ch(j).is_some_and(|c| c.is_ascii_digit()) { + j += 1; + } + let is_row_ref = { + let mut k = j; + if ch(k) == Some('.') && ch(k + 1) == Some(':') { + k += 1; + } + ch(k) == Some(':') && { + k += 1; + if ch(k) == Some('.') { + k += 1; + } + if ch(k) == Some('$') { + k += 1; + } + ch(k).is_some_and(|c| c.is_ascii_digit()) + } + }; + if is_row_ref { + let word = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + emit(src, chars, out, Tok::Word(word), start, j); + return Ok(j); + } + if ch(j) == Some(locale.decimal_separator()) { + j += 1; + while ch(j).is_some_and(|c| c.is_ascii_digit()) { + j += 1; + } + } + if matches!(ch(j), Some('e' | 'E')) { + let mut exponent = j + 1; + if matches!(ch(exponent), Some('+' | '-')) { + exponent += 1; + } + if ch(exponent).is_some_and(|c| c.is_ascii_digit()) { + while ch(exponent).is_some_and(|c| c.is_ascii_digit()) { + exponent += 1; + } + j = exponent; + } + } + let text = src.get(start..byte_pos(src, chars, j)).unwrap_or(""); + let value: f64 = + text.replace(locale.decimal_separator(), ".").parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; + if !value.is_finite() { + return Err(LexError { msg: format!("number too large `{text}`"), pos: start }); + } + emit(src, chars, out, Tok::Number(value), start, j); + Ok(j) +} + +fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, i: usize, start: usize, out: &mut Vec) -> usize { + let ch = |index| char_at(chars, index); + let mut j = i; + while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + j += 1; + } + let value: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + if ch(j) == Some('!') { + emit(src, chars, out, Tok::Sheet(value, None), start, j + 1); + j + 1 + } else if ch(j) == Some(':') && sheet_range_end(chars, locale, array_depth, &value, j, out) { + let mut end = j + 1; + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + end += 1; + } + let last: String = chars.get(j + 1..end).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); + emit(src, chars, out, Tok::Sheet(value, Some(last)), start, end + 1); + end + 1 + } else if ch(j) == Some('(') { + emit(src, chars, out, Tok::Func(value), start, j + 1); + j + 1 + } else if value.eq_ignore_ascii_case("TRUE") && ch(j) != Some('[') { + emit(src, chars, out, Tok::Bool(true), start, j); + j + } else if value.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') { + emit(src, chars, out, Tok::Bool(false), start, j); + j + } else if value.len() > 1 && value.ends_with('.') && ch(j) == Some(':') && is_range_end(value.get(..value.len() - 1).unwrap_or("")) { + emit(src, chars, out, Tok::Word(value[..value.len() - 1].to_string()), start, j - 1); + j - 1 + } else { + emit(src, chars, out, Tok::Word(value), start, j); + j + } +} + +fn sheet_range_end(chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, value: &str, colon: usize, out: &[Token]) -> bool { + let ch = |index| char_at(chars, index); + let mut end = colon + 1; + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + end += 1; + } + let repeats_sheet = is_range_end(value) + && matches!(out.last(), Some(Token { tok: Tok::Sheet(previous, None), .. }) + if chars.get(colon + 1..end).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::(), previous))); + end > colon + 1 && ch(end) == Some('!') && !repeats_sheet +} + /// Tokenizes formula text (without the leading `=`). pub fn tokenize(src: &str) -> Result, LexError> { tokenize_locale(src, crate::FormulaLocale::En) @@ -94,107 +286,17 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - let mut s = String::new(); - let mut j = i + 1; - loop { - match ch(j) { - None => return Err(LexError { msg: "unterminated text".into(), pos: start }), - Some('"') if ch(j + 1) == Some('"') => { - s.push('"'); - j += 2; - } - Some('"') => { - j += 1; - break; - } - Some(x) => { - s.push(x); - j += 1; - } - } - } - push(&mut out, Tok::Text(s), j); - i = j; - } - '\'' => { - // Quoted sheet name: 'My Sheet'! or 'A:B'! - let mut s = String::new(); - let mut j = i + 1; - loop { - match ch(j) { - None => return Err(LexError { msg: "unterminated sheet name".into(), pos: start }), - Some('\'') if ch(j + 1) == Some('\'') => { - s.push('\''); - j += 2; - } - Some('\'') => { - j += 1; - break; - } - Some(x) => { - s.push(x); - j += 1; - } - } - } - if ch(j) != Some('!') { - return Err(LexError { msg: "expected ! after sheet name".into(), pos: pos_of(j) }); - } - let (a, b) = match s.split_once(':') { - Some((a, b)) => (a.to_string(), Some(b.to_string())), - None => (s, None), - }; - push(&mut out, Tok::Sheet(a, b), j + 1); - i = j + 1; - } + '"' | '\'' => i = quoted(src, &chars, i, start, &mut out)?, '#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => { // Spill range operator right after a reference: `A1#`. push(&mut out, Tok::Op("#"), i + 1); i += 1; } '#' => { - // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). - let rest = src.get(start..).unwrap_or(""); - let mut found = None; - for e in CellError::ALL { - let t = e.as_str(); - if rest.len() >= t.len() && rest.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t)) { - found = Some(e); - break; - } - } - let Some(e) = found else { return Err(LexError { msg: "unknown error literal".into(), pos: start }) }; - let len = e.as_str().chars().count(); - // `#REF!A1` style is not valid; `#REF!` is an error literal. - push(&mut out, Tok::Error(e), i + len); - i += len; + i = error_literal(src, &chars, i, start, &mut out)?; } '[' => { - // Structured reference body: balanced brackets. - let mut depth = 0; - let mut j = i; - loop { - match ch(j) { - None => return Err(LexError { msg: "unbalanced [".into(), pos: start }), - Some('\'') => j += 2, // escape char inside struct refs - Some('[') => { - depth += 1; - j += 1; - } - Some(']') => { - depth -= 1; - j += 1; - if depth == 0 { - break; - } - } - Some(_) => j += 1, - } - } - let inner = src.get(pos_of(i + 1)..pos_of(j.saturating_sub(1))).unwrap_or("").to_string(); - push(&mut out, Tok::Struct(inner), j); - i = j; + i = structured_ref(src, &chars, i, start, &mut out)?; } '.' if ch(i + 1) == Some(':') => { // Trim reference `A1.:E10` / `A1.:.E10` (the word before stopped at the dot). @@ -207,64 +309,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - // Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers - // followed by ':' and digits are row refs: lex as Word. - let mut j = i; - while ch(j).is_some_and(|d| d.is_ascii_digit()) { - j += 1; - } - // Row ranges `1:3`, and the trim forms `1.:3`, `1:.3`, `1.:.3`. - let is_row_ref = { - let mut k = j; - if ch(k) == Some('.') && ch(k + 1) == Some(':') { - k += 1; - } - ch(k) == Some(':') && { - k += 1; - if ch(k) == Some('.') { - k += 1; - } - if ch(k) == Some('$') { - k += 1; - } - ch(k).is_some_and(|d| d.is_ascii_digit()) - } - }; - // A word like "1A" can't happen in valid formulas; numbers then. - if is_row_ref { - let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - push(&mut out, Tok::Word(w), j); - i = j; - continue; - } - if ch(j) == Some(locale.decimal_separator()) { - j += 1; - while ch(j).is_some_and(|d| d.is_ascii_digit()) { - j += 1; - } - } - if matches!(ch(j), Some('e' | 'E')) { - let mut k = j + 1; - if matches!(ch(k), Some('+' | '-')) { - k += 1; - } - if ch(k).is_some_and(|d| d.is_ascii_digit()) { - while ch(k).is_some_and(|d| d.is_ascii_digit()) { - k += 1; - } - j = k; - } - } - let text = src.get(start..pos_of(j)).unwrap_or(""); - let v: f64 = text - .replace(locale.decimal_separator(), ".") - .parse() - .map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?; - if !v.is_finite() { - return Err(LexError { msg: format!("number too large `{text}`"), pos: start }); - } - push(&mut out, Tok::Number(v), j); - i = j; + i = number(src, &chars, locale, i, start, &mut out)?; } '(' => { push(&mut out, Tok::LParen, i + 1); @@ -328,53 +373,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - let mut j = i; - while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - j += 1; - } - let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - if ch(j) == Some('!') { - push(&mut out, Tok::Sheet(w, None), j + 1); - i = j + 1; - } else if ch(j) == Some(':') && { - // Sheet1:Sheet3!A1 — look ahead for word then '!'. - let mut k = j + 1; - while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - k += 1; - } - // `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span. - let repeats_sheet = is_range_end(&w) - && matches!(out.last(), Some(Token { tok: Tok::Sheet(p, None), .. }) - if chars.get(j + 1..k).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::(), p))); - k > j + 1 && ch(k) == Some('!') && !repeats_sheet - } { - let mut k = j + 1; - while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { - k += 1; - } - let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); - push(&mut out, Tok::Sheet(w, Some(w2)), k + 1); - i = k + 1; - } else if ch(j) == Some('(') { - push(&mut out, Tok::Func(w), j + 1); - i = j + 1; - } else if w.eq_ignore_ascii_case("TRUE") && ch(j) != Some('[') { - push(&mut out, Tok::Bool(true), j); - i = j; - } else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') { - push(&mut out, Tok::Bool(false), j); - i = j; - } else if w.len() > 1 && w.ends_with('.') && ch(j) == Some(':') && is_range_end(w.get(..w.len() - 1).unwrap_or("")) { - // `A1.:E10`: the dot belongs to the trim operator, lexed next. - let w = w.get(..w.len() - 1).unwrap_or("").to_string(); - push(&mut out, Tok::Word(w), j - 1); - i = j - 1; - } else { - push(&mut out, Tok::Word(w), j); - i = j; - } - } + c if is_word_char(c) => i = word(src, &chars, locale, array_depth, i, start, &mut out), other => return Err(LexError { msg: format!("unexpected character `{other}`"), pos: start }), } } diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 8a1c0c45b..bddbfebf2 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -2,7 +2,7 @@ use std::fmt::Write; -use gridcraft_core::{CellRef, col_to_letters, number_to_text}; +use gridcraft_core::{CellRef, number_to_text}; use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; @@ -19,8 +19,32 @@ fn sheet_prefix(s: &SheetSel) -> String { } } -fn anchor_a1(a: &Anchor) -> String { - format!("{}{}{}{}", if a.col_abs { "$" } else { "" }, col_to_letters(a.col), if a.row_abs { "$" } else { "" }, a.row as u64 + 1) +fn push_anchor_a1(s: &mut String, a: &Anchor) { + if a.col_abs { + s.push('$'); + } + push_col(s, a.col); + if a.row_abs { + s.push('$'); + } + let _ = write!(s, "{}", a.row as u64 + 1); +} + +/// Appends [`gridcraft_core::col_to_letters`]`(col)` without allocating. +fn push_col(s: &mut String, col: u32) { + let mut letters = [0u8; 8]; + let mut i = letters.len(); + let mut n = col as u64 + 1; + while n > 0 && i > 0 { + i -= 1; + if let Some(l) = letters.get_mut(i) { + *l = b'A' + ((n - 1) % 26) as u8; + } + n = (n - 1) / 26; + } + for &l in letters.get(i..).unwrap_or_default() { + s.push(char::from(l)); + } } pub fn reference_a1(r: &Reference) -> String { @@ -29,15 +53,40 @@ pub fn reference_a1(r: &Reference) -> String { /// A1 text with `sep` between the two ends of a range (`:` or a trim operator). fn range_a1(r: &Reference, sep: &str) -> String { - let body = match &r.kind { - RefKind::Cell(a) => anchor_a1(a), - RefKind::Range(a, b) => format!("{}{sep}{}", anchor_a1(a), anchor_a1(b)), - RefKind::Rows(r0, a0, r1, a1) => format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1), - RefKind::Cols(c0, a0, c1, a1) => { - format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1)) + let mut s = String::new(); + push_range_a1(&mut s, r, sep); + s +} + +/// Appends [`range_a1`]`(r, sep)` to `s`. +fn push_range_a1(s: &mut String, r: &Reference, sep: &str) { + match &r.sheet { + SheetSel::Current => {} + SheetSel::Named(n) => { + s.push_str("e_sheet(n)); + s.push('!'); } - }; - format!("{}{}", sheet_prefix(&r.sheet), body) + sheet @ SheetSel::Span(_, _) => s.push_str(&sheet_prefix(sheet)), + } + let dollar = |abs: bool| if abs { "$" } else { "" }; + match &r.kind { + RefKind::Cell(a) => push_anchor_a1(s, a), + RefKind::Range(a, b) => { + push_anchor_a1(s, a); + s.push_str(sep); + push_anchor_a1(s, b); + } + RefKind::Rows(r0, a0, r1, a1) => { + let _ = write!(s, "{}{}{sep}{}{}", dollar(*a0), r0 + 1, dollar(*a1), r1 + 1); + } + RefKind::Cols(c0, a0, c1, a1) => { + s.push_str(dollar(*a0)); + push_col(s, *c0); + s.push_str(sep); + s.push_str(dollar(*a1)); + push_col(s, *c1); + } + } } fn r1c1_part(letter: char, v: u32, abs: bool, base: u32) -> String { @@ -126,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); } (Some(c), None) if s.specifiers.is_empty() => { - // Excel writes a name with spaces at its edges in the double-bracket form. - if c.trim() == c { + // Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form. + if c.trim() == c && !c.starts_with('@') { let _ = write!(out, "[{}]", esc(c)); } else { let _ = write!(out, "[[{}]]", esc(c)); @@ -207,7 +256,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { } Expr::Ref(r) => match r1c1 { Some(at) => s.push_str(&reference_r1c1(r, at)), - None => s.push_str(&reference_a1(r)), + None => push_range_a1(s, r, ":"), }, Expr::Name(n) => match sheet_split(n) { // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. @@ -246,7 +295,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { UnOp::Trim(mode) => match &**x { Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 { Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())), - None => s.push_str(&range_a1(r, mode.symbol())), + None => push_range_a1(s, r, mode.symbol()), }, Expr::Binary(BinOp::Range, a, b) => { write_expr(s, a, r1c1); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index a7506aa4b..8b8215455 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -2,7 +2,10 @@ use super::*; use crate::adjust::{Axis, Edit, adjust, rename_sheet, shift_relative}; fn rt(src: &str) -> String { - print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}"))) + match parse(src) { + Ok(e) => print(&e), + Err(e) => format!("{src}: {e}"), + } } #[test] @@ -87,11 +90,11 @@ fn normalises() { } #[test] -fn spill_operator() { +fn spill_operator() -> Result<(), Box> { for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] { assert_eq!(rt(f), f); } - assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _))); + assert!(matches!(parse("A1#")?, Expr::Unary(UnOp::Spill, _))); // Files spell it ANCHORARRAY. assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#"); assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)"); @@ -99,35 +102,35 @@ fn spill_operator() { assert!(parse("A1:B2#").is_err()); assert!(parse("Name#").is_err()); // Deleting the anchor's sheet leaves #REF!. - assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data", &[])), "SUM(#REF!)"); + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)")?, "Data", &[])), "SUM(#REF!)"); + Ok(()) } #[test] -fn delete_sheet_shrinks_3d_span() { +fn delete_sheet_shrinks_3d_span() -> Result<(), Box> { let order: Vec = ["Sheet1", "Sheet2", "Sheet3", "Results"].iter().map(|s| s.to_string()).collect(); - let del = |f: &str, n: &str| print(&crate::adjust::delete_sheet(parse(f).unwrap(), n, &order)); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1"), "SUM(Sheet2:Sheet3!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3"), "SUM(Sheet1:Sheet2!A1)"); - assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1"), "SUM(Sheet3:Sheet2!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2"), "SUM(Sheet1:Sheet3!A1)"); - assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1"), "SUM(Sheet2!A1)"); - assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1"), "#REF!+Sheet3!A1"); + let del = |f: &str, n: &str| parse(f).map(|e| print(&crate::adjust::delete_sheet(e, n, &order))); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1")?, "SUM(Sheet2:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3")?, "SUM(Sheet1:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1")?, "SUM(Sheet3:Sheet2!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2")?, "SUM(Sheet1:Sheet3!A1)"); + assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1")?, "SUM(Sheet2!A1)"); + assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1")?, "#REF!+Sheet3!A1"); + Ok(()) } #[test] -fn precedence() { - let e = parse("1+2*3").unwrap(); +fn precedence() -> Result<(), Box> { + let e = parse("1+2*3")?; assert!(matches!(e, Expr::Binary(BinOp::Add, _, _))); - let e = parse("-2^2").unwrap(); + let e = parse("-2^2")?; assert!(matches!(e, Expr::Binary(BinOp::Pow, _, _))); - let e = parse("1&2=\"12\"").unwrap(); + let e = parse("1&2=\"12\"")?; assert!(matches!(e, Expr::Binary(BinOp::Eq, _, _))); - let e = parse("2^3^2").unwrap(); // left associative: (2^3)^2 - if let Expr::Binary(BinOp::Pow, l, _) = e { - assert!(matches!(*l, Expr::Binary(BinOp::Pow, _, _))); - } else { - panic!() - } + let e = parse("2^3^2")?; // left associative: (2^3)^2 + let Expr::Binary(BinOp::Pow, l, _) = e else { return Err("not a power".into()) }; + assert!(matches!(*l, Expr::Binary(BinOp::Pow, _, _))); + Ok(()) } #[test] @@ -149,122 +152,130 @@ fn no_panic_on_garbage() { } #[test] -fn references() { - let e = parse("SUM(A1:B2)+Sheet2!C3*D4").unwrap(); +fn references() -> Result<(), Box> { + let e = parse("SUM(A1:B2)+Sheet2!C3*D4")?; let refs: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); assert_eq!(refs, ["A1:B2", "Sheet2!C3", "D4"]); + Ok(()) } #[test] -fn copy_shift() { - let e = parse("A1+$A1+A$1+$A$1+SUM(B2:C3)").unwrap(); +fn copy_shift() -> Result<(), Box> { + let e = parse("A1+$A1+A$1+$A$1+SUM(B2:C3)")?; assert_eq!(print(&shift_relative(e, 2, 1)), "B3+$A3+B$1+$A$1+SUM(C4:D5)"); - let e = parse("A1").unwrap(); + let e = parse("A1")?; assert_eq!(print(&shift_relative(e, -1, 0)), "#REF!"); - let e = parse("SUM(A:A)").unwrap(); + let e = parse("SUM(A:A)")?; assert_eq!(print(&shift_relative(e, 5, 1)), "SUM(B:B)"); + Ok(()) } #[test] -fn insert_delete() { +fn insert_delete() -> Result<(), Box> { let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; - let e = parse("A1+A3+SUM(A2:A5)+Other!A3").unwrap(); + let e = parse("A1+A3+SUM(A2:A5)+Other!A3")?; assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &ins)), "A1+A5+SUM(A2:A7)+Other!A3"); let del = Edit::Delete { axis: Axis::Rows, at: 2, count: 2 }; // rows 3-4 - let e = parse("A1+A3+SUM(A2:A5)+A6").unwrap(); + let e = parse("A1+A3+SUM(A2:A5)+A6")?; assert_eq!(print(&adjust(e, "Sheet1", "Sheet1", &del)), "A1+#REF!+SUM(A2:A3)+A4"); let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; - let e = parse("SUM(A1:C1)+B2").unwrap(); + let e = parse("SUM(A1:C1)+B2")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1:B1)+A2"); - let e = parse("Sheet1!B2").unwrap(); + let e = parse("Sheet1!B2")?; assert_eq!(print(&adjust(e, "Other", "Sheet1", &del)), "Sheet1!A2"); - let e = parse("SUM(A:A)").unwrap(); + let e = parse("SUM(A:A)")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); + Ok(()) } #[test] -fn insert_delete_cells() { - let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap(); - let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e)); +fn insert_delete_cells() -> Result<(), Box> { + let range = |a: &str| gridcraft_core::RangeRef::parse(a); + let adj = |f: &str, e: &Edit| parse(f).map(|x| print(&adjust(x, "S", "S", e))); // Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't. - let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") }; - assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); + let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4").ok_or("none")? }; + assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins)?, "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); // Insert B2 shifting right. - let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") }; - assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)"); + let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2").ok_or("none")? }; + assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins)?, "A2+C2+D2+B3+SUM(A2:D2)"); // Delete A3:A4 shifting up: references into it become #REF!, those below move up. - let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") }; - assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); + let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4").ok_or("none")? }; + assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del)?, "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); // Delete B1:C2 shifting left. - let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") }; - assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); + let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2").ok_or("none")? }; + assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del)?, "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); + Ok(()) } #[test] -fn moves_and_renames() { - let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; - let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); +fn moves_and_renames() -> Result<(), Box> { + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_row: 9, to_col: 3 }; + let e = parse("A1+B2+C3+SUM(A1:B2)")?; assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); - let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 }; - assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); - let e = parse("Old!A1+'Old'!B2").unwrap(); + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").ok_or("none")?, to_row: 0, to_col: 4 }; + assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1")?, "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); + let e = parse("Old!A1+'Old'!B2")?; assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); + Ok(()) } #[test] -fn rename_sheet_updates_qualified_names() { - let e = parse("Data!MyName+MyName+Other!MyName").unwrap(); +fn rename_sheet_updates_qualified_names() -> Result<(), Box> { + let e = parse("Data!MyName+MyName+Other!MyName")?; assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName"); - let e = parse("Data!MyName").unwrap(); + let e = parse("Data!MyName")?; assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName"); // Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches. - let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's"); + let e = rename_sheet(parse("Data!MyName")?, "Data", "It's"); assert_eq!(e, Expr::Name("It's!MyName".into())); assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName"); + Ok(()) } #[test] -fn moves_between_sheets() { - let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_sheet: "New Name".into(), to_row: 9, to_col: 3 }; +fn moves_between_sheets() -> Result<(), Box> { + let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_sheet: "New Name".into(), to_row: 9, to_col: 3 }; for (host, input, expected) in [ ("Source", "A1+$B$2+C3+SUM(A1:B2)", "'New Name'!D10+'New Name'!$E$11+C3+SUM('New Name'!D10:E11)"), ("Other", "A1+sOuRcE!$B2+Other!B2", "A1+'New Name'!$E11+Other!B2"), ("Source", "SUM(A1:C3)+SUM(Source:Other!A1)", "SUM(A1:C3)+SUM(Source:Other!A1)"), ] { - assert_eq!(print(&adjust(parse(input).unwrap(), host, "Source", &mv)), expected); + assert_eq!(print(&adjust(parse(input)?, host, "Source", &mv)), expected); } let edge = Edit::MoveToSheet { - from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), + from: gridcraft_core::RangeRef::parse("A1:B2").ok_or("none")?, to_sheet: "New Name".into(), to_row: gridcraft_core::MAX_ROWS - 1, to_col: 3, }; - assert_eq!(print(&adjust(parse("B2").unwrap(), "Source", "Source", &edge)), "#REF!"); + assert_eq!(print(&adjust(parse("B2")?, "Source", "Source", &edge)), "#REF!"); for (from, input, row, col, expected) in [("A:A", "$A:$A", 0, 2, "Dest!$C:$C"), ("1:1", "$1:$1", 2, 0, "Dest!$3:$3")] { - let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).unwrap(), to_sheet: "Dest".into(), to_row: row, to_col: col }; - assert_eq!(print(&adjust(parse(input).unwrap(), "Source", "Source", &mv)), expected); + let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).ok_or("none")?, to_sheet: "Dest".into(), to_row: row, to_col: col }; + assert_eq!(print(&adjust(parse(input)?, "Source", "Source", &mv)), expected); } + Ok(()) } #[test] -fn r1c1() { - let e = parse("A1+$B$2+SUM(C:C)").unwrap(); +fn r1c1() -> Result<(), Box> { + let e = parse("A1+$B$2+SUM(C:C)")?; assert_eq!(print_r1c1(&e, gridcraft_core::CellRef::new(1, 1)), "R[-1]C[-1]+R2C2+SUM(C[1])"); + Ok(()) } #[test] -fn spans() { - let toks = lexer::tokenize("SUM(A1, B2)").unwrap(); - let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap(); +fn spans() -> Result<(), Box> { + let toks = lexer::tokenize("SUM(A1, B2)")?; + let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).ok_or("none")?; assert_eq!((a1.start, a1.end), (4, 6)); + Ok(()) } #[test] -fn unbracketed_this_row_column() { +fn unbracketed_this_row_column() -> Result<(), Box> { // Writers such as xlsxwriter spell `[@Units]` as `[[#This Row],Units]`. - let p = |f: &str| parse(f).unwrap_or_else(|e| panic!("{f}: {e}")); - let Expr::Binary(BinOp::Mul, a, _) = p("[[#This Row],Units]*[[#This Row],Price]") else { panic!() }; - let Expr::Struct(s) = *a else { panic!() }; + let Expr::Binary(BinOp::Mul, a, _) = parse("[[#This Row],Units]*[[#This Row],Price]")? else { return Err("not a product".into()) }; + let Expr::Struct(s) = *a else { return Err("not a struct".into()) }; assert_eq!((s.specifiers.as_slice(), s.col_start.as_deref()), ([StructItem::ThisRow].as_slice(), Some("Units"))); assert_eq!(rt("[[#This Row],Units]*[[#This Row],Price]"), "[@Units]*[@Price]"); assert_eq!(rt("Sales[[#This Row],Units]"), "Sales[@Units]"); @@ -284,24 +295,26 @@ fn unbracketed_this_row_column() { ] { assert!(parse(bad).is_err(), "{bad} should fail"); } + Ok(()) } #[test] -fn struct_column_keeps_edge_spaces() { +fn struct_column_keeps_edge_spaces() -> Result<(), Box> { // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. - let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() }; + let Expr::Struct(s) = parse("Table1[Qty ]")? else { return Err("unexpected expression".into()) }; assert_eq!(s.col_start.as_deref(), Some("Qty ")); - let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() }; + let Expr::Struct(s) = parse("Table1[ Qty]")? else { return Err("unexpected expression".into()) }; assert_eq!(s.col_start.as_deref(), Some(" Qty")); // Excel writes such a column in the double-bracket form. assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]"); for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] { assert_eq!(rt(f), f); } + Ok(()) } #[test] -fn trim_references() { +fn trim_references() -> Result<(), Box> { for f in [ "SUM(A1:.E10)", "SUM(A1.:E10)", @@ -319,15 +332,15 @@ fn trim_references() { ] { assert_eq!(rt(f), f, "round trip of {f}"); } - let Expr::Call(_, args) = parse("SUM(A1:.E10)").unwrap() else { panic!() }; + let Expr::Call(_, args) = parse("SUM(A1:.E10)")? else { return Err("unexpected expression".into()) }; assert!(matches!(args.as_slice(), [Expr::Unary(UnOp::Trim(TrimMode::Trailing), r)] if matches!(**r, Expr::Ref(_)))); // Decimals, row ranges and dotted names keep their meaning. - assert_eq!(parse("1.5+.5").unwrap(), Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5)))); + assert_eq!(parse("1.5+.5")?, Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5)))); for f in ["SUM(1:5)", "Q1.2026!A1:B2", "my.name+1", "SUM(A1:B2)*1.5", "1.:5"] { assert_eq!(rt(f), f); } assert_eq!(rt("'Q1.2026'!A1:B2"), "Q1.2026!A1:B2"); - assert!(matches!(parse("Q1.2026!A1:B2").unwrap(), Expr::Ref(_))); + assert!(matches!(parse("Q1.2026!A1:B2")?, Expr::Ref(_))); // File call forms. assert_eq!(rt("SUM(_xlfn._TRO_TRAILING(A1:E10))"), "SUM(A1:.E10)"); assert_eq!(rt("_xlfn._TRO_LEADING(A:A)"), "A.:A"); @@ -338,77 +351,76 @@ fn trim_references() { assert!(parse(bad).is_err(), "{bad} should fail"); } // R1C1 keeps the operator. - assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)").unwrap(), gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])"); + assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)")?, gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])"); + Ok(()) } #[test] -fn trim_references_adjust() { +fn trim_references_adjust() -> Result<(), Box> { let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; - let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)").unwrap(); + let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)")?; assert_eq!(print(&adjust(e, "S", "S", &ins)), "SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)"); let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; - let e = parse("SUM(A1.:C1)+SUM(A:.A)").unwrap(); + let e = parse("SUM(A1.:C1)+SUM(A:.A)")?; assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1.:B1)+SUM(#REF!)"); - assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)").unwrap(), 1, 1)), "SUM(B2.:.C3)"); - let refs: Vec = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)")?, 1, 1)), "SUM(B2.:.C3)"); + let refs: Vec = parse("SUM(Sheet2!A1:.B5)")?.references().iter().map(|r| printer::reference_a1(r)).collect(); assert_eq!(refs, ["Sheet2!A1:B5"]); + Ok(()) } #[test] -fn prints_long_flat_chain_without_deep_recursion() { +fn prints_long_flat_chain_without_deep_recursion() -> Result<(), Box> { // Small stack: a recursive printer overflows long before 20000 levels. - let handle = std::thread::Builder::new() - .stack_size(256 * 1024) - .spawn(|| { - let mut e = Expr::Number(1.0); - for _ in 0..20000 { - e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0))); - } - let text = print(&e); - let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 }); - // Dropping the deep tree recurses; leak it so only the printer is under test. - std::mem::forget(e); - (text, r1c1) - }) - .expect("spawn"); - let (text, r1c1) = handle.join().expect("printer must not overflow the stack"); + let handle = std::thread::Builder::new().stack_size(256 * 1024).spawn(|| { + let mut e = Expr::Number(1.0); + for _ in 0..20000 { + e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0))); + } + let text = print(&e); + let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 }); + // Dropping the deep tree recurses; leak it so only the printer is under test. + std::mem::forget(e); + (text, r1c1) + })?; + let (text, r1c1) = handle.join().map_err(|_| "thread panicked")?; assert_eq!(text, vec!["1"; 20001].join("+")); assert_eq!(r1c1, text); + Ok(()) } #[test] -fn hostile_formulas_are_errors_not_stack_overflows() { +fn hostile_formulas_are_errors_not_stack_overflows() -> Result<(), Box> { // A small stack: parsing must fail cleanly instead of building trees deeper than the nesting // limit, which dropping, printing and evaluating walk recursively. - let handle = std::thread::Builder::new() - .stack_size(512 * 1024) - .spawn(|| { - let percents = format!("1{}", "%".repeat(1_000)); - let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000)); - let long_chain = format!("1{}", "+1".repeat(100_000)); - let huge_percents = format!("1{}", "%".repeat(1_000_000)); - (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err()) - }) - .expect("spawn"); - assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true)); + let handle = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { + let percents = format!("1{}", "%".repeat(1_000)); + let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000)); + let long_chain = format!("1{}", "+1".repeat(100_000)); + let huge_percents = format!("1{}", "%".repeat(1_000_000)); + (parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err()) + })?; + assert_eq!(handle.join().map_err(|_| "thread panicked")?, (true, true, true, true)); // Within the limits these still parse. assert!(parse(&format!("1{}", "%".repeat(100))).is_ok()); assert!(parse("LAMBDA(x,x)(1)(2)").is_ok()); + Ok(()) } #[test] -fn formulas_longer_than_excel_allows_are_refused() { +fn formulas_longer_than_excel_allows_are_refused() -> Result<(), Box> { let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2)); assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1); assert!(parse(&at_limit).is_ok()); assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count"); let over = format!("{at_limit}+1"); - let err = parse(&over).unwrap_err(); + let err = parse(&over).err().ok_or("none")?; assert!(err.msg.contains("8192"), "{}", err.msg); // Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit. let text = format!("\"{}\"", "é".repeat(8_000)); assert!(text.len() > crate::MAX_FORMULA_LEN); assert!(parse(&text).is_ok()); + Ok(()) } #[test] @@ -424,41 +436,40 @@ fn sheet_names_excel_quotes_stay_quoted() { } #[test] -fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() { +fn longest_flat_chains_clone_compare_walk_and_adjust_without_deep_recursion() -> Result<(), Box> { // `=1+1+…` with 4,096 operands is 8,192 characters, Excel's formula limit, and nests 4,095 // levels on its left. On a 1 MiB stack (the Windows main thread) the derived, recursive // Clone overflowed when the engine stored such a formula (#916). - let handle = std::thread::Builder::new() - .stack_size(1 << 20) - .spawn(|| { - let plus = vec!["1"; 4096].join("+"); - let e = parse(&plus).expect("parse"); - let copy = e.clone(); - assert!(copy == e); - let other = parse(&format!("{}-1", vec!["1"; 4095].join("+"))).expect("parse"); - assert!(other != e); - let mut nodes = 0; - e.walk(&mut |_| nodes += 1); - assert_eq!(nodes, 2 * 4096 - 1); - assert_eq!(print(©.map(&mut |x| x)), plus); - // A chain of references: walk finds them in order, adjust rewrites every one. - let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); - let e = parse(&refs).expect("parse"); - let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); - assert_eq!(found.first().map(String::as_str), Some("A1")); - assert_eq!(found.last().map(String::as_str), Some("A1000")); - assert_eq!(found.len(), 1000); - let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; - let moved = print(&adjust(e.clone(), "S", "S", &ins)); - assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); - assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); - }) - .expect("spawn"); - handle.join().expect("long chains must not overflow the stack"); + let handle = std::thread::Builder::new().stack_size(1 << 20).spawn(|| { + let plus = vec!["1"; 4096].join("+"); + let e = parse(&plus)?; + let copy = e.clone(); + assert!(copy == e); + let other = parse(&format!("{}-1", vec!["1"; 4095].join("+")))?; + assert!(other != e); + let mut nodes = 0; + e.walk(&mut |_| nodes += 1); + assert_eq!(nodes, 2 * 4096 - 1); + assert_eq!(print(©.map(&mut |x| x)), plus); + // A chain of references: walk finds them in order, adjust rewrites every one. + let refs = (1..=1000).map(|r| format!("A{r}")).collect::>().join("+"); + let e = parse(&refs)?; + let found: Vec = e.references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(found.first().map(String::as_str), Some("A1")); + assert_eq!(found.last().map(String::as_str), Some("A1000")); + assert_eq!(found.len(), 1000); + let ins = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 }; + let moved = print(&adjust(e.clone(), "S", "S", &ins)); + assert_eq!(moved, (2..=1001).map(|r| format!("A{r}")).collect::>().join("+")); + assert_eq!(print(&shift_relative(e, 0, 1)), (1..=1000).map(|r| format!("B{r}")).collect::>().join("+")); + Ok::<(), crate::ParseError>(()) + })?; + handle.join().map_err(|_| "thread panicked")??; + Ok(()) } #[test] -fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { +fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() -> Result<(), Box> { // The leftmost operand of a flat chain is not nested, so `(((…1)))+1` is accepted whenever // `(((…1)))` is, in both the pre-check and `normalize`. let wrap = |n: usize| { @@ -469,17 +480,18 @@ fn spanish_input_counts_left_operand_of_a_chain_at_the_same_depth() { e }; let es = |e: &Expr| crate::print_input(e, FormulaLocale::Es, |_| false); - let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().expect("depth 0 is accepted"); + let deepest = (0..400).take_while(|&n| es(&wrap(n)).is_ok()).last().ok_or("none")?; assert!(deepest > 1 && deepest < 400); let chain = Expr::Binary(BinOp::Add, Box::new(wrap(deepest)), Box::new(Expr::Number(1.0))); assert!(es(&chain).is_ok()); + Ok(()) } #[test] -fn walk_is_preorder_and_map_is_postorder_left_to_right() { +fn walk_is_preorder_and_map_is_postorder_left_to_right() -> Result<(), Box> { // `(A1+B1)+SUM(C1,D1)-E1`: a chain with a call on its right. walk visits parents before // children, map rewrites children (left to right) before their parent. - let e = parse("(A1+B1)+SUM(C1,D1)-E1").unwrap(); + let e = parse("(A1+B1)+SUM(C1,D1)-E1")?; let mut walked = Vec::new(); e.walk(&mut |n| walked.push(print(n))); assert_eq!(walked, ["(A1+B1)+SUM(C1,D1)-E1", "(A1+B1)+SUM(C1,D1)", "(A1+B1)", "A1+B1", "A1", "B1", "SUM(C1,D1)", "C1", "D1", "E1"]); @@ -490,19 +502,21 @@ fn walk_is_preorder_and_map_is_postorder_left_to_right() { }); assert_eq!(mapped, ["A1", "B1", "A1+B1", "(A1+B1)", "C1", "D1", "SUM(C1,D1)", "(A1+B1)+SUM(C1,D1)", "E1", "(A1+B1)+SUM(C1,D1)-E1"]); assert_eq!(print(&out), "(A1+B1)+SUM(C1,D1)-E1"); + Ok(()) } #[test] -fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() { +fn missing_closing_parentheses_are_closed_and_errors_describe_the_typed_text() -> Result<(), Box> { // #2030: `1+` reported "unexpected `)`", the error of the last retry with `)` appended. let entry = |src: &str| parse_closing_parens(src, parse); assert_eq!(entry("SUM(1,2").map(|e| print(&e)), Ok("SUM(1,2)".to_string())); assert_eq!(entry("((1+2").map(|e| print(&e)), Ok("((1+2))".to_string())); assert_eq!(entry("1+2").map(|e| print(&e)), Ok("1+2".to_string())); for src in ["1+", "--", "SUM(1,+", "1+*2", &format!("{}1", "(".repeat(9))] { - assert_eq!(entry(src).unwrap_err(), parse(src).unwrap_err(), "{src}"); + assert_eq!(entry(src).err().ok_or("none")?, parse(src).err().ok_or("none")?, "{src}"); } - assert_eq!(entry("1+").unwrap_err().msg, "unexpected end of formula"); + assert_eq!(entry("1+").err().ok_or("none")?.msg, "unexpected end of formula"); + Ok(()) } #[test] @@ -518,3 +532,38 @@ fn three_d_span_and_cell_start_still_lex_as_span() { assert_eq!(rt("SUM(Sheet1:Sheet3!A1)"), "SUM(Sheet1:Sheet3!A1)"); assert_eq!(rt("SUM(Q1:Sheet3!A1)"), "SUM('Q1:Sheet3'!A1)"); } + +/// References print the same through the appending printer for every reference shape. +#[test] +fn reference_shapes_print_unchanged() -> Result<(), Box> { + for text in [ + "A1", + "XFD1048576", + "$AA$10:AB$11", + "$A:XFD", + "B:$C", + "1:$1048576", + "$3:4", + "'My Sheet'!A1", + "'It''s'!$B$2:C3", + "Sheet1:Sheet3!B2", + "'Sheet 1:Sheet3'!B2", + "SUM(A1:.B3)+SUM(A.:.B)", + ] { + let e = parse(text)?; + assert_eq!(print(&e), text); + if let Expr::Ref(r) = &e { + assert_eq!(printer::reference_a1(r), text); + } + } + Ok(()) +} + +#[test] +fn column_starting_with_at_round_trips() { + for name in ["@a", "a@b"] { + let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None }; + let text = format!("={}", printer::struct_ref(&s)); + assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}"); + } +} diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 2d8218f3e..fd5342352 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -389,7 +389,7 @@ mod tests { } #[test] - fn criteria() { + fn text_criteria() { assert!(m(">5", Value::Number(6.0))); assert!(!m(">5", Value::Number(5.0))); assert!(m(">=5", Value::Number(5.0))); @@ -402,6 +402,10 @@ mod tests { assert!(m("?pple", Value::from("apple"))); assert!(m("~*", Value::from("*"))); assert!(!m("~*", Value::from("x"))); + } + + #[test] + fn blank_criteria() { assert!(m("", Value::Empty)); assert!(m("", Value::from(""))); assert!(!m("", Value::Number(0.0))); @@ -409,10 +413,14 @@ mod tests { assert!(!m("=", Value::from("a"))); assert!(m("<>", Value::from("a"))); assert!(!m("<>", Value::Empty)); - assert!(m("TRUE", Value::Bool(true))); - // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). assert!(!m("<=", Value::from(""))); assert!(!m(">=", Value::from(""))); + // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). + } + + #[test] + fn typed_criteria() { + assert!(m("TRUE", Value::Bool(true))); assert!(m(" Option { } } +/// A criteria row: its index and its conditions (criteria column, database column or `None` +/// when the label is unknown, criterion). +type CriteriaRow = (usize, Vec<(usize, Option, Criterion)>); + /// Rows (1-based within the database, excluding the header) of matching records. fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { // Per criteria row: its index and a list of (criteria column, database column or None when // the label is unknown, criterion). - let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); + let mut rows: Vec = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { 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/engineering.rs b/crates/functions/src/engineering.rs index 86ab7d7c7..a163d8fda 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R { Ok(number_to_text(n)) } Value::Empty => Ok(String::new()), - v => Ok(v.to_text()?.trim().to_string()), + v => Ok(v.to_text()?.to_string()), } } @@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option) -> R { if v < min || v > max { return Err(CellError::Num); } + let places = places + .map(|p| { + let p = p.trunc(); + if !(1.0..=10.0).contains(&p) { + return Err(CellError::Num); + } + Ok(p as usize) + }) + .transpose()?; if v < 0 { let u = v + (1i64 << bits); return text_val(to_radix(u, radix)); @@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option) -> R { match places { None => text_val(s), Some(p) => { - let p = p.trunc(); - if !(1.0..=10.0).contains(&p) { - return Err(CellError::Num); - } - let p = p as usize; if s.len() > p { return Err(CellError::Num); } @@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String { } fn places_arg(a: &[Arg]) -> R> { - if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) } + if !has(a, 1) { + return Ok(None); + } + if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) { + return Err(CellError::Value); + } + Ok(Some(num(a, 1)?)) } fn convert_base(a: &[Arg], from: u32, to: u32) -> R { @@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R { fn shift(a: &[Arg], left: bool) -> R { let n = bit_operand(a, 0)?; let s = num(a, 1)?.trunc(); + if n == 0 { + return Ok(Value::Number(0.0)); + } if s.abs() > 53.0 { return Err(CellError::Num); } @@ -197,283 +210,6 @@ fn bitrshift(a: &[Arg], _c: &mut dyn Ctx) -> R { shift(a, false) } -// --------------------------------------------------------------------------------------------- -// CONVERT - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Dim { - Mass, - Length, - Time, - Pressure, - Force, - Energy, - Power, - Magnetism, - Temperature, - Volume, - Area, - Information, - Speed, -} - -/// How a unit accepts prefixes: none, SI (raised to `power` for areas/volumes) or SI + binary. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Pre { - No, - Si(i32), - Bin, -} - -struct Unit { - names: &'static [&'static str], - dim: Dim, - factor: f64, - pre: Pre, -} - -const fn u(names: &'static [&'static str], dim: Dim, factor: f64, pre: Pre) -> Unit { - Unit { names, dim, factor, pre } -} - -const IN: f64 = 0.0254; -const FT: f64 = 0.3048; -const YD: f64 = 0.9144; -const MI: f64 = 1609.344; -const NMI: f64 = 1852.0; -const LY: f64 = 9_460_730_472_580_800.0; -const PICA_PT: f64 = 0.0254 / 72.0; -const PICA: f64 = 0.0254 / 6.0; -const GAL: f64 = 0.003_785_411_784; - -const UNITS: &[Unit] = &[ - // Mass (gram) - u(&["g"], Dim::Mass, 1.0, Pre::Si(1)), - u(&["sg"], Dim::Mass, 14_593.902_937_206_4, Pre::No), - u(&["lbm"], Dim::Mass, 453.592_37, Pre::No), - u(&["u"], Dim::Mass, 1.660_539_066_6e-24, Pre::Si(1)), - u(&["ozm"], Dim::Mass, 28.349_523_125, Pre::No), - u(&["grain"], Dim::Mass, 0.064_798_91, Pre::No), - u(&["cwt", "shweight"], Dim::Mass, 45_359.237, Pre::No), - u(&["uk_cwt", "lcwt", "hweight"], Dim::Mass, 50_802.345_44, Pre::No), - u(&["stone"], Dim::Mass, 6_350.293_18, Pre::No), - u(&["ton"], Dim::Mass, 907_184.74, Pre::No), - u(&["uk_ton", "LTON", "brton"], Dim::Mass, 1_016_046.908_8, Pre::No), - // Length (metre) - u(&["m"], Dim::Length, 1.0, Pre::Si(1)), - u(&["mi"], Dim::Length, MI, Pre::No), - u(&["Nmi"], Dim::Length, NMI, Pre::No), - u(&["in"], Dim::Length, IN, Pre::No), - u(&["ft"], Dim::Length, FT, Pre::No), - u(&["yd"], Dim::Length, YD, Pre::No), - u(&["ang"], Dim::Length, 1e-10, Pre::Si(1)), - u(&["ell"], Dim::Length, 1.143, Pre::No), - u(&["ly"], Dim::Length, LY, Pre::Si(1)), - u(&["parsec", "pc"], Dim::Length, 30_856_775_814_913_673.0, Pre::Si(1)), - u(&["Picapt", "Pica"], Dim::Length, PICA_PT, Pre::No), - u(&["pica"], Dim::Length, PICA, Pre::No), - u(&["survey_mi"], Dim::Length, 1_609.347_218_694_437, Pre::No), - // Time (second) - u(&["yr"], Dim::Time, 31_557_600.0, Pre::No), - u(&["day", "d"], Dim::Time, 86_400.0, Pre::No), - u(&["hr"], Dim::Time, 3_600.0, Pre::No), - u(&["mn", "min"], Dim::Time, 60.0, Pre::No), - u(&["sec", "s"], Dim::Time, 1.0, Pre::Si(1)), - // Pressure (pascal) - u(&["Pa", "p"], Dim::Pressure, 1.0, Pre::Si(1)), - u(&["atm", "at"], Dim::Pressure, 101_325.0, Pre::Si(1)), - u(&["mmHg"], Dim::Pressure, 133.322_387_415, Pre::Si(1)), - u(&["psi"], Dim::Pressure, 6_894.757_293_168_36, Pre::No), - u(&["Torr"], Dim::Pressure, 101_325.0 / 760.0, Pre::No), - // Force (newton) - u(&["N"], Dim::Force, 1.0, Pre::Si(1)), - u(&["dyn", "dy"], Dim::Force, 1e-5, Pre::Si(1)), - u(&["lbf"], Dim::Force, 4.448_221_615_260_5, Pre::No), - u(&["pond"], Dim::Force, 0.009_806_65, Pre::Si(1)), - // Energy (joule) - u(&["J"], Dim::Energy, 1.0, Pre::Si(1)), - u(&["e"], Dim::Energy, 1e-7, Pre::Si(1)), - u(&["c"], Dim::Energy, 4.184, Pre::Si(1)), - u(&["cal"], Dim::Energy, 4.1868, Pre::Si(1)), - u(&["eV", "ev"], Dim::Energy, 1.602_176_634e-19, Pre::Si(1)), - u(&["HPh", "hh"], Dim::Energy, 2_684_519.537_696_17, Pre::No), - u(&["Wh", "wh"], Dim::Energy, 3_600.0, Pre::Si(1)), - u(&["flb"], Dim::Energy, 1.355_817_948_331_4, Pre::No), - u(&["BTU", "btu"], Dim::Energy, 1_055.055_852_62, Pre::No), - // Power (watt) - u(&["W", "w"], Dim::Power, 1.0, Pre::Si(1)), - u(&["HP", "h"], Dim::Power, 745.699_871_582_270_2, Pre::No), - u(&["PS"], Dim::Power, 735.498_75, Pre::No), - // Magnetism (tesla) - u(&["T"], Dim::Magnetism, 1.0, Pre::Si(1)), - u(&["ga"], Dim::Magnetism, 1e-4, Pre::Si(1)), - // Temperature (handled specially; factor unused) - u(&["C", "cel"], Dim::Temperature, 1.0, Pre::No), - u(&["F", "fah"], Dim::Temperature, 1.0, Pre::No), - u(&["K", "kel"], Dim::Temperature, 1.0, Pre::Si(1)), - u(&["Rank"], Dim::Temperature, 1.0, Pre::No), - u(&["Reau"], Dim::Temperature, 1.0, Pre::No), - // Volume (cubic metre) - u(&["l", "L", "lt"], Dim::Volume, 0.001, Pre::Si(1)), - u(&["tsp"], Dim::Volume, GAL / 768.0, Pre::No), - u(&["tspm"], Dim::Volume, 5e-6, Pre::No), - u(&["tbs"], Dim::Volume, GAL / 256.0, Pre::No), - u(&["oz"], Dim::Volume, GAL / 128.0, Pre::No), - u(&["cup"], Dim::Volume, GAL / 16.0, Pre::No), - u(&["pt", "us_pt"], Dim::Volume, GAL / 8.0, Pre::No), - u(&["uk_pt"], Dim::Volume, 0.000_568_261_25, Pre::No), - u(&["qt"], Dim::Volume, GAL / 4.0, Pre::No), - u(&["uk_qt"], Dim::Volume, 0.001_136_522_5, Pre::No), - u(&["gal"], Dim::Volume, GAL, Pre::No), - u(&["uk_gal"], Dim::Volume, 0.004_546_09, Pre::No), - u(&["m3"], Dim::Volume, 1.0, Pre::Si(3)), - u(&["mi3"], Dim::Volume, MI * MI * MI, Pre::No), - u(&["Nmi3"], Dim::Volume, NMI * NMI * NMI, Pre::No), - u(&["yd3"], Dim::Volume, YD * YD * YD, Pre::No), - u(&["ft3"], Dim::Volume, FT * FT * FT, Pre::No), - u(&["in3"], Dim::Volume, IN * IN * IN, Pre::No), - u(&["ang3"], Dim::Volume, 1e-30, Pre::Si(3)), - u(&["ly3"], Dim::Volume, LY * LY * LY, Pre::No), - u(&["Picapt3", "Pica3"], Dim::Volume, PICA_PT * PICA_PT * PICA_PT, Pre::No), - u(&["barrel"], Dim::Volume, 0.158_987_294_928, Pre::No), - u(&["bushel"], Dim::Volume, 0.035_239_070_166_88, Pre::No), - u(&["MTON"], Dim::Volume, 1.132_673_863_68, Pre::No), - u(&["GRT", "regton"], Dim::Volume, 2.831_684_659_2, Pre::No), - // Area (square metre) - u(&["m2"], Dim::Area, 1.0, Pre::Si(2)), - u(&["ha"], Dim::Area, 10_000.0, Pre::No), - u(&["ar"], Dim::Area, 100.0, Pre::Si(1)), - u(&["ft2"], Dim::Area, FT * FT, Pre::No), - u(&["in2"], Dim::Area, IN * IN, Pre::No), - u(&["yd2"], Dim::Area, YD * YD, Pre::No), - u(&["mi2"], Dim::Area, MI * MI, Pre::No), - u(&["Nmi2"], Dim::Area, NMI * NMI, Pre::No), - u(&["ang2"], Dim::Area, 1e-20, Pre::Si(2)), - u(&["ly2"], Dim::Area, LY * LY, Pre::No), - u(&["Picapt2", "Pica2"], Dim::Area, PICA_PT * PICA_PT, Pre::No), - u(&["uk_acre"], Dim::Area, 4_046.856_422_4, Pre::No), - u(&["us_acre"], Dim::Area, 4_046.872_609_874_252, Pre::No), - u(&["Morgen"], Dim::Area, 2_500.0, Pre::No), - // Information (bit) - u(&["bit"], Dim::Information, 1.0, Pre::Bin), - u(&["byte"], Dim::Information, 8.0, Pre::Bin), - // Speed (metres per second) - u(&["m/s", "m/sec"], Dim::Speed, 1.0, Pre::Si(1)), - u(&["m/h", "m/hr"], Dim::Speed, 1.0 / 3600.0, Pre::Si(1)), - u(&["mph"], Dim::Speed, MI / 3600.0, Pre::No), - u(&["kn"], Dim::Speed, NMI / 3600.0, Pre::No), - u(&["admkn"], Dim::Speed, 6080.0 * FT / 3600.0, Pre::No), -]; - -const SI_PREFIXES: &[(&str, f64)] = &[ - ("da", 1e1), - ("Y", 1e24), - ("Z", 1e21), - ("E", 1e18), - ("P", 1e15), - ("T", 1e12), - ("G", 1e9), - ("M", 1e6), - ("k", 1e3), - ("h", 1e2), - ("e", 1e1), - ("d", 1e-1), - ("c", 1e-2), - ("m", 1e-3), - ("u", 1e-6), - ("n", 1e-9), - ("p", 1e-12), - ("f", 1e-15), - ("a", 1e-18), - ("z", 1e-21), - ("y", 1e-24), -]; - -const BIN_PREFIXES: &[(&str, f64)] = &[ - ("ki", 1024.0), - ("Mi", 1_048_576.0), - ("Gi", 1_073_741_824.0), - ("Ti", 1_099_511_627_776.0), - ("Pi", 1_125_899_906_842_624.0), - ("Ei", 1_152_921_504_606_846_976.0), - ("Zi", 1_180_591_620_717_411_303_424.0), - ("Yi", 1_208_925_819_614_629_174_706_176.0), -]; - -fn find_unit(name: &str) -> Option<&'static Unit> { - UNITS.iter().find(|u| u.names.contains(&name)) -} - -/// Resolves a unit name to (unit, prefix multiplier). Exact names win over prefixed ones. -fn resolve(name: &str) -> Option<(&'static Unit, f64)> { - let name = name.replace("^2", "2").replace("^3", "3"); - if let Some(u) = find_unit(&name) { - return Some((u, 1.0)); - } - for (p, f) in BIN_PREFIXES { - if let Some(rest) = name.strip_prefix(p) - && let Some(u) = find_unit(rest) - && u.pre == Pre::Bin - { - return Some((u, *f)); - } - } - for (p, f) in SI_PREFIXES { - if let Some(rest) = name.strip_prefix(p) - && let Some(u) = find_unit(rest) - { - match u.pre { - Pre::Si(pow) => return Some((u, f.powi(pow))), - Pre::Bin => return Some((u, *f)), - Pre::No => {} - } - } - } - None -} - -fn to_kelvin(name: &str, x: f64) -> f64 { - match name { - "C" | "cel" => x + 273.15, - "F" | "fah" => (x - 32.0) * 5.0 / 9.0 + 273.15, - "Rank" => x * 5.0 / 9.0, - "Reau" => x * 1.25 + 273.15, - _ => x, - } -} - -fn from_kelvin(name: &str, k: f64) -> f64 { - match name { - "C" | "cel" => k - 273.15, - "F" | "fah" => (k - 273.15) * 9.0 / 5.0 + 32.0, - "Rank" => k * 9.0 / 5.0, - "Reau" => (k - 273.15) / 1.25, - _ => k, - } -} - -fn convert(a: &[Arg], _c: &mut dyn Ctx) -> R { - let x = num(a, 0)?; - let from = text(a, 1)?; - let to = text(a, 2)?; - let (fu, fp) = resolve(&from).ok_or(CellError::NA)?; - let (tu, tp) = resolve(&to).ok_or(CellError::NA)?; - if fu.dim != tu.dim { - return Err(CellError::NA); - } - if fu.dim == Dim::Temperature { - let fname = fu.names.first().copied().unwrap_or("K"); - let tname = tu.names.first().copied().unwrap_or("K"); - if fname == tname && fp == tp { - return num_val(x); - } - let k = to_kelvin(fname, x * fp); - return num_val(from_kelvin(tname, k) / tp); - } - let base = x * fu.factor * fp; - num_val(base / (tu.factor * tp)) -} - // --------------------------------------------------------------------------------------------- // DELTA, GESTEP, ERF @@ -502,201 +238,6 @@ fn erfc_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(erfc(num(a, 0)?)) } -// --------------------------------------------------------------------------------------------- -// Complex numbers - -#[derive(Clone, Copy, Debug, PartialEq)] -struct Cx { - re: f64, - im: f64, -} - -impl Cx { - fn new(re: f64, im: f64) -> Cx { - Cx { re, im } - } - fn add(self, o: Cx) -> Cx { - Cx::new(self.re + o.re, self.im + o.im) - } - fn sub(self, o: Cx) -> Cx { - Cx::new(self.re - o.re, self.im - o.im) - } - fn mul(self, o: Cx) -> Cx { - Cx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) - } - fn div(self, o: Cx) -> R { - let d = o.re * o.re + o.im * o.im; - if d == 0.0 { - return Err(CellError::Num); - } - Ok(Cx::new((self.re * o.re + self.im * o.im) / d, (self.im * o.re - self.re * o.im) / d)) - } - fn abs(self) -> f64 { - self.re.hypot(self.im) - } - fn arg(self) -> f64 { - self.im.atan2(self.re) - } - fn exp(self) -> Cx { - let e = self.re.exp(); - Cx::new(e * self.im.cos(), e * self.im.sin()) - } - fn ln(self) -> R { - if self.re == 0.0 && self.im == 0.0 { - return Err(CellError::Num); - } - Ok(Cx::new(self.abs().ln(), self.arg())) - } - fn sin(self) -> Cx { - Cx::new(self.re.sin() * self.im.cosh(), self.re.cos() * self.im.sinh()) - } - fn cos(self) -> Cx { - Cx::new(self.re.cos() * self.im.cosh(), -self.re.sin() * self.im.sinh()) - } - fn sinh(self) -> Cx { - Cx::new(self.re.sinh() * self.im.cos(), self.re.cosh() * self.im.sin()) - } - fn cosh(self) -> Cx { - Cx::new(self.re.cosh() * self.im.cos(), self.re.sinh() * self.im.sin()) - } - fn powf(self, n: f64) -> R { - if self.re == 0.0 && self.im == 0.0 { - return if n > 0.0 { Ok(Cx::new(0.0, 0.0)) } else { Err(CellError::Num) }; - } - let r = self.abs().powf(n); - let t = self.arg() * n; - Ok(Cx::new(r * t.cos(), r * t.sin())) - } -} - -const ONE: Cx = Cx { re: 1.0, im: 0.0 }; - -/// Parses a real number written in a complex string (no spaces, optional exponent). -fn parse_real(s: &str) -> Option { - if s.is_empty() || s.contains(char::is_whitespace) { - return None; - } - let ok = s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')); - if !ok { - return None; - } - s.parse::().ok().filter(|v| v.is_finite()) -} - -/// Parses "a+bi" forms. Returns the number and its suffix ('i', 'j' or none). -fn parse_complex(s: &str) -> R<(Cx, Option)> { - if s.is_empty() { - return Ok((Cx::new(0.0, 0.0), None)); - } - let suffix = match s.chars().last() { - Some(c @ ('i' | 'j')) => Some(c), - _ => None, - }; - let Some(sfx) = suffix else { - return parse_real(s).map(|r| (Cx::new(r, 0.0), None)).ok_or(CellError::Num); - }; - let body = &s[..s.len() - 1]; - // Split at the last sign that is not at the start and not part of an exponent. - let bytes = body.as_bytes(); - let mut split = None; - for i in (1..bytes.len()).rev() { - let c = bytes.get(i).copied().unwrap_or(0); - let prev = bytes.get(i - 1).copied().unwrap_or(0); - if (c == b'+' || c == b'-') && prev != b'e' && prev != b'E' { - split = Some(i); - break; - } - } - let (re_s, im_s) = match split { - Some(i) => (&body[..i], &body[i..]), - None => ("", body), - }; - let re = if re_s.is_empty() { 0.0 } else { parse_real(re_s).ok_or(CellError::Num)? }; - let im = match im_s { - "" | "+" => 1.0, - "-" => -1.0, - t => parse_real(t).ok_or(CellError::Num)?, - }; - Ok((Cx::new(re, im), Some(sfx))) -} - -fn cx_of(v: &Value) -> R<(Cx, Option)> { - match v { - Value::Number(n) => Ok((Cx::new(*n, 0.0), None)), - Value::Empty => Ok((Cx::new(0.0, 0.0), None)), - Value::Bool(_) => Err(CellError::Value), - Value::Error(e) => Err(*e), - Value::Text(t) => parse_complex(t), - Value::Array(a) => cx_of(a.data.first().unwrap_or(&Value::Empty)), - } -} - -fn cx_arg(a: &[Arg], i: usize) -> R<(Cx, Option)> { - cx_of(&a.get(i).map(scalar).unwrap_or(Value::Empty)) -} - -fn merge_suffix(acc: Option, s: Option) -> R> { - match (acc, s) { - (Some(x), Some(y)) if x != y => Err(CellError::Value), - (Some(x), _) => Ok(Some(x)), - (None, y) => Ok(y), - } -} - -fn fmt_complex(z: Cx, suffix: char) -> R { - if !z.re.is_finite() || !z.im.is_finite() { - return Err(CellError::Num); - } - let re = clean(z.re); - let im = clean(z.im); - let mut s = String::new(); - if im == 0.0 { - s.push_str(&number_to_text(re)); - return text_val(s); - } - if re != 0.0 { - s.push_str(&number_to_text(re)); - if im > 0.0 { - s.push('+'); - } - } - if im == -1.0 { - s.push('-'); - } else if im != 1.0 { - s.push_str(&number_to_text(im)); - } - s.push(suffix); - text_val(s) -} - -/// Rounds to 15 significant digits so values like 0.999999999999999999 print as 1. -fn clean(x: f64) -> f64 { - let s = format!("{:.14e}", x); - s.parse().unwrap_or(x) -} - -fn complex(a: &[Arg], _c: &mut dyn Ctx) -> R { - let re = num(a, 0)?; - let im = num(a, 1)?; - let sfx = if has(a, 2) { text(a, 2)? } else { "i".into() }; - let sfx = match sfx.as_str() { - "i" | "" => 'i', - "j" => 'j', - _ => return Err(CellError::Value), - }; - fmt_complex(Cx::new(re, im), sfx) -} - -fn im_unary(a: &[Arg], f: fn(Cx) -> R) -> R { - let (z, s) = cx_arg(a, 0)?; - fmt_complex(f(z)?, s.unwrap_or('i')) -} - -fn im_real_fn(a: &[Arg], f: fn(Cx) -> R) -> R { - let (z, _) = cx_arg(a, 0)?; - num_val(f(z)?) -} - macro_rules! im1 { ($name:ident, $f:expr) => { fn $name(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -705,16 +246,41 @@ macro_rules! im1 { }; } +mod bessel; +mod complex; +mod units; + +use bessel::{besseli, besselj, besselk, bessely}; +#[cfg(test)] +use complex::parse_complex; +use complex::{Cx, ONE, complex, im_unary, imabs, imaginary, imargument, imdiv, impower, improduct, imreal, imsub, imsum}; +use units::convert; + im1!(imconjugate, |z: Cx| Ok(Cx::new(z.re, -z.im))); im1!(imexp, |z: Cx| Ok(z.exp())); im1!(imln, |z: Cx| z.ln()); im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10))); im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2))); +// Excel takes the polar route, so IMSQRT("-4") is 1.22514845490862E-16+2i, not 2i. Its cosine +// reduces its argument with a 64-bit-mantissa pi, which is PI/2 + 2.5e-20 and shows up as the +// stray real part. `+ 0.0` turns a -0 imaginary part into +0, so the angle stays +pi. +// shortcut: parts above ~1E154 or below ~1E-155 keep the exact result (Excel gives #NUM! or 0), no known use. +const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20; im1!(imsqrt, |z: Cx| { - let m = z.abs(); - let re = ((m + z.re) / 2.0).sqrt(); - let im = ((m - z.re) / 2.0).sqrt(); - Ok(Cx::new(re, if z.im < 0.0 { -im } else { im })) + let im = z.im + 0.0; + let theta = match z.re { + 0.0 => { + if im == 0.0 { + 0.0 + } else { + std::f64::consts::FRAC_PI_2.copysign(im) + } + } + x if x > 0.0 => (im / x).atan(), + x => (im / x).atan() + if im < 0.0 { -std::f64::consts::PI } else { std::f64::consts::PI }, + }; + let (half, root) = (theta / 2.0, z.abs().sqrt()); + Ok(Cx::new(root * (half.cos() + HALF_PI_EXCESS * half.abs().sin()), root * half.sin())) }); im1!(imsin, |z: Cx| Ok(z.sin())); im1!(imcos, |z: Cx| Ok(z.cos())); @@ -727,310 +293,6 @@ im1!(imcosh, |z: Cx| Ok(z.cosh())); im1!(imsech, |z: Cx| ONE.div(z.cosh())); im1!(imcsch, |z: Cx| ONE.div(z.sinh())); -fn imabs(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.abs())) -} -fn imreal(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.re)) -} -fn imaginary(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.im)) -} -fn imargument(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| if z.re == 0.0 && z.im == 0.0 { Err(CellError::Div0) } else { Ok(z.arg()) }) -} - -fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R) -> R { - let (x, s1) = cx_arg(a, 0)?; - let (y, s2) = cx_arg(a, 1)?; - let s = merge_suffix(s1, s2)?; - fmt_complex(f(x, y)?, s.unwrap_or('i')) -} -fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_binary(a, Cx::div) -} -fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_binary(a, |x, y| Ok(x.sub(y))) -} -fn impower(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (z, s) = cx_arg(a, 0)?; - let n = num(a, 1)?; - // Integer powers by repeated multiplication are exact for small Gaussian integers. - let r = if n == n.trunc() && n.abs() <= 64.0 && !(z.re == 0.0 && z.im == 0.0) { - let mut acc = ONE; - for _ in 0..(n.abs() as u32) { - acc = acc.mul(z); - } - if n < 0.0 { ONE.div(acc)? } else { acc } - } else { - z.powf(n)? - }; - fmt_complex(r, s.unwrap_or('i')) -} - -fn im_fold(a: &[Arg], init: Cx, f: fn(Cx, Cx) -> Cx) -> R { - let mut acc = init; - let mut sfx = None; - for arg in a { - let direct = !matches!(arg.value, Value::Array(_)); - for v in flatten(std::slice::from_ref(arg)) { - if matches!(v, Value::Empty) && !direct { - continue; - } - let (z, s) = cx_of(&v)?; - sfx = merge_suffix(sfx, s)?; - acc = f(acc, z); - } - } - fmt_complex(acc, sfx.unwrap_or('i')) -} -fn imsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_fold(a, Cx::new(0.0, 0.0), Cx::add) -} -fn improduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_fold(a, ONE, Cx::mul) -} - -// --------------------------------------------------------------------------------------------- -// Bessel functions - -const MAX_BESSEL_POINTS: f64 = 4_000_000.0; - -/// J_n(x) for integer n ≥ 0 via Bessel's integral, J_n(x) = (1/2π)∫₀^{2π} cos(nτ − x sin τ) dτ, -/// evaluated with the trapezoid rule, which converges exponentially for periodic integrands. -fn bessel_j(n: u32, x: f64) -> Option { - let pts = (2.0 * (x.abs() + n as f64) + 64.0).ceil(); - if pts > MAX_BESSEL_POINTS { - return if x.abs() > 1e6 { hankel(n, x.abs()).map(|(j, _)| if x < 0.0 && n % 2 == 1 { -j } else { j }) } else { None }; - } - let m = pts as usize; - let h = 2.0 * PI / m as f64; - let nf = n as f64; - let mut sum = 0.0; - for k in 0..m { - let t = k as f64 * h; - sum += (nf * t - x * t.sin()).cos(); - } - Some(sum / m as f64) -} - -/// Hankel asymptotic expansion: (J_n(x), Y_n(x)) for large x. -fn hankel(n: u32, x: f64) -> Option<(f64, f64)> { - let mu = 4.0 * (n as f64) * (n as f64); - let mut p = 0.0; - let mut q = 0.0; - let mut term: f64 = 1.0; - let mut last = f64::INFINITY; - for k in 0..200 { - if term.abs() > last { - break; - } - let t = if k % 4 < 2 { term } else { -term }; - if k % 2 == 0 { - p += t; - } else { - q += t; - } - last = term.abs(); - if last < 1e-17 { - break; - } - let kk = (k + 1) as f64; - let odd = 2.0 * kk - 1.0; - term *= (mu - odd * odd) / (kk * 8.0 * x); - } - let chi = x - (n as f64 / 2.0 + 0.25) * PI; - let f = (2.0 / (PI * x)).sqrt(); - let j = f * (p * chi.cos() - q * chi.sin()); - let y = f * (p * chi.sin() + q * chi.cos()); - if j.is_finite() && y.is_finite() { Some((j, y)) } else { None } -} - -const EULER: f64 = std::f64::consts::EULER_GAMMA; - -/// J_0..J_m(x) for 0 < x ≤ ~100 by Miller's backward recurrence, normalised with -/// J_0 + 2ΣJ_{2k} = 1. -fn bessel_j_all(x: f64) -> Vec { - let top = x.ceil() as usize + 30 + (40.0 * x.max(1.0)).sqrt() as usize; - let m = top + top % 2; - let mut j = vec![0.0f64; m + 2]; - if let Some(v) = j.get_mut(m) { - *v = 1e-30; - } - for k in (1..=m).rev() { - let jk = j.get(k).copied().unwrap_or(0.0); - let jk1 = j.get(k + 1).copied().unwrap_or(0.0); - let mut prev = 2.0 * k as f64 / x * jk - jk1; - if prev.abs() > 1e250 { - for v in j.iter_mut() { - *v *= 1e-250; - } - prev *= 1e-250; - } - if let Some(v) = j.get_mut(k - 1) { - *v = prev; - } - } - let norm: f64 = j.first().copied().unwrap_or(0.0) + 2.0 * j.iter().skip(2).step_by(2).sum::(); - if norm != 0.0 { - for v in j.iter_mut() { - *v /= norm; - } - } - j -} - -/// Y_0 and Y_1 by Neumann series over J_k (x ≤ 25) or the asymptotic expansion. -fn bessel_y01(x: f64) -> Option<(f64, f64)> { - if x > 25.0 { - let (_, y0) = hankel(0, x)?; - let (_, y1) = hankel(1, x)?; - return Some((y0, y1)); - } - let j = bessel_j_all(x); - let jj = |k: usize| j.get(k).copied().unwrap_or(0.0); - let l = (x / 2.0).ln() + EULER; - // Y0 = (2/π)(L J0) − (4/π) Σ (−1)^k J_{2k}/k - // Y1 = −Y0' = −(2/π)(J0/x − L J1) + (2/π) Σ (−1)^k (J_{2k−1} − J_{2k+1})/k - let mut s0 = 0.0; - let mut s1 = 0.0; - let mut k = 1usize; - while 2 * k + 1 < j.len() { - let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - s0 += sign * jj(2 * k) / k as f64; - s1 += sign * (jj(2 * k - 1) - jj(2 * k + 1)) / k as f64; - k += 1; - } - let y0 = (2.0 / PI) * l * jj(0) - (4.0 / PI) * s0; - let y1 = -(2.0 / PI) * (jj(0) / x - l * jj(1)) + (2.0 / PI) * s1; - if y0.is_finite() && y1.is_finite() { Some((y0, y1)) } else { None } -} - -fn bessel_y(n: u32, x: f64) -> Option { - let (y0, y1) = bessel_y01(x)?; - if n == 0 { - return Some(y0); - } - let (mut a, mut b) = (y0, y1); - for k in 1..n { - let c = 2.0 * k as f64 / x * b - a; - a = b; - b = c; - if !b.is_finite() { - return None; - } - } - Some(b) -} - -/// I_n(x) by its power series (all terms positive), computed with a log-scaled first term. -fn bessel_i(n: u32, x: f64) -> Option { - if x == 0.0 { - return Some(if n == 0 { 1.0 } else { 0.0 }); - } - let ax = x.abs(); - let nf = n as f64; - let ln_t0 = nf * (ax / 2.0).ln() - crate::special::ln_gamma(nf + 1.0); - let q = ax * ax / 4.0; - let mut term = 1.0; - let mut sum = 1.0; - let mut k = 0.0; - while k < 1e6 { - k += 1.0; - term *= q / (k * (k + nf)); - sum += term; - if term < 1e-17 * sum { - break; - } - if !sum.is_finite() { - return None; - } - } - let r = (ln_t0 + sum.ln()).exp(); - let r = if x < 0.0 && n % 2 == 1 { -r } else { r }; - if r.is_finite() { Some(r) } else { None } -} - -/// K_n(x) for x > 0: K_0 and K_1 from ∫₀^∞ e^{−x cosh t} cosh(νt) dt (trapezoid rule), then -/// upward recurrence. -fn bessel_k(n: u32, x: f64) -> Option { - let k_int = |nu: f64| -> f64 { - let h: f64 = 0.02; - let mut sum = 0.5 * (-x).exp(); - let mut t: f64 = h; - while t < 800.0 { - let v = (-x * t.cosh() + (nu * t).ln_cosh_safe()).exp(); - sum += v; - if v < 1e-18 * sum && x * t.cosh() > 50.0 { - break; - } - t += h; - } - sum * h - }; - let k0 = k_int(0.0); - if n == 0 { - return Some(k0); - } - let k1 = k_int(1.0); - let (mut a, mut b) = (k0, k1); - for k in 1..n { - let c = a + 2.0 * k as f64 / x * b; - a = b; - b = c; - if !b.is_finite() { - return None; - } - } - if b.is_finite() { Some(b) } else { None } -} - -trait LnCosh { - fn ln_cosh_safe(self) -> f64; -} -impl LnCosh for f64 { - /// ln(cosh(x)) without overflow. - fn ln_cosh_safe(self) -> f64 { - let a = self.abs(); - a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2 - } -} - -fn bessel_args(a: &[Arg]) -> R<(f64, u32)> { - let x = num(a, 0)?; - let n = num(a, 1)?.trunc(); - if n < 0.0 { - return Err(CellError::Num); - } - if n > 100_000.0 { - return Err(CellError::Num); - } - Ok((x, n as u32)) -} - -fn besselj(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - num_val(bessel_j(n, x).ok_or(CellError::Num)?) -} -fn bessely(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(bessel_y(n, x).ok_or(CellError::Num)?) -} -fn besseli(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - num_val(bessel_i(n, x).ok_or(CellError::Num)?) -} -fn besselk(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(bessel_k(n, x).ok_or(CellError::Num)?) -} - pub(crate) fn specs() -> Vec { vec![ f!("BIN2DEC", 1, 1, Engineering, S, "BIN2DEC(number)", "Converts a binary number to decimal.", bin2dec), @@ -1153,6 +415,12 @@ mod tests { is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777"); is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001"); is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100"); + is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num); + is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num); + is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num); + is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value); + is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111"); + is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF"); is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num); is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064"); is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA"); @@ -1177,6 +445,21 @@ mod tests { is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040"); is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B"); is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num); + for (f, input) in [ + ("BIN2DEC", " 10"), + ("BIN2DEC", "10 "), + ("BIN2DEC", "\t10"), + ("BIN2HEX", " 10"), + ("BIN2OCT", "10 "), + ("HEX2DEC", " 10"), + ("HEX2BIN", "10 "), + ("HEX2OCT", "\t10"), + ("OCT2DEC", " 10"), + ("OCT2BIN", "10 "), + ("OCT2HEX", "\t10"), + ] { + is_err(ev(f, vec![t(input)]), CellError::Num); + } is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num); is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num); is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value); @@ -1197,6 +480,21 @@ mod tests { is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num); + for f in ["BITLSHIFT", "BITRSHIFT"] { + for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] { + close(ev(f, vec![n(0.0), n(shift)]), 0.0); + } + for shift in [-100.0, -54.0, 54.0, 100.0] { + is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num); + } + if f == "BITLSHIFT" { + is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num); + } else { + close(ev(f, vec![n(1.0), n(53.0)]), 0.0); + } + is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value); + } + is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num); } #[test] @@ -1275,9 +573,36 @@ mod tests { cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2); cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8); cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3); - cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0); - is_text(ev("IMSQRT", vec![t("-4")]), "2i"); - is_text(ev("IMSQRT", vec![t("-9")]), "3i"); + // Strings read back from Excel (16.0): its stray real part on negative reals included. + for (input, want) in [ + ("-4", "1.22514845490862E-16+2i"), + ("-9", "1.83772268236293E-16+3i"), + ("-1", "6.1257422745431E-17+i"), + ("-2", "8.66310780426106E-17+1.4142135623731i"), + ("-100", "6.1257422745431E-16+10i"), + ("-1E10", "6.1257422745431E-12+100000i"), + ("-1E-10", "6.1257422745431E-22+0.00001i"), + ("-4+0j", "1.22514845490862E-16+2j"), + ("-4-0i", "1.22514845490862E-16+2i"), + ("-4-1E-20i", "1.22514845490862E-16-2i"), + ("-1+1E-10i", "5.00000653944413E-11+i"), + ("-0.5-1E-12i", "7.07212958930194E-13-0.707106781186548i"), + ("4", "2"), + ("2", "1.4142135623731"), + ("0", "0"), + ("3+4i", "2+i"), + ("3+4j", "2+j"), + ("-3+4i", "1+2i"), + ("-3-4i", "1-2i"), + ("-2i", "1-i"), + ("2i", "1+i"), + ("i", "0.707106781186548+0.707106781186547i"), + ("5-12i", "3-2i"), + ("-5+12i", "2+3i"), + ("1E10+1E10i", "109868.411346781+45508.9860562227i"), + ] { + is_text(ev("IMSQRT", vec![t(input)]), want); + } is_text(ev("IMSQRT", vec![t("4")]), "2"); is_text(ev("IMSQRT", vec![t("-2i")]), "1-i"); is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i"); diff --git a/crates/functions/src/engineering/bessel.rs b/crates/functions/src/engineering/bessel.rs new file mode 100644 index 000000000..c91157318 --- /dev/null +++ b/crates/functions/src/engineering/bessel.rs @@ -0,0 +1,245 @@ +use super::{Arg, CellError, Ctx, PI, R, Value, num, num_val}; + +// --------------------------------------------------------------------------------------------- +// Bessel functions + +const MAX_BESSEL_POINTS: f64 = 4_000_000.0; + +/// J_n(x) for integer n ≥ 0 via Bessel's integral, J_n(x) = (1/2π)∫₀^{2π} cos(nτ − x sin τ) dτ, +/// evaluated with the trapezoid rule, which converges exponentially for periodic integrands. +fn bessel_j(n: u32, x: f64) -> Option { + let pts = (2.0 * (x.abs() + n as f64) + 64.0).ceil(); + if pts > MAX_BESSEL_POINTS { + return if x.abs() > 1e6 { hankel(n, x.abs()).map(|(j, _)| if x < 0.0 && n % 2 == 1 { -j } else { j }) } else { None }; + } + let m = pts as usize; + let h = 2.0 * PI / m as f64; + let nf = n as f64; + let mut sum = 0.0; + for k in 0..m { + let t = k as f64 * h; + sum += (nf * t - x * t.sin()).cos(); + } + Some(sum / m as f64) +} + +/// Hankel asymptotic expansion: (J_n(x), Y_n(x)) for large x. +fn hankel(n: u32, x: f64) -> Option<(f64, f64)> { + let mu = 4.0 * (n as f64) * (n as f64); + let mut p = 0.0; + let mut q = 0.0; + let mut term: f64 = 1.0; + let mut last = f64::INFINITY; + for k in 0..200 { + if term.abs() > last { + break; + } + let t = if k % 4 < 2 { term } else { -term }; + if k % 2 == 0 { + p += t; + } else { + q += t; + } + last = term.abs(); + if last < 1e-17 { + break; + } + let kk = (k + 1) as f64; + let odd = 2.0 * kk - 1.0; + term *= (mu - odd * odd) / (kk * 8.0 * x); + } + let chi = x - (n as f64 / 2.0 + 0.25) * PI; + let f = (2.0 / (PI * x)).sqrt(); + let j = f * (p * chi.cos() - q * chi.sin()); + let y = f * (p * chi.sin() + q * chi.cos()); + if j.is_finite() && y.is_finite() { Some((j, y)) } else { None } +} + +const EULER: f64 = std::f64::consts::EULER_GAMMA; + +/// J_0..J_m(x) for 0 < x ≤ ~100 by Miller's backward recurrence, normalised with +/// J_0 + 2ΣJ_{2k} = 1. +fn bessel_j_all(x: f64) -> Vec { + let top = x.ceil() as usize + 30 + (40.0 * x.max(1.0)).sqrt() as usize; + let m = top + top % 2; + let mut j = vec![0.0f64; m + 2]; + if let Some(v) = j.get_mut(m) { + *v = 1e-30; + } + for k in (1..=m).rev() { + let jk = j.get(k).copied().unwrap_or(0.0); + let jk1 = j.get(k + 1).copied().unwrap_or(0.0); + let mut prev = 2.0 * k as f64 / x * jk - jk1; + if prev.abs() > 1e250 { + for v in j.iter_mut() { + *v *= 1e-250; + } + prev *= 1e-250; + } + if let Some(v) = j.get_mut(k - 1) { + *v = prev; + } + } + let norm: f64 = j.first().copied().unwrap_or(0.0) + 2.0 * j.iter().skip(2).step_by(2).sum::(); + if norm != 0.0 { + for v in j.iter_mut() { + *v /= norm; + } + } + j +} + +/// Y_0 and Y_1 by Neumann series over J_k (x ≤ 25) or the asymptotic expansion. +fn bessel_y01(x: f64) -> Option<(f64, f64)> { + if x > 25.0 { + let (_, y0) = hankel(0, x)?; + let (_, y1) = hankel(1, x)?; + return Some((y0, y1)); + } + let j = bessel_j_all(x); + let jj = |k: usize| j.get(k).copied().unwrap_or(0.0); + let l = (x / 2.0).ln() + EULER; + // Y0 = (2/π)(L J0) − (4/π) Σ (−1)^k J_{2k}/k + // Y1 = −Y0' = −(2/π)(J0/x − L J1) + (2/π) Σ (−1)^k (J_{2k−1} − J_{2k+1})/k + let mut s0 = 0.0; + let mut s1 = 0.0; + let mut k = 1usize; + while 2 * k + 1 < j.len() { + let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; + s0 += sign * jj(2 * k) / k as f64; + s1 += sign * (jj(2 * k - 1) - jj(2 * k + 1)) / k as f64; + k += 1; + } + let y0 = (2.0 / PI) * l * jj(0) - (4.0 / PI) * s0; + let y1 = -(2.0 / PI) * (jj(0) / x - l * jj(1)) + (2.0 / PI) * s1; + if y0.is_finite() && y1.is_finite() { Some((y0, y1)) } else { None } +} + +fn bessel_y(n: u32, x: f64) -> Option { + let (y0, y1) = bessel_y01(x)?; + if n == 0 { + return Some(y0); + } + let (mut a, mut b) = (y0, y1); + for k in 1..n { + let c = 2.0 * k as f64 / x * b - a; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + Some(b) +} + +/// I_n(x) by its power series (all terms positive), computed with a log-scaled first term. +fn bessel_i(n: u32, x: f64) -> Option { + if x == 0.0 { + return Some(if n == 0 { 1.0 } else { 0.0 }); + } + let ax = x.abs(); + let nf = n as f64; + let ln_t0 = nf * (ax / 2.0).ln() - crate::special::ln_gamma(nf + 1.0); + let q = ax * ax / 4.0; + let mut term = 1.0; + let mut sum = 1.0; + let mut k = 0.0; + while k < 1e6 { + k += 1.0; + term *= q / (k * (k + nf)); + sum += term; + if term < 1e-17 * sum { + break; + } + if !sum.is_finite() { + return None; + } + } + let r = (ln_t0 + sum.ln()).exp(); + let r = if x < 0.0 && n % 2 == 1 { -r } else { r }; + if r.is_finite() { Some(r) } else { None } +} + +/// K_n(x) for x > 0: K_0 and K_1 from ∫₀^∞ e^{−x cosh t} cosh(νt) dt (trapezoid rule), then +/// upward recurrence. +fn bessel_k(n: u32, x: f64) -> Option { + let k_int = |nu: f64| -> f64 { + let h: f64 = 0.02; + let mut sum = 0.5 * (-x).exp(); + let mut t: f64 = h; + while t < 800.0 { + let v = (-x * t.cosh() + (nu * t).ln_cosh_safe()).exp(); + sum += v; + if v < 1e-18 * sum && x * t.cosh() > 50.0 { + break; + } + t += h; + } + sum * h + }; + let k0 = k_int(0.0); + if n == 0 { + return Some(k0); + } + let k1 = k_int(1.0); + let (mut a, mut b) = (k0, k1); + for k in 1..n { + let c = a + 2.0 * k as f64 / x * b; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + if b.is_finite() { Some(b) } else { None } +} + +trait LnCosh { + fn ln_cosh_safe(self) -> f64; +} + +impl LnCosh for f64 { + /// ln(cosh(x)) without overflow. + fn ln_cosh_safe(self) -> f64 { + let a = self.abs(); + a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2 + } +} + +fn bessel_args(a: &[Arg]) -> R<(f64, u32)> { + let x = num(a, 0)?; + let n = num(a, 1)?.trunc(); + if n < 0.0 { + return Err(CellError::Num); + } + if n > 100_000.0 { + return Err(CellError::Num); + } + Ok((x, n as u32)) +} + +pub(super) fn besselj(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_j(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn bessely(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_y(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn besseli(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_i(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn besselk(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_k(n, x).ok_or(CellError::Num)?) +} diff --git a/crates/functions/src/engineering/complex.rs b/crates/functions/src/engineering/complex.rs new file mode 100644 index 000000000..a04c19e8f --- /dev/null +++ b/crates/functions/src/engineering/complex.rs @@ -0,0 +1,268 @@ +use super::{Arg, CellError, Ctx, R, Value, flatten, has, num, num_val, number_to_text, scalar, text, text_val}; + +// --------------------------------------------------------------------------------------------- +// Complex numbers + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Cx { + pub(super) re: f64, + pub(super) im: f64, +} + +impl Cx { + pub(super) fn new(re: f64, im: f64) -> Cx { + Cx { re, im } + } + fn add(self, o: Cx) -> Cx { + Cx::new(self.re + o.re, self.im + o.im) + } + fn sub(self, o: Cx) -> Cx { + Cx::new(self.re - o.re, self.im - o.im) + } + fn mul(self, o: Cx) -> Cx { + Cx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) + } + pub(super) fn div(self, o: Cx) -> R { + let d = o.re * o.re + o.im * o.im; + if d == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new((self.re * o.re + self.im * o.im) / d, (self.im * o.re - self.re * o.im) / d)) + } + pub(super) fn abs(self) -> f64 { + self.re.hypot(self.im) + } + fn arg(self) -> f64 { + self.im.atan2(self.re) + } + pub(super) fn exp(self) -> Cx { + let e = self.re.exp(); + Cx::new(e * self.im.cos(), e * self.im.sin()) + } + pub(super) fn ln(self) -> R { + if self.re == 0.0 && self.im == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new(self.abs().ln(), self.arg())) + } + pub(super) fn sin(self) -> Cx { + Cx::new(self.re.sin() * self.im.cosh(), self.re.cos() * self.im.sinh()) + } + pub(super) fn cos(self) -> Cx { + Cx::new(self.re.cos() * self.im.cosh(), -self.re.sin() * self.im.sinh()) + } + pub(super) fn sinh(self) -> Cx { + Cx::new(self.re.sinh() * self.im.cos(), self.re.cosh() * self.im.sin()) + } + pub(super) fn cosh(self) -> Cx { + Cx::new(self.re.cosh() * self.im.cos(), self.re.sinh() * self.im.sin()) + } + fn powf(self, n: f64) -> R { + if self.re == 0.0 && self.im == 0.0 { + return if n > 0.0 { Ok(Cx::new(0.0, 0.0)) } else { Err(CellError::Num) }; + } + let r = self.abs().powf(n); + let t = self.arg() * n; + Ok(Cx::new(r * t.cos(), r * t.sin())) + } +} + +pub(super) const ONE: Cx = Cx { re: 1.0, im: 0.0 }; + +/// Parses a real number written in a complex string (no spaces, optional exponent). +fn parse_real(s: &str) -> Option { + if s.is_empty() || s.contains(char::is_whitespace) { + return None; + } + let ok = s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')); + if !ok { + return None; + } + s.parse::().ok().filter(|v| v.is_finite()) +} + +/// Parses "a+bi" forms. Returns the number and its suffix ('i', 'j' or none). +pub(super) fn parse_complex(s: &str) -> R<(Cx, Option)> { + if s.is_empty() { + return Ok((Cx::new(0.0, 0.0), None)); + } + let suffix = match s.chars().last() { + Some(c @ ('i' | 'j')) => Some(c), + _ => None, + }; + let Some(sfx) = suffix else { + return parse_real(s).map(|r| (Cx::new(r, 0.0), None)).ok_or(CellError::Num); + }; + let body = &s[..s.len() - 1]; + // Split at the last sign that is not at the start and not part of an exponent. + let bytes = body.as_bytes(); + let mut split = None; + for i in (1..bytes.len()).rev() { + let c = bytes.get(i).copied().unwrap_or(0); + let prev = bytes.get(i - 1).copied().unwrap_or(0); + if (c == b'+' || c == b'-') && prev != b'e' && prev != b'E' { + split = Some(i); + break; + } + } + let (re_s, im_s) = match split { + Some(i) => (&body[..i], &body[i..]), + None => ("", body), + }; + let re = if re_s.is_empty() { 0.0 } else { parse_real(re_s).ok_or(CellError::Num)? }; + let im = match im_s { + "" | "+" => 1.0, + "-" => -1.0, + t => parse_real(t).ok_or(CellError::Num)?, + }; + Ok((Cx::new(re, im), Some(sfx))) +} + +fn cx_of(v: &Value) -> R<(Cx, Option)> { + match v { + Value::Number(n) => Ok((Cx::new(*n, 0.0), None)), + Value::Empty => Ok((Cx::new(0.0, 0.0), None)), + Value::Bool(_) => Err(CellError::Value), + Value::Error(e) => Err(*e), + Value::Text(t) => parse_complex(t), + Value::Array(a) => cx_of(a.data.first().unwrap_or(&Value::Empty)), + } +} + +fn cx_arg(a: &[Arg], i: usize) -> R<(Cx, Option)> { + cx_of(&a.get(i).map(scalar).unwrap_or(Value::Empty)) +} + +fn merge_suffix(acc: Option, s: Option) -> R> { + match (acc, s) { + (Some(x), Some(y)) if x != y => Err(CellError::Value), + (Some(x), _) => Ok(Some(x)), + (None, y) => Ok(y), + } +} + +fn fmt_complex(z: Cx, suffix: char) -> R { + if !z.re.is_finite() || !z.im.is_finite() { + return Err(CellError::Num); + } + let re = clean(z.re); + let im = clean(z.im); + let mut s = String::new(); + if im == 0.0 { + s.push_str(&number_to_text(re)); + return text_val(s); + } + if re != 0.0 { + s.push_str(&number_to_text(re)); + if im > 0.0 { + s.push('+'); + } + } + if im == -1.0 { + s.push('-'); + } else if im != 1.0 { + s.push_str(&number_to_text(im)); + } + s.push(suffix); + text_val(s) +} + +/// Rounds to 15 significant digits so values like 0.999999999999999999 print as 1. +fn clean(x: f64) -> f64 { + let s = format!("{:.14e}", x); + s.parse().unwrap_or(x) +} + +pub(super) fn complex(a: &[Arg], _c: &mut dyn Ctx) -> R { + let re = num(a, 0)?; + let im = num(a, 1)?; + let sfx = if has(a, 2) { text(a, 2)? } else { "i".into() }; + let sfx = match sfx.as_str() { + "i" | "" => 'i', + "j" => 'j', + _ => return Err(CellError::Value), + }; + fmt_complex(Cx::new(re, im), sfx) +} + +pub(super) fn im_unary(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, s) = cx_arg(a, 0)?; + fmt_complex(f(z)?, s.unwrap_or('i')) +} + +fn im_real_fn(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, _) = cx_arg(a, 0)?; + num_val(f(z)?) +} + +pub(super) fn imabs(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.abs())) +} + +pub(super) fn imreal(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.re)) +} + +pub(super) fn imaginary(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.im)) +} + +pub(super) fn imargument(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| if z.re == 0.0 && z.im == 0.0 { Err(CellError::Div0) } else { Ok(z.arg()) }) +} + +fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R) -> R { + let (x, s1) = cx_arg(a, 0)?; + let (y, s2) = cx_arg(a, 1)?; + let s = merge_suffix(s1, s2)?; + fmt_complex(f(x, y)?, s.unwrap_or('i')) +} + +pub(super) fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, Cx::div) +} + +pub(super) fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, |x, y| Ok(x.sub(y))) +} + +pub(super) fn impower(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (z, s) = cx_arg(a, 0)?; + let n = num(a, 1)?; + // Integer powers by repeated multiplication are exact for small Gaussian integers. + let r = if n == n.trunc() && n.abs() <= 64.0 && !(z.re == 0.0 && z.im == 0.0) { + let mut acc = ONE; + for _ in 0..(n.abs() as u32) { + acc = acc.mul(z); + } + if n < 0.0 { ONE.div(acc)? } else { acc } + } else { + z.powf(n)? + }; + fmt_complex(r, s.unwrap_or('i')) +} + +fn im_fold(a: &[Arg], init: Cx, f: fn(Cx, Cx) -> Cx) -> R { + let mut acc = init; + let mut sfx = None; + for arg in a { + let direct = !matches!(arg.value, Value::Array(_)); + for v in flatten(std::slice::from_ref(arg)) { + if matches!(v, Value::Empty) && !direct { + continue; + } + let (z, s) = cx_of(&v)?; + sfx = merge_suffix(sfx, s)?; + acc = f(acc, z); + } + } + fmt_complex(acc, sfx.unwrap_or('i')) +} + +pub(super) fn imsum(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, Cx::new(0.0, 0.0), Cx::add) +} + +pub(super) fn improduct(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, ONE, Cx::mul) +} diff --git a/crates/functions/src/engineering/units.rs b/crates/functions/src/engineering/units.rs new file mode 100644 index 000000000..b6c4faf2b --- /dev/null +++ b/crates/functions/src/engineering/units.rs @@ -0,0 +1,286 @@ +use super::{Arg, CellError, Ctx, R, Value, num, num_val, text}; + +// --------------------------------------------------------------------------------------------- +// CONVERT + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dim { + Mass, + Length, + Time, + Pressure, + Force, + Energy, + Power, + Magnetism, + Temperature, + Volume, + Area, + Information, + Speed, +} + +/// How a unit accepts prefixes: none, SI (raised to `power` for areas/volumes) or SI + binary. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pre { + No, + Si(i32), + Bin, +} + +struct Unit { + names: &'static [&'static str], + dim: Dim, + factor: f64, + pre: Pre, +} + +const fn u(names: &'static [&'static str], dim: Dim, factor: f64, pre: Pre) -> Unit { + Unit { names, dim, factor, pre } +} + +const IN: f64 = 0.0254; + +const FT: f64 = 0.3048; + +const YD: f64 = 0.9144; + +const MI: f64 = 1609.344; + +const NMI: f64 = 1852.0; + +const LY: f64 = 9_460_730_472_580_800.0; + +const PICA_PT: f64 = 0.0254 / 72.0; + +const PICA: f64 = 0.0254 / 6.0; + +const GAL: f64 = 0.003_785_411_784; + +const UNITS: &[Unit] = &[ + // Mass (gram) + u(&["g"], Dim::Mass, 1.0, Pre::Si(1)), + u(&["sg"], Dim::Mass, 14_593.902_937_206_4, Pre::No), + u(&["lbm"], Dim::Mass, 453.592_37, Pre::No), + u(&["u"], Dim::Mass, 1.660_539_066_6e-24, Pre::Si(1)), + u(&["ozm"], Dim::Mass, 28.349_523_125, Pre::No), + u(&["grain"], Dim::Mass, 0.064_798_91, Pre::No), + u(&["cwt", "shweight"], Dim::Mass, 45_359.237, Pre::No), + u(&["uk_cwt", "lcwt", "hweight"], Dim::Mass, 50_802.345_44, Pre::No), + u(&["stone"], Dim::Mass, 6_350.293_18, Pre::No), + u(&["ton"], Dim::Mass, 907_184.74, Pre::No), + u(&["uk_ton", "LTON", "brton"], Dim::Mass, 1_016_046.908_8, Pre::No), + // Length (metre) + u(&["m"], Dim::Length, 1.0, Pre::Si(1)), + u(&["mi"], Dim::Length, MI, Pre::No), + u(&["Nmi"], Dim::Length, NMI, Pre::No), + u(&["in"], Dim::Length, IN, Pre::No), + u(&["ft"], Dim::Length, FT, Pre::No), + u(&["yd"], Dim::Length, YD, Pre::No), + u(&["ang"], Dim::Length, 1e-10, Pre::Si(1)), + u(&["ell"], Dim::Length, 1.143, Pre::No), + u(&["ly"], Dim::Length, LY, Pre::Si(1)), + u(&["parsec", "pc"], Dim::Length, 30_856_775_814_913_673.0, Pre::Si(1)), + u(&["Picapt", "Pica"], Dim::Length, PICA_PT, Pre::No), + u(&["pica"], Dim::Length, PICA, Pre::No), + u(&["survey_mi"], Dim::Length, 1_609.347_218_694_437, Pre::No), + // Time (second) + u(&["yr"], Dim::Time, 31_557_600.0, Pre::No), + u(&["day", "d"], Dim::Time, 86_400.0, Pre::No), + u(&["hr"], Dim::Time, 3_600.0, Pre::No), + u(&["mn", "min"], Dim::Time, 60.0, Pre::No), + u(&["sec", "s"], Dim::Time, 1.0, Pre::Si(1)), + // Pressure (pascal) + u(&["Pa", "p"], Dim::Pressure, 1.0, Pre::Si(1)), + u(&["atm", "at"], Dim::Pressure, 101_325.0, Pre::Si(1)), + u(&["mmHg"], Dim::Pressure, 133.322_387_415, Pre::Si(1)), + u(&["psi"], Dim::Pressure, 6_894.757_293_168_36, Pre::No), + u(&["Torr"], Dim::Pressure, 101_325.0 / 760.0, Pre::No), + // Force (newton) + u(&["N"], Dim::Force, 1.0, Pre::Si(1)), + u(&["dyn", "dy"], Dim::Force, 1e-5, Pre::Si(1)), + u(&["lbf"], Dim::Force, 4.448_221_615_260_5, Pre::No), + u(&["pond"], Dim::Force, 0.009_806_65, Pre::Si(1)), + // Energy (joule) + u(&["J"], Dim::Energy, 1.0, Pre::Si(1)), + u(&["e"], Dim::Energy, 1e-7, Pre::Si(1)), + u(&["c"], Dim::Energy, 4.184, Pre::Si(1)), + u(&["cal"], Dim::Energy, 4.1868, Pre::Si(1)), + u(&["eV", "ev"], Dim::Energy, 1.602_176_634e-19, Pre::Si(1)), + u(&["HPh", "hh"], Dim::Energy, 2_684_519.537_696_17, Pre::No), + u(&["Wh", "wh"], Dim::Energy, 3_600.0, Pre::Si(1)), + u(&["flb"], Dim::Energy, 1.355_817_948_331_4, Pre::No), + u(&["BTU", "btu"], Dim::Energy, 1_055.055_852_62, Pre::No), + // Power (watt) + u(&["W", "w"], Dim::Power, 1.0, Pre::Si(1)), + u(&["HP", "h"], Dim::Power, 745.699_871_582_270_2, Pre::No), + u(&["PS"], Dim::Power, 735.498_75, Pre::No), + // Magnetism (tesla) + u(&["T"], Dim::Magnetism, 1.0, Pre::Si(1)), + u(&["ga"], Dim::Magnetism, 1e-4, Pre::Si(1)), + // Temperature (handled specially; factor unused) + u(&["C", "cel"], Dim::Temperature, 1.0, Pre::No), + u(&["F", "fah"], Dim::Temperature, 1.0, Pre::No), + u(&["K", "kel"], Dim::Temperature, 1.0, Pre::Si(1)), + u(&["Rank"], Dim::Temperature, 1.0, Pre::No), + u(&["Reau"], Dim::Temperature, 1.0, Pre::No), + // Volume (cubic metre) + u(&["l", "L", "lt"], Dim::Volume, 0.001, Pre::Si(1)), + u(&["tsp"], Dim::Volume, GAL / 768.0, Pre::No), + u(&["tspm"], Dim::Volume, 5e-6, Pre::No), + u(&["tbs"], Dim::Volume, GAL / 256.0, Pre::No), + u(&["oz"], Dim::Volume, GAL / 128.0, Pre::No), + u(&["cup"], Dim::Volume, GAL / 16.0, Pre::No), + u(&["pt", "us_pt"], Dim::Volume, GAL / 8.0, Pre::No), + u(&["uk_pt"], Dim::Volume, 0.000_568_261_25, Pre::No), + u(&["qt"], Dim::Volume, GAL / 4.0, Pre::No), + u(&["uk_qt"], Dim::Volume, 0.001_136_522_5, Pre::No), + u(&["gal"], Dim::Volume, GAL, Pre::No), + u(&["uk_gal"], Dim::Volume, 0.004_546_09, Pre::No), + u(&["m3"], Dim::Volume, 1.0, Pre::Si(3)), + u(&["mi3"], Dim::Volume, MI * MI * MI, Pre::No), + u(&["Nmi3"], Dim::Volume, NMI * NMI * NMI, Pre::No), + u(&["yd3"], Dim::Volume, YD * YD * YD, Pre::No), + u(&["ft3"], Dim::Volume, FT * FT * FT, Pre::No), + u(&["in3"], Dim::Volume, IN * IN * IN, Pre::No), + u(&["ang3"], Dim::Volume, 1e-30, Pre::Si(3)), + u(&["ly3"], Dim::Volume, LY * LY * LY, Pre::No), + u(&["Picapt3", "Pica3"], Dim::Volume, PICA_PT * PICA_PT * PICA_PT, Pre::No), + u(&["barrel"], Dim::Volume, 0.158_987_294_928, Pre::No), + u(&["bushel"], Dim::Volume, 0.035_239_070_166_88, Pre::No), + u(&["MTON"], Dim::Volume, 1.132_673_863_68, Pre::No), + u(&["GRT", "regton"], Dim::Volume, 2.831_684_659_2, Pre::No), + // Area (square metre) + u(&["m2"], Dim::Area, 1.0, Pre::Si(2)), + u(&["ha"], Dim::Area, 10_000.0, Pre::No), + u(&["ar"], Dim::Area, 100.0, Pre::Si(1)), + u(&["ft2"], Dim::Area, FT * FT, Pre::No), + u(&["in2"], Dim::Area, IN * IN, Pre::No), + u(&["yd2"], Dim::Area, YD * YD, Pre::No), + u(&["mi2"], Dim::Area, MI * MI, Pre::No), + u(&["Nmi2"], Dim::Area, NMI * NMI, Pre::No), + u(&["ang2"], Dim::Area, 1e-20, Pre::Si(2)), + u(&["ly2"], Dim::Area, LY * LY, Pre::No), + u(&["Picapt2", "Pica2"], Dim::Area, PICA_PT * PICA_PT, Pre::No), + u(&["uk_acre"], Dim::Area, 4_046.856_422_4, Pre::No), + u(&["us_acre"], Dim::Area, 4_046.872_609_874_252, Pre::No), + u(&["Morgen"], Dim::Area, 2_500.0, Pre::No), + // Information (bit) + u(&["bit"], Dim::Information, 1.0, Pre::Bin), + u(&["byte"], Dim::Information, 8.0, Pre::Bin), + // Speed (metres per second) + u(&["m/s", "m/sec"], Dim::Speed, 1.0, Pre::Si(1)), + u(&["m/h", "m/hr"], Dim::Speed, 1.0 / 3600.0, Pre::Si(1)), + u(&["mph"], Dim::Speed, MI / 3600.0, Pre::No), + u(&["kn"], Dim::Speed, NMI / 3600.0, Pre::No), + u(&["admkn"], Dim::Speed, 6080.0 * FT / 3600.0, Pre::No), +]; + +const SI_PREFIXES: &[(&str, f64)] = &[ + ("da", 1e1), + ("Y", 1e24), + ("Z", 1e21), + ("E", 1e18), + ("P", 1e15), + ("T", 1e12), + ("G", 1e9), + ("M", 1e6), + ("k", 1e3), + ("h", 1e2), + ("e", 1e1), + ("d", 1e-1), + ("c", 1e-2), + ("m", 1e-3), + ("u", 1e-6), + ("n", 1e-9), + ("p", 1e-12), + ("f", 1e-15), + ("a", 1e-18), + ("z", 1e-21), + ("y", 1e-24), +]; + +const BIN_PREFIXES: &[(&str, f64)] = &[ + ("ki", 1024.0), + ("Mi", 1_048_576.0), + ("Gi", 1_073_741_824.0), + ("Ti", 1_099_511_627_776.0), + ("Pi", 1_125_899_906_842_624.0), + ("Ei", 1_152_921_504_606_846_976.0), + ("Zi", 1_180_591_620_717_411_303_424.0), + ("Yi", 1_208_925_819_614_629_174_706_176.0), +]; + +fn find_unit(name: &str) -> Option<&'static Unit> { + UNITS.iter().find(|u| u.names.contains(&name)) +} + +/// Resolves a unit name to (unit, prefix multiplier). Exact names win over prefixed ones. +fn resolve(name: &str) -> Option<(&'static Unit, f64)> { + let name = name.replace("^2", "2").replace("^3", "3"); + if let Some(u) = find_unit(&name) { + return Some((u, 1.0)); + } + for (p, f) in BIN_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + && u.pre == Pre::Bin + { + return Some((u, *f)); + } + } + for (p, f) in SI_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + { + match u.pre { + Pre::Si(pow) => return Some((u, f.powi(pow))), + Pre::Bin => return Some((u, *f)), + Pre::No => {} + } + } + } + None +} + +fn to_kelvin(name: &str, x: f64) -> f64 { + match name { + "C" | "cel" => x + 273.15, + "F" | "fah" => (x - 32.0) * 5.0 / 9.0 + 273.15, + "Rank" => x * 5.0 / 9.0, + "Reau" => x * 1.25 + 273.15, + _ => x, + } +} + +fn from_kelvin(name: &str, k: f64) -> f64 { + match name { + "C" | "cel" => k - 273.15, + "F" | "fah" => (k - 273.15) * 9.0 / 5.0 + 32.0, + "Rank" => k * 9.0 / 5.0, + "Reau" => (k - 273.15) / 1.25, + _ => k, + } +} + +pub(super) fn convert(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let from = text(a, 1)?; + let to = text(a, 2)?; + let (fu, fp) = resolve(&from).ok_or(CellError::NA)?; + let (tu, tp) = resolve(&to).ok_or(CellError::NA)?; + if fu.dim != tu.dim { + return Err(CellError::NA); + } + if fu.dim == Dim::Temperature { + let fname = fu.names.first().copied().unwrap_or("K"); + let tname = tu.names.first().copied().unwrap_or("K"); + if fname == tname && fp == tp { + return num_val(x); + } + let k = to_kelvin(fname, x * fp); + return num_val(from_kelvin(tname, k) / tp); + } + let base = x * fu.factor * fp; + num_val(base / (tu.factor * tp)) +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 55c570c06..b569c1617 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -9,1318 +9,26 @@ use gridcraft_core::{CellError, DateSystem, Value}; use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits}; use crate::{Arg, Ctx, FnSpec, VAR}; -// --------------------------------------------------------------------------------------------- -// Time value of money -// --------------------------------------------------------------------------------------------- +mod day_count; +mod depreciation; +mod securities; +mod tvm; -/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from -/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to -/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan -/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a -/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. -fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(pv + fv) / nper; - } - if rate <= -1.0 { - return f64::NAN; - } - let l = nper * rate.ln_1p(); - let m = l.exp_m1(); - let k = 1.0 + rate * typ; - if m > 0.0 { - // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. - // rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow. - -(pv * rate + (pv + fv) * (rate / m)) / k - } else if m > -0.5 { - // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. - -(rate * (pv + fv + pv * m)) / (k * m) - } else { - // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. - -(rate * (fv + pv * l.exp())) / (k * m) - } -} - -fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(pv + pmt * nper); - } - let p = (1.0 + rate).powf(nper); - -(pv * p + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) -} - -fn pv_calc(rate: f64, nper: f64, pmt: f64, fv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(fv + pmt * nper); - } - let p = (1.0 + rate).powf(nper); - -(fv + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) / p -} +use day_count::{coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd}; +use depreciation::{amordegrc, amorlinc, db, ddb, sln, syd, vdb}; +use securities::{ + accrint, accrintm, disc, duration, intrate, mduration, oddfprice, oddfyield, oddlprice, oddlyield, price, pricedisc, pricemat, received, tbilleq, + tbillprice, tbillyield, yield_, yielddisc, yieldmat, +}; +use tvm::{ + cumipmt, cumprinc, dollarde, dollarfr, effect, fv, fvschedule, ipmt, irr, ispmt, mirr, nominal, nper, npv, pduration, pmt, ppmt, pv, rate, rri, + xirr, xnpv, +}; fn type_arg(args: &[Arg], i: usize) -> R { Ok(if opt_num(args, i, 0.0)? != 0.0 { 1.0 } else { 0.0 }) } -fn pmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); - if nper == 0.0 { - return Err(CellError::Num); - } - num_val(pmt_calc(rate, nper, pv, fv, typ)) -} - -fn fv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - num_val(fv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) -} - -fn pv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - num_val(pv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) -} - -fn nper(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); - if rate == 0.0 { - if pmt == 0.0 { - return Err(CellError::Num); - } - return num_val(-(pv + fv) / pmt); - } - let numer = pmt * (1.0 + rate * typ) - fv * rate; - let denom = pv * rate + pmt * (1.0 + rate * typ); - if denom == 0.0 { - return Err(CellError::Num); - } - let q = numer / denom; - if q <= 0.0 || rate <= -1.0 { - return Err(CellError::Num); - } - num_val(q.ln() / (1.0 + rate).ln()) -} - -fn ipmt_calc(rate: f64, per: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { - let p = pmt_calc(rate, nper, pv, fv, typ); - if typ == 1.0 && per == 1.0 { - return 0.0; - } - let bal = fv_calc(rate, per - 1.0, p, pv, typ); - let i = bal * rate; - if typ == 1.0 { i / (1.0 + rate) } else { i } -} - -fn ipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); - if per < 1.0 || per > np || np == 0.0 { - return Err(CellError::Num); - } - num_val(ipmt_calc(rate, per, np, pv, fv, typ)) -} - -fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); - if per < 1.0 || per > np || np == 0.0 { - return Err(CellError::Num); - } - num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) -} - -fn cumulative(a: &[Arg], principal: bool) -> R { - let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); - let np_i = np.trunc(); - if rate <= 0.0 || np_i <= 0.0 || pv <= 0.0 || start < 1.0 || end < start || end > np_i || (typ != 0.0 && typ != 1.0) { - return Err(CellError::Num); - } - if end - start > 1e7 || np_i > 1e15 { - return Err(CellError::Num); - } - let p = pmt_calc(rate, np_i, pv, 0.0, typ); - let mut sum = 0.0; - let mut per = start; - while per <= end { - let i = ipmt_calc(rate, per, np_i, pv, 0.0, typ); - sum += if principal { p - i } else { i }; - per += 1.0; - } - num_val(sum) -} - -fn cumipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - cumulative(a, false) -} - -fn cumprinc(a: &[Arg], _c: &mut dyn Ctx) -> R { - cumulative(a, true) -} - -/// Newton iteration with a numeric derivative, keeping the rate above -1. -fn newton(f: impl Fn(f64) -> f64, guess: f64) -> Option { - let mut x = guess; - for _ in 0..200 { - let y = f(x); - if !y.is_finite() { - return None; - } - let h = 1e-6 * x.abs().max(1e-3); - let d = (f(x + h) - f(x - h)) / (2.0 * h); - if d == 0.0 || !d.is_finite() { - return None; - } - let mut nx = x - y / d; - if nx <= -1.0 { - nx = (x - 1.0) / 2.0; - if nx <= -1.0 { - nx = -0.999_999_999; - } - } - if (nx - x).abs() < 1e-12 * nx.abs().max(1.0) { - return Some(nx); - } - x = nx; - } - None -} - -/// Tries Newton from the guess and a few fallback starting points; then bisection over a -/// bracket if one can be found. -fn solve_rate(f: impl Fn(f64) -> f64, guess: f64) -> R { - let check = |r: f64| f(r).is_finite() && f(r).abs() < 1e-7 * (1.0 + f(guess).abs().min(1e12)); - for g in [guess, 0.1, 0.01, 0.5, -0.5, 1.0, 0.001, 5.0, -0.9, 10.0] { - if let Some(r) = newton(&f, g) - && r > -1.0 - && check(r) - { - return Ok(r); - } - } - // Scan for a sign change and bisect. - let grid: Vec = [-0.999_999, -0.99, -0.9, -0.5, -0.2, 0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 100.0, 1000.0].to_vec(); - for w in grid.windows(2) { - let (mut lo, mut hi) = match w { - [a, b] => (*a, *b), - _ => continue, - }; - let (flo, fhi) = (f(lo), f(hi)); - if !(flo.is_finite() && fhi.is_finite()) || flo * fhi > 0.0 { - continue; - } - let lo_neg = flo < 0.0; - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - let fm = f(mid); - if (fm < 0.0) == lo_neg { - lo = mid; - } else { - hi = mid; - } - } - return Ok(0.5 * (lo + hi)); - } - Err(CellError::Num) -} - -fn rate(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (np, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ, guess) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?, opt_num(a, 5, 0.1)?); - if np <= 0.0 { - return Err(CellError::Num); - } - let f = |r: f64| { - if r.abs() < 1e-12 { - pv + pmt * np + fv - } else { - let p = (1.0 + r).powf(np); - pv * p + pmt * (1.0 + r * typ) * (p - 1.0) / r + fv - } - }; - num_val(solve_rate(f, guess)?) -} - -fn npv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let rate = num(a, 0)?; - if rate == -1.0 { - return Err(CellError::Div0); - } - let vals = numbers(a.get(1..).unwrap_or(&[]))?; - let mut sum = 0.0; - let mut d = 1.0; - for v in vals { - d *= 1.0 + rate; - sum += v / d; - } - num_val(sum) -} - -/// Values and dates for XNPV/XIRR: both must be numeric and of equal length. -fn dated_flows(values: &Value, dates: &Value) -> R<(Vec, Vec)> { - let va = as_array(values); - let da = as_array(dates); - if va.data.len() != da.data.len() { - return Err(CellError::Num); - } - let mut v = Vec::with_capacity(va.data.len()); - let mut d = Vec::with_capacity(da.data.len()); - for (x, y) in va.data.iter().zip(da.data.iter()) { - match x { - Value::Number(n) => v.push(*n), - Value::Error(e) => return Err(*e), - _ => return Err(CellError::Value), - } - match y { - Value::Number(n) => d.push(n.trunc()), - Value::Error(e) => return Err(*e), - Value::Text(_) => d.push(y.to_number()?.trunc()), - _ => return Err(CellError::Value), - } - } - let d0 = *d.first().ok_or(CellError::Num)?; - if d.iter().any(|&x| x < d0 || x < 0.0) { - return Err(CellError::Num); - } - Ok((v, d)) -} - -fn xnpv_calc(rate: f64, v: &[f64], d: &[f64]) -> f64 { - let d0 = d.first().copied().unwrap_or(0.0); - v.iter().zip(d).map(|(x, t)| x / (1.0 + rate).powf((t - d0) / 365.0)).sum() -} - -fn xnpv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let rate = num(a, 0)?; - if rate <= -1.0 { - return Err(CellError::Num); - } - let (v, d) = dated_flows(&a.get(1).ok_or(CellError::Value)?.value, &a.get(2).ok_or(CellError::Value)?.value)?; - num_val(xnpv_calc(rate, &v, &d)) -} - -fn xirr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (v, d) = dated_flows(&a.first().ok_or(CellError::Value)?.value, &a.get(1).ok_or(CellError::Value)?.value)?; - let guess = opt_num(a, 2, 0.1)?; - if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { - return Err(CellError::Num); - } - num_val(solve_rate(|r| xnpv_calc(r, &v, &d), guess)?) -} - -fn irr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; - let guess = opt_num(a, 1, 0.1)?; - if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { - return Err(CellError::Num); - } - let f = |r: f64| { - let mut s = 0.0; - let mut d = 1.0; - for x in &v { - s += x / d; - d *= 1.0 + r; - } - s - }; - num_val(solve_rate(f, guess)?) -} - -fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; - let (fr, rr) = (num(a, 1)?, num(a, 2)?); - let n = v.len() as f64; - if v.len() < 2 { - return Err(CellError::Div0); - } - let (mut pos, mut neg) = (0.0, 0.0); - for (i, x) in v.iter().enumerate() { - if *x > 0.0 { - pos += x / (1.0 + rr).powi(i as i32); - } else if *x < 0.0 { - neg += x / (1.0 + fr).powi(i as i32); - } - } - // No positive flow is not an error in Excel: the formula below gives -1. - if neg == 0.0 { - return Err(CellError::Div0); - } - let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; - num_val(r) -} - -fn fvschedule(a: &[Arg], _c: &mut dyn Ctx) -> R { - let mut p = num(a, 0)?; - for v in flatten(a.get(1..2).unwrap_or(&[])) { - let r = match v { - Value::Number(n) => n, - Value::Empty => 0.0, - Value::Error(e) => return Err(e), - _ => return Err(CellError::Value), - }; - p *= 1.0 + r; - } - num_val(p) -} - -fn effect(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (nom, n) = (num(a, 0)?, num(a, 1)?.trunc()); - if nom <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val((1.0 + nom / n).powf(n) - 1.0) -} - -fn nominal(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (eff, n) = (num(a, 0)?, num(a, 1)?.trunc()); - if eff <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val(n * ((1.0 + eff).powf(1.0 / n) - 1.0)) -} - -fn ispmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - if np == 0.0 { - return Err(CellError::Div0); - } - num_val(pv * rate * (per / np - 1.0)) -} - -fn pduration(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 { - return Err(CellError::Num); - } - num_val((fv.ln() - pv.ln()) / (1.0 + rate).ln()) -} - -fn rri(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (np, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if np <= 0.0 || pv == 0.0 { - return Err(CellError::Num); - } - num_val((fv / pv).powf(1.0 / np) - 1.0) -} - -fn dollar_digits(fraction: f64) -> R { - if fraction < 0.0 { - return Err(CellError::Num); - } - if fraction < 1.0 { - return Err(CellError::Div0); - } - Ok(10f64.powf(fraction.log10().ceil())) -} - -fn dollarde(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); - let p = dollar_digits(f)?; - let int = x.trunc(); - num_val(int + (x - int) * p / f) -} - -fn dollarfr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); - let p = dollar_digits(f)?; - let int = x.trunc(); - num_val(int + (x - int) * f / p) -} - -// --------------------------------------------------------------------------------------------- -// Depreciation -// --------------------------------------------------------------------------------------------- - -fn sln(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if life == 0.0 { - return Err(CellError::Div0); - } - num_val((cost - salvage) / life) -} - -fn syd(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, per) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - if life <= 0.0 || per <= 0.0 || per > life || salvage < 0.0 { - return Err(CellError::Num); - } - num_val((cost - salvage) * (life - per + 1.0) * 2.0 / (life * (life + 1.0))) -} - -fn db(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?.trunc()); - let month = opt_num(a, 4, 12.0)?.trunc(); - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) { - return Err(CellError::Num); - } - let max_period = if month == 12.0 { life.trunc() } else { life.trunc() + 1.0 }; - if period > max_period.max(1.0) || period > 1e7 { - return Err(CellError::Num); - } - if cost == 0.0 { - return num_val(0.0); - } - let rate = crate::util::round_half_away(1.0 - (salvage / cost).powf(1.0 / life), 3); - let mut total = 0.0; - let mut dep = 0.0; - let mut p = 1.0; - while p <= period { - dep = if p == 1.0 { - cost * rate * month / 12.0 - } else if p == life.trunc() + 1.0 { - (cost - total) * rate * (12.0 - month) / 12.0 - } else { - (cost - total) * rate - }; - total += dep; - p += 1.0; - } - num_val(dep) -} - -fn ddb(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let factor = opt_num(a, 4, 2.0)?; - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 || period > life { - return Err(CellError::Num); - } - let mut rate = factor / life; - let old; - if rate >= 1.0 { - rate = 1.0; - old = if period == 1.0 { cost } else { 0.0 }; - } else { - old = cost * (1.0 - rate).powf(period - 1.0); - } - let new = cost * (1.0 - rate).powf(period); - let dep = if new < salvage { old - salvage } else { old - new }; - num_val(dep.max(0.0)) -} - -/// Per-period declining-balance depreciation with optional switch to straight line. -fn vdb_schedule(cost: f64, salvage: f64, life: f64, factor: f64, no_switch: bool, periods: usize) -> Vec { - let mut out = Vec::with_capacity(periods); - let mut book = cost; - let mut sl: Option = None; - for i in 0..periods { - let dep = if let Some(s) = sl { - s.min((book - salvage).max(0.0)) - } else { - let ddb = (book * factor / life).min((book - salvage).max(0.0)); - let remaining = life - i as f64; - let slv = if remaining > 0.0 { (book - salvage) / remaining.max(1.0) } else { 0.0 }; - if !no_switch && slv > ddb { - sl = Some(slv); - slv.min((book - salvage).max(0.0)) - } else { - ddb - } - }; - out.push(dep); - book -= dep; - } - out -} - -fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, start, end) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let factor = opt_num(a, 5, 2.0)?; - let no_switch = opt_bool(a, 6, false)?; - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || start < 0.0 || end < start || end > life || factor <= 0.0 { - return Err(CellError::Num); - } - let periods = end.ceil(); - if periods > 1e7 { - return Err(CellError::Num); - } - let sched = vdb_schedule(cost, salvage, life, factor, no_switch, periods as usize); - let mut total = 0.0; - for (i, d) in sched.iter().enumerate() { - let (p0, p1) = (i as f64, i as f64 + 1.0); - let overlap = (p1.min(end) - p0.max(start)).max(0.0); - total += d * overlap; - } - num_val(total) -} - -fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { - return Err(CellError::Num); - } - let full = cost * rate; - let limit = cost - salvage; - let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit); - if period == 0.0 { - return num_val(dep0); - } - let remaining = limit - dep0 - (period - 1.0) * full; - num_val(full.min(remaining.max(0.0))) -} - -fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { - return Err(CellError::Num); - } - let life = 1.0 / rate; - let coef = if life < 3.0 { - 1.0 - } else if life < 5.0 { - 1.5 - } else if life <= 6.0 { - 2.0 - } else { - 2.5 - }; - let r = rate * coef; - let mut book = cost; - let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round(); - if period == 0.0 { - return num_val(dep); - } - book -= dep; - let mut rest = book - salvage; - let mut n = 1.0; - while n <= period { - dep = (r * book).round(); - rest -= dep; - if rest < 0.0 { - // The two final periods split what remains; afterwards nothing is left. - let left = period - n; - return num_val(if left <= 1.0 { (book * 0.5).round() } else { 0.0 }); - } - book -= dep; - n += 1.0; - } - num_val(dep) -} - -// --------------------------------------------------------------------------------------------- -// Dates and day counts -// --------------------------------------------------------------------------------------------- - -fn date(a: &[Arg], i: usize) -> R { - let d = num(a, i)?.trunc(); - if !(0.0..=2_958_465.0).contains(&d) { - return Err(CellError::Num); - } - Ok(d) -} - -fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> { - let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?; - Ok((dt.year, dt.month, dt.day.max(1))) -} - -fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R { - serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num) -} - -fn basis_arg(a: &[Arg], i: usize) -> R { - let b = opt_num(a, i, 0.0)?.trunc(); - if !(0.0..=4.0).contains(&b) { - return Err(CellError::Num); - } - Ok(b as u32) -} - -fn freq_arg(a: &[Arg], i: usize) -> R { - let f = num(a, i)?.trunc(); - if f != 1.0 && f != 2.0 && f != 4.0 { - return Err(CellError::Num); - } - Ok(f) -} - -/// 30/360 day count (US/NASD when `us`, European otherwise). -fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R { - let (y1, m1, mut d1) = ymd(sys, s1)?; - let (y2, m2, mut d2) = ymd(sys, s2)?; - if us { - let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); - let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); - if feb_end1 && feb_end2 { - d2 = 30; - } - if feb_end1 { - d1 = 30; - } - if d2 == 31 && d1 >= 30 { - d2 = 30; - } - if d1 == 31 { - d1 = 30; - } - } else { - d1 = d1.min(30); - d2 = d2.min(30); - } - Ok((y2 - y1) as f64 * 360.0 + (m2 as f64 - m1 as f64) * 30.0 + (d2 as f64 - d1 as f64)) -} - -/// Days between two dates under a basis. -fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { - match basis { - 0 => days360(sys, s1, s2, true), - 4 => days360(sys, s1, s2, false), - _ => Ok(s2 - s1), - } -} - -/// Year fraction between two dates under a basis (YEARFRAC semantics). -fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { - let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; - match basis { - 0 => Ok(days360(sys, s1, s2, true)? / 360.0), - 2 => Ok((s2 - s1) / 360.0), - 3 => Ok((s2 - s1) / 365.0), - 4 => Ok(days360(sys, s1, s2, false)? / 360.0), - _ => { - let (y1, m1, d1) = ymd(sys, s1)?; - let (y2, m2, d2) = ymd(sys, s2)?; - let days = s2 - s1; - if y1 == y2 { - return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); - } - let within_year = y2 == y1 + 1 && (m2 < m1 || (m2 == m1 && d2 <= d1)); - if within_year { - let feb29_1 = is_leap(y1) && (m1 < 2 || (m1 == 2 && d1 <= 29)); - let feb29_2 = is_leap(y2) && (m2 > 2 || (m2 == 2 && d2 == 29)); - let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; - return Ok(days / denom); - } - let start = serial(sys, y1, 1, 1)?; - let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0); - let avg = (end - start) / (y2 - y1 + 1) as f64; - Ok(days / avg) - } - } -} - -/// Days in a year for discount securities. -fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R { - Ok(match basis { - 0 | 2 | 4 => 360.0, - 3 => 365.0, - _ => { - if is_leap(ymd(sys, s)?.0) { - 366.0 - } else { - 365.0 - } - } - }) -} - -/// Date `months` months before/after an anchor date, keeping end-of-month alignment. -fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R { - let (y, m, d) = anchor; - let total = y as i64 * 12 + (m as i64 - 1) + months; - let ny = total.div_euclid(12); - let nm = (total.rem_euclid(12) + 1) as u32; - if !(1900..=9999).contains(&ny) { - return Err(CellError::Num); - } - let dim = days_in_month(ny as i32, nm); - let nd = if eom { dim } else { d.min(dim) }; - serial(sys, ny as i32, nm, nd) -} - -struct Coupon { - pcd: f64, - ncd: f64, - num: f64, -} - -fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R { - if settle >= mat { - return Err(CellError::Num); - } - let anchor = ymd(sys, mat)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - // Estimate the count from the span, then adjust. - let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); - while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle { - k -= 1; - } - let mut guard = 0; - while shift_months(sys, anchor, -k * step, eom)? > settle { - k += 1; - guard += 1; - if guard > 100_000 { - return Err(CellError::Num); - } - } - Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 }) -} - -fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { - match basis { - 1 => c.ncd - c.pcd, - 3 => 365.0 / freq, - _ => 360.0 / freq, - } -} - -fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R { - day_count(sys, c.pcd, settle, basis) -} - -fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { - match basis { - 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?), - _ => Ok(c.ncd - settle), - } -} - -/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. -fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> { - let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); - let c = coupons(sys, s, m, f)?; - Ok((s, m, f, b, c)) -} - -fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (s, _, _, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_daybs(cx.date_system(), &c, s, b)?) -} - -fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (_, _, f, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_days(&c, f, b)) -} - -fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (s, _, f, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?) -} - -fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.ncd) -} - -fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.pcd) -} - -fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.num) -} - -// --------------------------------------------------------------------------------------------- -// Securities -// --------------------------------------------------------------------------------------------- - -fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R { - let sys = c.date_system(); - let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; - let freq = freq_arg(a, 5)?; - let basis = basis_arg(a, 6)?; - let calc_method = opt_bool(a, 7, true)?; - if issue >= settle || rate <= 0.0 || par <= 0.0 { - return Err(CellError::Num); - } - let anchor = ymd(sys, first)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let cd = |j: i64| shift_months(sys, anchor, j * step, eom); - // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). - let index_of = |d: f64| -> R { - let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); - let mut guard = 0; - while cd(j)? > d { - j -= 1; - guard += 1; - if guard > 400_000 { - return Err(CellError::Num); - } - } - while cd(j + 1)? <= d { - j += 1; - guard += 1; - if guard > 400_000 { - return Err(CellError::Num); - } - } - Ok(j) - }; - let start = if calc_method || settle <= first { issue } else { cd(index_of(settle)?)? }; - let mut j = index_of(start)?; - let mut sum = 0.0; - let mut guard = 0; - loop { - let (p0, p1) = (cd(j)?, cd(j + 1)?); - if p0 >= settle { - break; - } - let from = p0.max(start); - let to = p1.min(settle); - if to > from { - let nl = match basis { - 1 => p1 - p0, - 3 => 365.0 / freq, - _ => 360.0 / freq, - }; - sum += day_count(sys, from, to, basis)? / nl; - } - j += 1; - guard += 1; - if guard > 100_000 { - return Err(CellError::Num); - } - } - num_val(par * rate / freq * sum) -} - -fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { - let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); - let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; - let basis = basis_arg(a, 4)?; - if issue >= settle || rate <= 0.0 || par <= 0.0 { - return Err(CellError::Num); - } - num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) -} - -/// settlement, maturity, value, value, [basis] for discount securities. -fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { - let (s, m, x, y, b) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, basis_arg(a, 4)?); - if s >= m || x <= 0.0 || y <= 0.0 { - return Err(CellError::Num); - } - Ok((s, m, x, y, b)) -} - -fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R { - Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?) -} - -fn disc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, pr, red, b) = disc_args(a)?; - num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?) -} - -fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, d, red, b) = disc_args(a)?; - num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?) -} - -fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, pr, red, b) = disc_args(a)?; - num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?) -} - -fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, inv, red, b) = disc_args(a)?; - num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?) -} - -fn received(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, inv, d, b) = disc_args(a)?; - let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?; - if denom <= 0.0 { - return Err(CellError::Num); - } - num_val(inv / denom) -} - -fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> { - let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); - if s >= m || x <= 0.0 { - return Err(CellError::Num); - } - // At most one year after settlement. - let (y, mo, d) = ymd(sys, s)?; - let limit = shift_months(sys, (y, mo, d), 12, false)?; - if m > limit { - return Err(CellError::Num); - } - Ok((m - s, x)) -} - -fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a, c.date_system())?; - let p = 100.0 * (1.0 - d * dsm / 360.0); - if p <= 0.0 { - return Err(CellError::Num); - } - num_val(p) -} - -fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, pr) = tbill_args(a, c.date_system())?; - num_val((100.0 - pr) / pr * 360.0 / dsm) -} - -fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a, c.date_system())?; - if dsm <= 182.0 { - return num_val(365.0 * d / (360.0 - d * dsm)); - } - let price = 100.0 * (1.0 - d * dsm / 360.0); - if price <= 0.0 { - return Err(CellError::Num); - } - let t = dsm / 365.0; - let disc = t * t - (2.0 * t - 1.0) * (1.0 - 100.0 / price); - if disc < 0.0 { - return Err(CellError::Num); - } - num_val((-t + disc.sqrt()) / (t - 0.5)) -} - -/// settlement, maturity, issue, rate, value, [basis]. -fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> { - let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); - if s >= m || issue >= s || rate < 0.0 || x < 0.0 { - return Err(CellError::Num); - } - let dim = year_frac(sys, issue, m, b)?; - let dsm = year_frac(sys, s, m, b)?; - let ai = year_frac(sys, issue, s, b)?; - Ok((dim, dsm, ai, rate, x)) -} - -fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?; - num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) -} - -fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?; - if pr <= 0.0 || dsm == 0.0 { - return Err(CellError::Num); - } - let base = pr / 100.0 + ai * rate; - num_val(((1.0 + dim * rate) - base) / base / dsm) -} - -struct Bond { - n: f64, - e: f64, - a: f64, - dsc: f64, - freq: f64, -} - -fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R { - let c = coupons(sys, settle, mat, freq)?; - let e = coup_days(&c, freq, basis); - let a = coup_daybs(sys, &c, settle, basis)?; - let dsc = coup_daysnc(sys, &c, settle, freq, basis)?; - if e <= 0.0 { - return Err(CellError::Num); - } - Ok(Bond { n: c.num, e, a, dsc, freq }) -} - -fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { - let f = b.freq; - let coupon = 100.0 * rate / f; - let accrued = coupon * b.a / b.e; - if b.n == 1.0 { - return (red + coupon) / (1.0 + b.dsc / b.e * yld / f) - accrued; - } - let base = 1.0 + yld / f; - let frac = b.dsc / b.e; - let mut sum = red / base.powf(b.n - 1.0 + frac); - let mut k = 1.0; - while k <= b.n { - sum += coupon / base.powf(k - 1.0 + frac); - k += 1.0; - } - sum - accrued -} - -fn price(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); - if rate < 0.0 || yld < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - let b = bond(c.date_system(), s, m, f, basis)?; - num_val(bond_price(&b, rate, yld, red)) -} - -fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); - if rate < 0.0 || pr <= 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - let b = bond(c.date_system(), s, m, f, basis)?; - if b.n == 1.0 { - let base = pr / 100.0 + b.a / b.e * rate / f; - return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); - } - // Price is decreasing in yield: bisect on [0, hi]. - let g = |y: f64| bond_price(&b, rate, y, red) - pr; - let (mut lo, mut hi) = (-0.999_999 * f, 1.0); - let mut guard = 0; - while g(hi) > 0.0 && guard < 60 { - hi *= 2.0; - guard += 1; - } - if !(g(lo) >= 0.0 && g(hi) <= 0.0) { - return Err(CellError::Num); - } - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - if g(mid) > 0.0 { - lo = mid; - } else { - hi = mid; - } - } - num_val(0.5 * (lo + hi)) -} - -fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> { - let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); - let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); - if coupon < 0.0 || yld < 0.0 { - return Err(CellError::Num); - } - let b = bond(sys, s, m, f, basis)?; - let c = coupon * 100.0 / f; - let y = 1.0 + yld / f; - let frac = b.dsc / b.e; - let (mut wsum, mut psum) = (0.0, 0.0); - let mut k = 1.0; - while k <= b.n { - let t = k - 1.0 + frac; - let cf = if k == b.n { c + 100.0 } else { c }; - let pv = cf / y.powf(t); - wsum += t * pv; - psum += pv; - k += 1.0; - } - if psum == 0.0 { - return Err(CellError::Num); - } - Ok((wsum / psum / f, yld, f)) -} - -fn duration(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(duration_calc(a, c.date_system())?.0) -} - -fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R { - let (d, y, f) = duration_calc(a, c.date_system())?; - num_val(d / (1.0 + y / f)) -} - -// --------------------------------------------------------------------------------------------- -// Odd first and last coupon periods -// --------------------------------------------------------------------------------------------- -// -// An odd period is split into quasi-coupon periods: the regular coupon periods the security -// would have had, counted back from the first coupon (ODDF*) or forward from the last interest -// date (ODDL*). For quasi-period i, NL is its normal length, DC the days of it inside the odd -// period, A the accrued days of it before settlement and DSC the days of it from settlement on. - -/// Most quasi-coupon periods an odd period may span (the date range allows ~32,400 quarters). -const MAX_QUASI: i64 = 40_000; - -/// Normal length of the coupon period `[start, end]` under a basis (COUPDAYS semantics). -fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 { - match basis { - 1 => end - start, - 3 => 365.0 / freq, - _ => 360.0 / freq, - } -} - -/// Days of `[from, to]` under a basis, or 0 when the span is empty. -fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R { - if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) } -} - -/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share. -struct OddFirst { - /// Coupons after the first coupon up to maturity. - n: f64, - /// Whole quasi-coupon periods between settlement and the first coupon. - nq: f64, - /// DSC / NL of the quasi-period holding settlement. - frac: f64, - /// Σ DCi / NLi over the odd period. - dc: f64, - /// Σ Ai / NLi over the odd period. - acc: f64, - freq: f64, -} - -fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { - if !(mat > first && first > settle && settle > issue) { - return Err(CellError::Num); - } - let anchor = ymd(sys, first)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom); - let (mut dc, mut acc) = (0.0, 0.0); - let (mut nq, mut frac) = (None, 0.0); - // Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k). - let mut k = 0; - loop { - if k >= MAX_QUASI { - return Err(CellError::Num); - } - let (end, start) = (quasi(k)?, quasi(k + 1)?); - let nl = normal_length(start, end, freq, basis); - if nl <= 0.0 { - return Err(CellError::Num); - } - let from = start.max(issue); - dc += span(sys, from, end, basis)? / nl; - acc += span(sys, from, settle.min(end), basis)? / nl; - if start <= settle && settle < end { - nq = Some(k as f64); - frac = span(sys, settle, end, basis)? / nl; - } - if start <= issue { - break; - } - k += 1; - } - let nq = nq.ok_or(CellError::Num)?; - let n = coupons(sys, first, mat, freq)?.num; - Ok(OddFirst { n, nq, frac, dc, acc, freq }) -} - -fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 { - let coupon = 100.0 * rate / t.freq; - let base = 1.0 + yld / t.freq; - let lead = t.nq + t.frac; - let mut p = red / base.powf(t.n + lead) + coupon * t.dc / base.powf(lead); - let mut k = 1.0; - while k <= t.n { - p += coupon / base.powf(k + lead); - k += 1.0; - } - p - coupon * t.acc -} - -/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis]. -fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> { - let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?); - let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?); - let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?); - if rate < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red)) -} - -fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let (t, rate, yld, red) = odd_first_args(a, c.date_system())?; - if yld < 0.0 { - return Err(CellError::Num); - } - num_val(odd_first_price(&t, rate, yld, red)) -} - -fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let (t, rate, pr, red) = odd_first_args(a, c.date_system())?; - if pr <= 0.0 { - return Err(CellError::Num); - } - // The price falls as the yield rises: bracket the root, then bisect. - let g = |y: f64| odd_first_price(&t, rate, y, red) - pr; - let (mut lo, mut hi) = (-0.999_999 * t.freq, 1.0); - let mut guard = 0; - while g(hi) > 0.0 && guard < 60 { - hi *= 2.0; - guard += 1; - } - // NaN fails both comparisons. - if !(g(lo) >= 0.0 && g(hi) <= 0.0) { - return Err(CellError::Num); - } - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - if g(mid) > 0.0 { - lo = mid; - } else { - hi = mid; - } - } - num_val(0.5 * (lo + hi)) -} - -/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period. -fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { - if !(mat > settle && settle > last) { - return Err(CellError::Num); - } - let anchor = ymd(sys, last)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0); - // Walk forward from the last interest date: period k runs from quasi(k - 1) to quasi(k). - let mut start = last; - let mut k = 1; - loop { - if k > MAX_QUASI { - return Err(CellError::Num); - } - let end = shift_months(sys, anchor, k * step, eom)?; - let nl = normal_length(start, end, freq, basis); - if nl <= 0.0 { - return Err(CellError::Num); - } - let stop = end.min(mat); - dc += span(sys, start, stop, basis)? / nl; - acc += span(sys, start, settle.min(stop), basis)? / nl; - dsc += span(sys, settle.max(start), stop, basis)? / nl; - if end >= mat { - break; - } - start = end; - k += 1; - } - Ok((dc, acc, dsc)) -} - -/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. -fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> { - let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); - let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); - let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); - if rate < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f)) -} - -fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?; - if yld < 0.0 { - return Err(CellError::Num); - } - let coupon = 100.0 * rate / f; - num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon) -} - -fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?; - if pr <= 0.0 { - return Err(CellError::Num); - } - let coupon = 100.0 * rate / f; - let paid = pr + acc * coupon; - if paid <= 0.0 || dsc <= 0.0 { - return Err(CellError::Num); - } - num_val((red + dc * coupon - paid) / paid * f / dsc) -} - // --------------------------------------------------------------------------------------------- // Euro conversion // --------------------------------------------------------------------------------------------- @@ -1774,327 +482,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use crate::Arg; - use crate::util::testutil::*; - use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; - - fn d(y: i64, m: i64, dd: i64) -> Arg { - n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) - } - - #[test] - fn tvm() { - close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); - close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); - close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); - is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); - close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); - close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); - close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); - close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); - close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); - close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); - is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); - close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); - close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); - close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); - is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); - is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); - } - - /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across - /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. - #[test] - fn pmt_matches_excel_within_a_few_ulps() { - for (rate, nper, pv, fv, typ, excel) in [ - (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), - (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), - (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), - (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), - (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), - (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), - ] { - let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); - close_tol(got, excel, 1e-15); - } - } - - /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a - /// tiny rate no longer loses its low digits in 1 + rate. - #[test] - fn pmt_edge_cases_follow_excel() { - is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); - assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); - assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); - assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); - close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); - close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); - close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); - close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); - close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); - close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); - close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); - } - - #[test] - fn cash_flows() { - close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); - close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); - is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); - let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); - close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); - is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); - let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); - let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); - close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); - close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); - is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); - let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); - close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); - close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); - is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); - // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. - close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); - close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); - close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); - is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); - close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); - close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); - is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); - close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); - is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); - close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); - close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); - close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); - close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); - close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); - is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); - is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); - } - - #[test] - fn depreciation() { - close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); - is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); - let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); - close_tol(dbv(1.0), 186083.3333, 1e-9); - close_tol(dbv(2.0), 259639.4167, 1e-9); - close_tol(dbv(3.0), 176814.4428, 1e-9); - close_tol(dbv(7.0), 15845.09847, 1e-9); - is_err(dbv(8.0), CellError::Num); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); - close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); - is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); - // Over the whole life the total depreciation is cost - salvage. - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); - is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); - close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); - close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); - } - - #[test] - fn coupons() { - let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); - close(args("COUPDAYBS"), 71.0); - close(args("COUPDAYS"), 181.0); - close(args("COUPDAYSNC"), 110.0); - close(args("COUPNCD"), 40678.0); - close(args("COUPPCD"), 40497.0); - close(args("COUPNUM"), 2.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); - close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); - is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); - // End-of-month maturity keeps month-end coupon dates. - close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); - close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); - } - - #[test] - fn securities() { - close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); - close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); - close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); - close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); - close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); - is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); - close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); - close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); - close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); - is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); - close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); - is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); - close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); - close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); - close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); - close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); - close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); - is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); - close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); - close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); - // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. - let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); - let pv = match p { - Value::Number(x) => x, - _ => panic!(), - }; - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); - } - - fn number(v: Value) -> f64 { - match v { - Value::Number(x) => x, - other => panic!("expected a number, got {other:?}"), - } - } - - #[test] - fn odd_periods() { - // The worked examples on Microsoft's function pages (results as printed there). - let first = |x: f64, rest: &[f64]| { - let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; - a.extend(rest.iter().map(|v| n(*v))); - a - }; - close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); - close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); - let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); - let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); - - // Yield → price → yield, for a short and a long odd first period and a long odd last period. - for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { - let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; - let p = number(ev("ODDFPRICE", args(0.05))); - close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); - } - let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDLPRICE", last(0.07))); - close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); - - // Argument checks: frequency, basis, date order, negative yield, non-positive price. - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); - is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); - is_err( - ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), - CellError::Num, - ); - is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); - is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); - is_err( - ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), - CellError::Value, - ); - } - - #[test] - fn odd_periods_survive_hostile_input() { - // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or - // an error, never a panic or a hang. - let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { - let _ = ev("ODDFPRICE", wide(x, y)); - let _ = ev("ODDFYIELD", wide(x, y)); - } - // The long walk itself works: settled early in an odd first period of ~32,000 quarters, - // a small yield round-trips. - let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDFPRICE", long(0.0001))); - close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); - let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { - let _ = ev("ODDLPRICE", last(x, y)); - let _ = ev("ODDLYIELD", last(x, y)); - } - is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); - } - - #[test] - fn euroconvert() { - // The examples on Microsoft's EUROCONVERT page. - let ec = |x: f64, s: &str, tg: &str, rest: Vec| { - let mut a = vec![n(x), t(s), t(tg)]; - a.extend(rest); - ev("EUROCONVERT", a) - }; - close(ec(1.2, "DEM", "EUR", vec![]), 0.61); - close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); - close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); - // Euro to a member currency rounds to the target's calculation precision (lira: 0). - close(ec(10.0, "eur", "ITL", vec![]), 19363.0); - close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); - // Same currency returns the number untouched. - close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); - // Unknown codes and a triangulation precision below 3 are #VALUE!. - is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); - is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); - // Hostile magnitudes give a number or #NUM!, never a panic. - is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); - } - - #[test] - fn lifting() { - let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); - assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); - is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); - is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); - } - - #[test] - fn securities_read_dates_in_the_1904_system() { - let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); - let ev4 = |name: &str, args: Vec| { - let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; - crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) - }; - let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); - close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); - close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); - close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); - let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; - close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); - // Results that already matched Excel keep matching. - let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); - close(semi("COUPPCD"), 39050.0); - close(semi("COUPDAYS"), 180.0); - close(semi("COUPDAYBS"), 105.0); - close(semi("COUPDAYSNC"), 75.0); - close(semi("COUPNUM"), 2.0); - let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; - close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); - close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); - let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; - close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); - } -} +mod tests; diff --git a/crates/functions/src/financial/day_count.rs b/crates/functions/src/financial/day_count.rs new file mode 100644 index 000000000..a42156e17 --- /dev/null +++ b/crates/functions/src/financial/day_count.rs @@ -0,0 +1,216 @@ +use super::{ + Arg, CellError, Ctx, DateSystem, R, Value, datetime_from_serial, days_in_month, is_leap, max_serial, num, num_val, opt_num, serial_from_ymd, +}; + +// --------------------------------------------------------------------------------------------- +// Dates and day counts +// --------------------------------------------------------------------------------------------- + +pub(super) fn date(a: &[Arg], i: usize) -> R { + let d = num(a, i)?.trunc(); + if !(0.0..=2_958_465.0).contains(&d) { + return Err(CellError::Num); + } + Ok(d) +} + +pub(super) fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> { + let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?; + Ok((dt.year, dt.month, dt.day.max(1))) +} + +fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R { + serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num) +} + +pub(super) fn basis_arg(a: &[Arg], i: usize) -> R { + let b = opt_num(a, i, 0.0)?.trunc(); + if !(0.0..=4.0).contains(&b) { + return Err(CellError::Num); + } + Ok(b as u32) +} + +pub(super) fn freq_arg(a: &[Arg], i: usize) -> R { + let f = num(a, i)?.trunc(); + if f != 1.0 && f != 2.0 && f != 4.0 { + return Err(CellError::Num); + } + Ok(f) +} + +/// 30/360 day count (US/NASD when `us`, European otherwise). +fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R { + let (y1, m1, mut d1) = ymd(sys, s1)?; + let (y2, m2, mut d2) = ymd(sys, s2)?; + if us { + let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); + let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); + if feb_end1 && feb_end2 { + d2 = 30; + } + if feb_end1 { + d1 = 30; + } + if d2 == 31 && d1 >= 30 { + d2 = 30; + } + if d1 == 31 { + d1 = 30; + } + } else { + d1 = d1.min(30); + d2 = d2.min(30); + } + Ok((y2 - y1) as f64 * 360.0 + (m2 as f64 - m1 as f64) * 30.0 + (d2 as f64 - d1 as f64)) +} + +/// Days between two dates under a basis. +pub(super) fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { + match basis { + 0 => days360(sys, s1, s2, true), + 4 => days360(sys, s1, s2, false), + _ => Ok(s2 - s1), + } +} + +/// Year fraction between two dates under a basis (YEARFRAC semantics). +pub(super) fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { + let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; + match basis { + 0 => Ok(days360(sys, s1, s2, true)? / 360.0), + 2 => Ok((s2 - s1) / 360.0), + 3 => Ok((s2 - s1) / 365.0), + 4 => Ok(days360(sys, s1, s2, false)? / 360.0), + _ => { + let (y1, m1, d1) = ymd(sys, s1)?; + let (y2, m2, d2) = ymd(sys, s2)?; + let days = s2 - s1; + if y1 == y2 { + return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); + } + let within_year = y2 == y1 + 1 && (m2 < m1 || (m2 == m1 && d2 <= d1)); + if within_year { + let feb29_1 = is_leap(y1) && (m1 < 2 || (m1 == 2 && d1 <= 29)); + let feb29_2 = is_leap(y2) && (m2 > 2 || (m2 == 2 && d2 == 29)); + let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; + return Ok(days / denom); + } + let start = serial(sys, y1, 1, 1)?; + let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0); + let avg = (end - start) / (y2 - y1 + 1) as f64; + Ok(days / avg) + } + } +} + +/// Days in a year for discount securities. +pub(super) fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R { + Ok(match basis { + 0 | 2 | 4 => 360.0, + 3 => 365.0, + _ => { + if is_leap(ymd(sys, s)?.0) { + 366.0 + } else { + 365.0 + } + } + }) +} + +/// Date `months` months before/after an anchor date, keeping end-of-month alignment. +pub(super) fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R { + let (y, m, d) = anchor; + let total = y as i64 * 12 + (m as i64 - 1) + months; + let ny = total.div_euclid(12); + let nm = (total.rem_euclid(12) + 1) as u32; + if !(1900..=9999).contains(&ny) { + return Err(CellError::Num); + } + let dim = days_in_month(ny as i32, nm); + let nd = if eom { dim } else { d.min(dim) }; + serial(sys, ny as i32, nm, nd) +} + +pub(super) struct Coupon { + pcd: f64, + ncd: f64, + pub(super) num: f64, +} + +pub(super) fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R { + if settle >= mat { + return Err(CellError::Num); + } + let anchor = ymd(sys, mat)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + // Estimate the count from the span, then adjust. + let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); + while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle { + k -= 1; + } + let mut guard = 0; + while shift_months(sys, anchor, -k * step, eom)? > settle { + k += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 }) +} + +pub(super) fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { + match basis { + 1 => c.ncd - c.pcd, + 3 => 365.0 / freq, + _ => 360.0 / freq, + } +} + +pub(super) fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R { + day_count(sys, c.pcd, settle, basis) +} + +pub(super) fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { + match basis { + 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?), + _ => Ok(c.ncd - settle), + } +} + +/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. +fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> { + let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); + let c = coupons(sys, s, m, f)?; + Ok((s, m, f, b, c)) +} + +pub(super) fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, _, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daybs(cx.date_system(), &c, s, b)?) +} + +pub(super) fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (_, _, f, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_days(&c, f, b)) +} + +pub(super) fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, f, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?) +} + +pub(super) fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.ncd) +} + +pub(super) fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.pcd) +} + +pub(super) fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.num) +} diff --git a/crates/functions/src/financial/depreciation.rs b/crates/functions/src/financial/depreciation.rs new file mode 100644 index 000000000..e7b456c69 --- /dev/null +++ b/crates/functions/src/financial/depreciation.rs @@ -0,0 +1,173 @@ +use super::day_count::{basis_arg, date, year_frac}; +use super::{Arg, CellError, Ctx, R, Value, num, num_val, opt_bool, opt_num}; + +// --------------------------------------------------------------------------------------------- +// Depreciation +// --------------------------------------------------------------------------------------------- + +pub(super) fn sln(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if life == 0.0 { + return Err(CellError::Div0); + } + num_val((cost - salvage) / life) +} + +pub(super) fn syd(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, per) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if life <= 0.0 || per <= 0.0 || per > life || salvage < 0.0 { + return Err(CellError::Num); + } + num_val((cost - salvage) * (life - per + 1.0) * 2.0 / (life * (life + 1.0))) +} + +pub(super) fn db(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?.trunc()); + let month = opt_num(a, 4, 12.0)?.trunc(); + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) { + return Err(CellError::Num); + } + let max_period = if month == 12.0 { life.trunc() } else { life.trunc() + 1.0 }; + if period > max_period.max(1.0) || period > 1e7 { + return Err(CellError::Num); + } + if cost == 0.0 { + return num_val(0.0); + } + let rate = crate::util::round_half_away(1.0 - (salvage / cost).powf(1.0 / life), 3); + let mut total = 0.0; + let mut dep = 0.0; + let mut p = 1.0; + while p <= period { + dep = if p == 1.0 { + cost * rate * month / 12.0 + } else if p == life.trunc() + 1.0 { + (cost - total) * rate * (12.0 - month) / 12.0 + } else { + (cost - total) * rate + }; + total += dep; + p += 1.0; + } + num_val(dep) +} + +pub(super) fn ddb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let factor = opt_num(a, 4, 2.0)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 || period > life { + return Err(CellError::Num); + } + let mut rate = factor / life; + let old; + if rate >= 1.0 { + rate = 1.0; + old = if period == 1.0 { cost } else { 0.0 }; + } else { + old = cost * (1.0 - rate).powf(period - 1.0); + } + let new = cost * (1.0 - rate).powf(period); + let dep = if new < salvage { old - salvage } else { old - new }; + num_val(dep.max(0.0)) +} + +/// Per-period declining-balance depreciation with optional switch to straight line. +fn vdb_schedule(cost: f64, salvage: f64, life: f64, factor: f64, no_switch: bool, periods: usize) -> Vec { + let mut out = Vec::with_capacity(periods); + let mut book = cost; + let mut sl: Option = None; + for i in 0..periods { + let dep = if let Some(s) = sl { + s.min((book - salvage).max(0.0)) + } else { + let ddb = (book * factor / life).min((book - salvage).max(0.0)); + let remaining = life - i as f64; + let slv = if remaining > 0.0 { (book - salvage) / remaining.max(1.0) } else { 0.0 }; + if !no_switch && slv > ddb { + sl = Some(slv); + slv.min((book - salvage).max(0.0)) + } else { + ddb + } + }; + out.push(dep); + book -= dep; + } + out +} + +pub(super) fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, start, end) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let factor = opt_num(a, 5, 2.0)?; + let no_switch = opt_bool(a, 6, false)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || start < 0.0 || end < start || end > life || factor <= 0.0 { + return Err(CellError::Num); + } + let periods = end.ceil(); + if periods > 1e7 { + return Err(CellError::Num); + } + let sched = vdb_schedule(cost, salvage, life, factor, no_switch, periods as usize); + let mut total = 0.0; + for (i, d) in sched.iter().enumerate() { + let (p0, p1) = (i as f64, i as f64 + 1.0); + let overlap = (p1.min(end) - p0.max(start)).max(0.0); + total += d * overlap; + } + num_val(total) +} + +pub(super) fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { + return Err(CellError::Num); + } + let full = cost * rate; + let limit = cost - salvage; + let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit); + if period == 0.0 { + return num_val(dep0); + } + let remaining = limit - dep0 - (period - 1.0) * full; + num_val(full.min(remaining.max(0.0))) +} + +pub(super) fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { + return Err(CellError::Num); + } + let life = 1.0 / rate; + let coef = if life < 3.0 { + 1.0 + } else if life < 5.0 { + 1.5 + } else if life <= 6.0 { + 2.0 + } else { + 2.5 + }; + let r = rate * coef; + let mut book = cost; + let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round(); + if period == 0.0 { + return num_val(dep); + } + book -= dep; + let mut rest = book - salvage; + let mut n = 1.0; + while n <= period { + dep = (r * book).round(); + rest -= dep; + if rest < 0.0 { + // The two final periods split what remains; afterwards nothing is left. + let left = period - n; + return num_val(if left <= 1.0 { (book * 0.5).round() } else { 0.0 }); + } + book -= dep; + n += 1.0; + } + num_val(dep) +} diff --git a/crates/functions/src/financial/securities.rs b/crates/functions/src/financial/securities.rs new file mode 100644 index 000000000..ea696c8a6 --- /dev/null +++ b/crates/functions/src/financial/securities.rs @@ -0,0 +1,515 @@ +use super::day_count::{basis_arg, coup_daybs, coup_days, coup_daysnc, coupons, date, day_count, freq_arg, shift_months, year_basis, year_frac, ymd}; +use super::{Arg, CellError, Ctx, DateSystem, R, Value, days_in_month, has, num, num_val, opt_bool}; + +// --------------------------------------------------------------------------------------------- +// Securities +// --------------------------------------------------------------------------------------------- + +pub(super) fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; + let freq = freq_arg(a, 5)?; + let basis = basis_arg(a, 6)?; + let calc_method = opt_bool(a, 7, true)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + let anchor = ymd(sys, first)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let cd = |j: i64| shift_months(sys, anchor, j * step, eom); + // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). + let index_of = |d: f64| -> R { + let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); + let mut guard = 0; + while cd(j)? > d { + j -= 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + while cd(j + 1)? <= d { + j += 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + Ok(j) + }; + let start = if calc_method || settle <= first { issue } else { cd(index_of(settle)?)? }; + let mut j = index_of(start)?; + let mut sum = 0.0; + let mut guard = 0; + loop { + let (p0, p1) = (cd(j)?, cd(j + 1)?); + if p0 >= settle { + break; + } + let from = p0.max(start); + let to = p1.min(settle); + if to > from { + let nl = match basis { + 1 => p1 - p0, + 3 => 365.0 / freq, + _ => 360.0 / freq, + }; + sum += day_count(sys, from, to, basis)? / nl; + } + j += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + num_val(par * rate / freq * sum) +} + +pub(super) fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { + let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; + let basis = basis_arg(a, 4)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) +} + +/// settlement, maturity, value, value, [basis] for discount securities. +fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { + let (s, m, x, y, b) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, basis_arg(a, 4)?); + if s >= m || x <= 0.0 || y <= 0.0 { + return Err(CellError::Num); + } + Ok((s, m, x, y, b)) +} + +fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R { + Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?) +} + +pub(super) fn disc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, d, red, b) = disc_args(a)?; + num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, inv, red, b) = disc_args(a)?; + num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn received(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, inv, d, b) = disc_args(a)?; + let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?; + if denom <= 0.0 { + return Err(CellError::Num); + } + num_val(inv / denom) +} + +fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> { + let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + if s >= m || x <= 0.0 { + return Err(CellError::Num); + } + // At most one year after settlement. + let (y, mo, d) = ymd(sys, s)?; + let limit = shift_months(sys, (y, mo, d), 12, false)?; + if m > limit { + return Err(CellError::Num); + } + Ok((m - s, x)) +} + +pub(super) fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; + let p = 100.0 * (1.0 - d * dsm / 360.0); + if p <= 0.0 { + return Err(CellError::Num); + } + num_val(p) +} + +pub(super) fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, pr) = tbill_args(a, c.date_system())?; + num_val((100.0 - pr) / pr * 360.0 / dsm) +} + +pub(super) fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; + if dsm <= 182.0 { + return num_val(365.0 * d / (360.0 - d * dsm)); + } + let price = 100.0 * (1.0 - d * dsm / 360.0); + if price <= 0.0 { + return Err(CellError::Num); + } + let t = dsm / 365.0; + let disc = t * t - (2.0 * t - 1.0) * (1.0 - 100.0 / price); + if disc < 0.0 { + return Err(CellError::Num); + } + num_val((-t + disc.sqrt()) / (t - 0.5)) +} + +/// settlement, maturity, issue, rate, value, [basis]. +fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> { + let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); + if s >= m || issue >= s || rate < 0.0 || x < 0.0 { + return Err(CellError::Num); + } + let dim = year_frac(sys, issue, m, b)?; + let dsm = year_frac(sys, s, m, b)?; + let ai = year_frac(sys, issue, s, b)?; + Ok((dim, dsm, ai, rate, x)) +} + +pub(super) fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?; + num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) +} + +pub(super) fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?; + if pr <= 0.0 || dsm == 0.0 { + return Err(CellError::Num); + } + let base = pr / 100.0 + ai * rate; + num_val(((1.0 + dim * rate) - base) / base / dsm) +} + +struct Bond { + n: f64, + e: f64, + a: f64, + dsc: f64, + freq: f64, +} + +fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R { + let c = coupons(sys, settle, mat, freq)?; + let e = coup_days(&c, freq, basis); + let a = coup_daybs(sys, &c, settle, basis)?; + let dsc = coup_daysnc(sys, &c, settle, freq, basis)?; + if e <= 0.0 { + return Err(CellError::Num); + } + Ok(Bond { n: c.num, e, a, dsc, freq }) +} + +fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { + let f = b.freq; + let coupon = 100.0 * rate / f; + let accrued = coupon * b.a / b.e; + if b.n == 1.0 { + return (red + coupon) / (1.0 + b.dsc / b.e * yld / f) - accrued; + } + let base = 1.0 + yld / f; + let frac = b.dsc / b.e; + let mut sum = red / base.powf(b.n - 1.0 + frac); + let mut k = 1.0; + while k <= b.n { + sum += coupon / base.powf(k - 1.0 + frac); + k += 1.0; + } + sum - accrued +} + +pub(super) fn price(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || yld < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(c.date_system(), s, m, f, basis)?; + num_val(bond_price(&b, rate, yld, red)) +} + +pub(super) fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || pr <= 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(c.date_system(), s, m, f, basis)?; + if b.n == 1.0 { + let base = pr / 100.0 + b.a / b.e * rate / f; + return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); + } + // Price is decreasing in yield: bisect on [0, hi]. + let g = |y: f64| bond_price(&b, rate, y, red) - pr; + let (mut lo, mut hi) = (-0.999_999 * f, 1.0); + let mut guard = 0; + while g(hi) > 0.0 && guard < 60 { + hi *= 2.0; + guard += 1; + } + if !(g(lo) >= 0.0 && g(hi) <= 0.0) { + return Err(CellError::Num); + } + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if g(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + num_val(0.5 * (lo + hi)) +} + +fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> { + let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); + let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); + if coupon < 0.0 || yld < 0.0 { + return Err(CellError::Num); + } + let b = bond(sys, s, m, f, basis)?; + let c = coupon * 100.0 / f; + let y = 1.0 + yld / f; + let frac = b.dsc / b.e; + let (mut wsum, mut psum) = (0.0, 0.0); + let mut k = 1.0; + while k <= b.n { + let t = k - 1.0 + frac; + let cf = if k == b.n { c + 100.0 } else { c }; + let pv = cf / y.powf(t); + wsum += t * pv; + psum += pv; + k += 1.0; + } + if psum == 0.0 { + return Err(CellError::Num); + } + Ok((wsum / psum / f, yld, f)) +} + +pub(super) fn duration(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(duration_calc(a, c.date_system())?.0) +} + +pub(super) fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R { + let (d, y, f) = duration_calc(a, c.date_system())?; + num_val(d / (1.0 + y / f)) +} + +// --------------------------------------------------------------------------------------------- +// Odd first and last coupon periods +// --------------------------------------------------------------------------------------------- +// +// An odd period is split into quasi-coupon periods: the regular coupon periods the security +// would have had, counted back from the first coupon (ODDF*) or forward from the last interest +// date (ODDL*). For quasi-period i, NL is its normal length, DC the days of it inside the odd +// period, A the accrued days of it before settlement and DSC the days of it from settlement on. + +/// Most quasi-coupon periods an odd period may span (the date range allows ~32,400 quarters). +const MAX_QUASI: i64 = 40_000; + +/// Normal length of the coupon period `[start, end]` under a basis (COUPDAYS semantics). +fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 { + match basis { + 1 => end - start, + 3 => 365.0 / freq, + _ => 360.0 / freq, + } +} + +/// Days of `[from, to]` under a basis, or 0 when the span is empty. +fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R { + if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) } +} + +/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share. +struct OddFirst { + /// Coupons after the first coupon up to maturity. + n: f64, + /// Whole quasi-coupon periods between settlement and the first coupon. + nq: f64, + /// DSC / NL of the quasi-period holding settlement. + frac: f64, + /// Σ DCi / NLi over the odd period. + dc: f64, + /// Σ Ai / NLi over the odd period. + acc: f64, + freq: f64, +} + +fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { + if !(mat > first && first > settle && settle > issue) { + return Err(CellError::Num); + } + let anchor = ymd(sys, first)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom); + let (mut dc, mut acc) = (0.0, 0.0); + let (mut nq, mut frac) = (None, 0.0); + // Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k). + let mut k = 0; + loop { + if k >= MAX_QUASI { + return Err(CellError::Num); + } + let (end, start) = (quasi(k)?, quasi(k + 1)?); + let nl = normal_length(start, end, freq, basis); + if nl <= 0.0 { + return Err(CellError::Num); + } + let from = start.max(issue); + dc += span(sys, from, end, basis)? / nl; + acc += span(sys, from, settle.min(end), basis)? / nl; + if start <= settle && settle < end { + nq = Some(k as f64); + frac = span(sys, settle, end, basis)? / nl; + } + if start <= issue { + break; + } + k += 1; + } + let nq = nq.ok_or(CellError::Num)?; + let n = coupons(sys, first, mat, freq)?.num; + Ok(OddFirst { n, nq, frac, dc, acc, freq }) +} + +fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 { + let coupon = 100.0 * rate / t.freq; + let base = 1.0 + yld / t.freq; + let lead = t.nq + t.frac; + let mut p = red / base.powf(t.n + lead) + coupon * t.dc / base.powf(lead); + let mut k = 1.0; + while k <= t.n { + p += coupon / base.powf(k + lead); + k += 1.0; + } + p - coupon * t.acc +} + +/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis]. +fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> { + let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?); + let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?); + let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?); + if rate < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red)) +} + +pub(super) fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, yld, red) = odd_first_args(a, c.date_system())?; + if yld < 0.0 { + return Err(CellError::Num); + } + num_val(odd_first_price(&t, rate, yld, red)) +} + +pub(super) fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, pr, red) = odd_first_args(a, c.date_system())?; + if pr <= 0.0 { + return Err(CellError::Num); + } + // The price falls as the yield rises: bracket the root, then bisect. + let g = |y: f64| odd_first_price(&t, rate, y, red) - pr; + let (mut lo, mut hi) = (-0.999_999 * t.freq, 1.0); + let mut guard = 0; + while g(hi) > 0.0 && guard < 60 { + hi *= 2.0; + guard += 1; + } + // NaN fails both comparisons. + if !(g(lo) >= 0.0 && g(hi) <= 0.0) { + return Err(CellError::Num); + } + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if g(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + num_val(0.5 * (lo + hi)) +} + +/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period. +fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { + if !(mat > settle && settle > last) { + return Err(CellError::Num); + } + let anchor = ymd(sys, last)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0); + // Walk forward from the last interest date: period k runs from quasi(k - 1) to quasi(k). + let mut start = last; + let mut k = 1; + loop { + if k > MAX_QUASI { + return Err(CellError::Num); + } + let end = shift_months(sys, anchor, k * step, eom)?; + let nl = normal_length(start, end, freq, basis); + if nl <= 0.0 { + return Err(CellError::Num); + } + let stop = end.min(mat); + dc += span(sys, start, stop, basis)? / nl; + acc += span(sys, start, settle.min(stop), basis)? / nl; + dsc += span(sys, settle.max(start), stop, basis)? / nl; + if end >= mat { + break; + } + start = end; + k += 1; + } + Ok((dc, acc, dsc)) +} + +/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. +/// `((dc, acc, dsc), rate, yield-or-price, redemption, frequency)` of an odd-last-period call. +type OddLastArgs = ((f64, f64, f64), f64, f64, f64, f64); + +fn odd_last_args(a: &[Arg], sys: DateSystem) -> R { + let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); + let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); + let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); + if rate < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f)) +} + +pub(super) fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?; + if yld < 0.0 { + return Err(CellError::Num); + } + let coupon = 100.0 * rate / f; + num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon) +} + +pub(super) fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?; + if pr <= 0.0 { + return Err(CellError::Num); + } + let coupon = 100.0 * rate / f; + let paid = pr + acc * coupon; + if paid <= 0.0 || dsc <= 0.0 { + return Err(CellError::Num); + } + num_val((red + dc * coupon - paid) / paid * f / dsc) +} diff --git a/crates/functions/src/financial/tests.rs b/crates/functions/src/financial/tests.rs new file mode 100644 index 000000000..2788da94a --- /dev/null +++ b/crates/functions/src/financial/tests.rs @@ -0,0 +1,319 @@ +use crate::Arg; +use crate::util::testutil::*; +use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; + +fn d(y: i64, m: i64, dd: i64) -> Arg { + n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) +} + +#[test] +fn tvm() { + close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); + close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); + close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); + is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); + close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); + close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); + close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); + close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); + close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); + close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); + is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); + close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); + close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); + close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); + is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); + is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); +} + +/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across +/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. +#[test] +fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } +} + +/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a +/// tiny rate no longer loses its low digits in 1 + rate. +#[test] +fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); +} + +#[test] +fn cash_flows() { + close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); + close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); + is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); + let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); + close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); + is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); + let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); + let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); + close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); + close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); + is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); + let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); + close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); + close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); + is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); + close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); + close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); + is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); + close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); + close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); + is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); + is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); + close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); + close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); + close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); + close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); + close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); + is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); + is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); +} + +#[test] +fn depreciation() { + close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); + is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); + let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); + close_tol(dbv(1.0), 186083.3333, 1e-9); + close_tol(dbv(2.0), 259639.4167, 1e-9); + close_tol(dbv(3.0), 176814.4428, 1e-9); + close_tol(dbv(7.0), 15845.09847, 1e-9); + is_err(dbv(8.0), CellError::Num); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); + close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); + is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); + // Over the whole life the total depreciation is cost - salvage. + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); + is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); + close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); + close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); +} + +#[test] +fn coupons() { + let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); + close(args("COUPDAYBS"), 71.0); + close(args("COUPDAYS"), 181.0); + close(args("COUPDAYSNC"), 110.0); + close(args("COUPNCD"), 40678.0); + close(args("COUPPCD"), 40497.0); + close(args("COUPNUM"), 2.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); + close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); + is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); + // End-of-month maturity keeps month-end coupon dates. + close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); + close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); +} + +#[test] +fn securities() { + close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); + close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); + close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); + close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); + close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); + is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); + close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); + close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); + close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); + is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); + close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); + is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); + close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); + close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); + close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); + close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); + close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); + is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); + close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); + close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); + // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. + let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); + let pv = match p { + Value::Number(x) => x, + _ => panic!(), + }; + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); +} + +fn number(v: Value) -> f64 { + match v { + Value::Number(x) => x, + other => panic!("expected a number, got {other:?}"), + } +} + +#[test] +fn odd_periods() { + // The worked examples on Microsoft's function pages (results as printed there). + let first = |x: f64, rest: &[f64]| { + let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; + a.extend(rest.iter().map(|v| n(*v))); + a + }; + close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); + close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); + let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); + let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); + + // Yield → price → yield, for a short and a long odd first period and a long odd last period. + for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { + let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; + let p = number(ev("ODDFPRICE", args(0.05))); + close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); + } + let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDLPRICE", last(0.07))); + close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); + + // Argument checks: frequency, basis, date order, negative yield, non-positive price. + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); + is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); + is_err(ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); + is_err( + ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), + CellError::Value, + ); +} + +#[test] +fn odd_periods_survive_hostile_input() { + // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or + // an error, never a panic or a hang. + let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { + let _ = ev("ODDFPRICE", wide(x, y)); + let _ = ev("ODDFYIELD", wide(x, y)); + } + // The long walk itself works: settled early in an odd first period of ~32,000 quarters, + // a small yield round-trips. + let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDFPRICE", long(0.0001))); + close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); + let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { + let _ = ev("ODDLPRICE", last(x, y)); + let _ = ev("ODDLYIELD", last(x, y)); + } + is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); +} + +#[test] +fn euroconvert() { + // The examples on Microsoft's EUROCONVERT page. + let ec = |x: f64, s: &str, tg: &str, rest: Vec| { + let mut a = vec![n(x), t(s), t(tg)]; + a.extend(rest); + ev("EUROCONVERT", a) + }; + close(ec(1.2, "DEM", "EUR", vec![]), 0.61); + close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); + close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); + // Euro to a member currency rounds to the target's calculation precision (lira: 0). + close(ec(10.0, "eur", "ITL", vec![]), 19363.0); + close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); + // Same currency returns the number untouched. + close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); + // Unknown codes and a triangulation precision below 3 are #VALUE!. + is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); + is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); + // Hostile magnitudes give a number or #NUM!, never a panic. + is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); +} + +#[test] +fn lifting() { + let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); + assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); + is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); + is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); +} + +#[test] +fn securities_read_dates_in_the_1904_system() { + let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); + let ev4 = |name: &str, args: Vec| { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) + }; + let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); + close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); + close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); + close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); + let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; + close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); + // Results that already matched Excel keep matching. + let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); + close(semi("COUPPCD"), 39050.0); + close(semi("COUPDAYS"), 180.0); + close(semi("COUPDAYBS"), 105.0); + close(semi("COUPDAYSNC"), 75.0); + close(semi("COUPNUM"), 2.0); + let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; + close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); + close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); + let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; + close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); +} diff --git a/crates/functions/src/financial/tvm.rs b/crates/functions/src/financial/tvm.rs new file mode 100644 index 000000000..61710f0bd --- /dev/null +++ b/crates/functions/src/financial/tvm.rs @@ -0,0 +1,415 @@ +use super::{Arg, CellError, Ctx, R, Value, array_numbers, as_array, flatten, num, num_val, numbers, opt_num, type_arg}; + +// --------------------------------------------------------------------------------------------- +// Time value of money +// --------------------------------------------------------------------------------------------- + +/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from +/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to +/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan +/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a +/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. +fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + fv) / nper; + } + if rate <= -1.0 { + return f64::NAN; + } + let l = nper * rate.ln_1p(); + let m = l.exp_m1(); + let k = 1.0 + rate * typ; + if m > 0.0 { + // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. + // rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow. + -(pv * rate + (pv + fv) * (rate / m)) / k + } else if m > -0.5 { + // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. + -(rate * (pv + fv + pv * m)) / (k * m) + } else { + // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. + -(rate * (fv + pv * l.exp())) / (k * m) + } +} + +fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(pv * p + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) +} + +fn pv_calc(rate: f64, nper: f64, pmt: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(fv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(fv + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) / p +} + +pub(super) fn pmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if nper == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, nper, pv, fv, typ)) +} + +pub(super) fn fv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(fv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +pub(super) fn pv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(pv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +pub(super) fn nper(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if rate == 0.0 { + if pmt == 0.0 { + return Err(CellError::Num); + } + return num_val(-(pv + fv) / pmt); + } + let numer = pmt * (1.0 + rate * typ) - fv * rate; + let denom = pv * rate + pmt * (1.0 + rate * typ); + if denom == 0.0 { + return Err(CellError::Num); + } + let q = numer / denom; + if q <= 0.0 || rate <= -1.0 { + return Err(CellError::Num); + } + num_val(q.ln() / (1.0 + rate).ln()) +} + +fn ipmt_calc(rate: f64, per: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + let p = pmt_calc(rate, nper, pv, fv, typ); + if typ == 1.0 && per == 1.0 { + return 0.0; + } + let bal = fv_calc(rate, per - 1.0, p, pv, typ); + let i = bal * rate; + if typ == 1.0 { i / (1.0 + rate) } else { i } +} + +pub(super) fn ipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(ipmt_calc(rate, per, np, pv, fv, typ)) +} + +pub(super) fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) +} + +fn cumulative(a: &[Arg], principal: bool) -> R { + let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); + let np_i = np.trunc(); + if rate <= 0.0 || np_i <= 0.0 || pv <= 0.0 || start < 1.0 || end < start || end > np_i || (typ != 0.0 && typ != 1.0) { + return Err(CellError::Num); + } + if end - start > 1e7 || np_i > 1e15 { + return Err(CellError::Num); + } + let p = pmt_calc(rate, np_i, pv, 0.0, typ); + let mut sum = 0.0; + let mut per = start; + while per <= end { + let i = ipmt_calc(rate, per, np_i, pv, 0.0, typ); + sum += if principal { p - i } else { i }; + per += 1.0; + } + num_val(sum) +} + +pub(super) fn cumipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, false) +} + +pub(super) fn cumprinc(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, true) +} + +/// Newton iteration with a numeric derivative, keeping the rate above -1. +fn newton(f: impl Fn(f64) -> f64, guess: f64) -> Option { + let mut x = guess; + for _ in 0..200 { + let y = f(x); + if !y.is_finite() { + return None; + } + let h = 1e-6 * x.abs().max(1e-3); + let d = (f(x + h) - f(x - h)) / (2.0 * h); + if d == 0.0 || !d.is_finite() { + return None; + } + let mut nx = x - y / d; + if nx <= -1.0 { + nx = (x - 1.0) / 2.0; + if nx <= -1.0 { + nx = -0.999_999_999; + } + } + if (nx - x).abs() < 1e-12 * nx.abs().max(1.0) { + return Some(nx); + } + x = nx; + } + None +} + +/// Tries Newton from the guess and a few fallback starting points; then bisection over a +/// bracket if one can be found. +fn solve_rate(f: impl Fn(f64) -> f64, guess: f64) -> R { + let check = |r: f64| f(r).is_finite() && f(r).abs() < 1e-7 * (1.0 + f(guess).abs().min(1e12)); + for g in [guess, 0.1, 0.01, 0.5, -0.5, 1.0, 0.001, 5.0, -0.9, 10.0] { + if let Some(r) = newton(&f, g) + && r > -1.0 + && check(r) + { + return Ok(r); + } + } + // Scan for a sign change and bisect. + let grid: Vec = [-0.999_999, -0.99, -0.9, -0.5, -0.2, 0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 100.0, 1000.0].to_vec(); + for w in grid.windows(2) { + let (mut lo, mut hi) = match w { + [a, b] => (*a, *b), + _ => continue, + }; + let (flo, fhi) = (f(lo), f(hi)); + if !(flo.is_finite() && fhi.is_finite()) || flo * fhi > 0.0 { + continue; + } + let lo_neg = flo < 0.0; + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + let fm = f(mid); + if (fm < 0.0) == lo_neg { + lo = mid; + } else { + hi = mid; + } + } + return Ok(0.5 * (lo + hi)); + } + Err(CellError::Num) +} + +pub(super) fn rate(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ, guess) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?, opt_num(a, 5, 0.1)?); + if np <= 0.0 { + return Err(CellError::Num); + } + let f = |r: f64| { + if r.abs() < 1e-12 { + pv + pmt * np + fv + } else { + let p = (1.0 + r).powf(np); + pv * p + pmt * (1.0 + r * typ) * (p - 1.0) / r + fv + } + }; + num_val(solve_rate(f, guess)?) +} + +pub(super) fn npv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate == -1.0 { + return Err(CellError::Div0); + } + let vals = numbers(a.get(1..).unwrap_or(&[]))?; + let mut sum = 0.0; + let mut d = 1.0; + for v in vals { + d *= 1.0 + rate; + sum += v / d; + } + num_val(sum) +} + +/// Values and dates for XNPV/XIRR: both must be numeric and of equal length. +fn dated_flows(values: &Value, dates: &Value) -> R<(Vec, Vec)> { + let va = as_array(values); + let da = as_array(dates); + if va.data.len() != da.data.len() { + return Err(CellError::Num); + } + let mut v = Vec::with_capacity(va.data.len()); + let mut d = Vec::with_capacity(da.data.len()); + for (x, y) in va.data.iter().zip(da.data.iter()) { + match x { + Value::Number(n) => v.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + match y { + Value::Number(n) => d.push(n.trunc()), + Value::Error(e) => return Err(*e), + Value::Text(_) => d.push(y.to_number()?.trunc()), + _ => return Err(CellError::Value), + } + } + let d0 = *d.first().ok_or(CellError::Num)?; + if d.iter().any(|&x| x < d0 || x < 0.0) { + return Err(CellError::Num); + } + Ok((v, d)) +} + +fn xnpv_calc(rate: f64, v: &[f64], d: &[f64]) -> f64 { + let d0 = d.first().copied().unwrap_or(0.0); + v.iter().zip(d).map(|(x, t)| x / (1.0 + rate).powf((t - d0) / 365.0)).sum() +} + +pub(super) fn xnpv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate <= -1.0 { + return Err(CellError::Num); + } + let (v, d) = dated_flows(&a.get(1).ok_or(CellError::Value)?.value, &a.get(2).ok_or(CellError::Value)?.value)?; + num_val(xnpv_calc(rate, &v, &d)) +} + +pub(super) fn xirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (v, d) = dated_flows(&a.first().ok_or(CellError::Value)?.value, &a.get(1).ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 2, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + num_val(solve_rate(|r| xnpv_calc(r, &v, &d), guess)?) +} + +pub(super) fn irr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 1, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + let f = |r: f64| { + let mut s = 0.0; + let mut d = 1.0; + for x in &v { + s += x / d; + d *= 1.0 + r; + } + s + }; + num_val(solve_rate(f, guess)?) +} + +pub(super) fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let (fr, rr) = (num(a, 1)?, num(a, 2)?); + let n = v.len() as f64; + if v.len() < 2 { + return Err(CellError::Div0); + } + let (mut pos, mut neg) = (0.0, 0.0); + for (i, x) in v.iter().enumerate() { + if *x > 0.0 { + pos += x / (1.0 + rr).powi(i as i32); + } else if *x < 0.0 { + neg += x / (1.0 + fr).powi(i as i32); + } + } + // No positive flow is not an error in Excel: the formula below gives -1. + if neg == 0.0 { + return Err(CellError::Div0); + } + let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; + num_val(r) +} + +pub(super) fn fvschedule(a: &[Arg], _c: &mut dyn Ctx) -> R { + let mut p = num(a, 0)?; + for v in flatten(a.get(1..2).unwrap_or(&[])) { + let r = match v { + Value::Number(n) => n, + Value::Empty => 0.0, + Value::Error(e) => return Err(e), + _ => return Err(CellError::Value), + }; + p *= 1.0 + r; + } + num_val(p) +} + +pub(super) fn effect(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (nom, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if nom <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val((1.0 + nom / n).powf(n) - 1.0) +} + +pub(super) fn nominal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (eff, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if eff <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(n * ((1.0 + eff).powf(1.0 / n) - 1.0)) +} + +pub(super) fn ispmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if np == 0.0 { + return Err(CellError::Div0); + } + num_val(pv * rate * (per / np - 1.0)) +} + +pub(super) fn pduration(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 { + return Err(CellError::Num); + } + num_val((fv.ln() - pv.ln()) / (1.0 + rate).ln()) +} + +pub(super) fn rri(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if np <= 0.0 || pv == 0.0 { + return Err(CellError::Num); + } + num_val((fv / pv).powf(1.0 / np) - 1.0) +} + +fn dollar_digits(fraction: f64) -> R { + if fraction < 0.0 { + return Err(CellError::Num); + } + if fraction < 1.0 { + return Err(CellError::Div0); + } + Ok(10f64.powf(fraction.log10().ceil())) +} + +pub(super) fn dollarde(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * p / f) +} + +pub(super) fn dollarfr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * f / p) +} diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs index 7aa2c2660..309f0323e 100644 --- a/crates/functions/src/info.rs +++ b/crates/functions/src/info.rs @@ -311,11 +311,8 @@ fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R { let v = a.get(1).map(scalar).unwrap_or(Value::Empty); return Ok(empty_to_zero(v)); } - let link = a.first().map(scalar).unwrap_or(Value::Empty); - if let Value::Error(e) = link { - return Err(e); - } - Ok(link) + // Excel returns the link as text, so HYPERLINK(1) is "1" and ISTEXT is TRUE. + Ok(Value::Text(text(a, 0)?.into())) } fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -608,6 +605,12 @@ mod tests { is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com"); is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click"); close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0); + // Excel 16: without a friendly name the result is the link as text. + is_text(ev("HYPERLINK", vec![n(1.0)]), "1"); + is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5"); + is_text(ev("HYPERLINK", vec![b(true)]), "TRUE"); + is_text(ev("HYPERLINK", vec![rf(Value::Empty)]), ""); + is_err(ev("HYPERLINK", vec![e(CellError::Div0)]), CellError::Div0); is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9"); is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value); is_err(ev("FILTERXML", vec![t(""), t("//a")]), CellError::Value); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index 056cb6d31..ea7f07fd9 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -58,6 +58,35 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { ) } +/// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n` +/// needles does `n` x (vector length) work. Their search arguments don't lift. +const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XMATCH"]; + +/// Work budget of a lifted [`SCANS`] lookup: needle cells times the length of the longest vector +/// it searches (the longest dimension of its largest non-lifted array). Each needle re-reads and +/// scans the vector (about 8 ns per entry), so without a cap a one-cell formula such as +/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file. +/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst +/// case to a few seconds and still admits 5,000 needles over a 100,000-row table. +const LIFTED_SCAN_BUDGET: usize = 500_000_000; + +/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec` +/// is one of the [`SCANS`] lookups and a non-lifted argument is an array. +fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize { + if !SCANS.contains(&spec.name) { + return 0; + } + args.iter() + .enumerate() + .filter(|(i, _)| !spec.is_scalar_arg(*i)) + .filter_map(|(_, a)| match &a.value { + Value::Array(arr) => Some(arr.rows.max(arr.cols)), + _ => None, + }) + .max() + .unwrap_or(0) +} + pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) }; let args = rejected.as_deref().unwrap_or(args); @@ -76,7 +105,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val if !any { return (spec.imp)(args, ctx); } - if rows.saturating_mul(cols) > MAX_CELLS { + let needles = rows.saturating_mul(cols); + if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET { return Value::Error(CellError::Num); } let mut cell_args: Vec = args.to_vec(); @@ -149,6 +179,36 @@ mod tests { is_err(huge, CellError::Num); } + /// Security: a lifted lookup searches its whole lookup vector once per needle, so needles x + /// vector length past `LIFTED_SCAN_BUDGET` is `#NUM!` at once. + /// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` used to run for hours (and hung opening + /// the 1.6 KB file holding it). + #[test] + fn lifted_lookups_stop_past_their_work_budget() { + use gridcraft_core::Array; + let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into()); + let wide = |n: usize| av(Array::filled(1, n, nv(-1.0)).into()); + is_err(ev("XMATCH", vec![tall(1_048_576), tall(1_048_576)]), CellError::Num); + // Just past the budget, for every lookup: 501 needles over 1,000,000 entries. + assert_eq!(super::LIFTED_SCAN_BUDGET, 500 * 1_000_000); + is_err(ev("XMATCH", vec![tall(501), tall(1_000_000)]), CellError::Num); + is_err(ev("MATCH", vec![tall(501), tall(1_000_000), n(0.0)]), CellError::Num); + is_err(ev("XLOOKUP", vec![tall(501), tall(1_000_000), tall(1_000_000)]), CellError::Num); + is_err(ev("VLOOKUP", vec![tall(501), tall(1_000_000), n(1.0), b(false)]), CellError::Num); + is_err(ev("HLOOKUP", vec![tall(501), wide(1_000_000), n(1.0), b(false)]), CellError::Num); + is_err(ev("LOOKUP", vec![tall(501), tall(1_000_000)]), CellError::Num); + // Within the budget lifted lookups work as before. + let keys = || av(col(&[10.0, 20.0, 30.0])); + let pos = ev("XMATCH", vec![av(col(&[30.0, 10.0, 99.0])), keys()]); + assert_eq!(rows_of(&pos), vec![vec![nv(3.0)], vec![nv(1.0)], vec![Value::Error(CellError::NA)]]); + let found = ev("XLOOKUP", vec![av(row(&[20.0, 30.0])), keys(), av(col(&[1.0, 2.0, 3.0]))]); + assert_eq!(rows_of(&found), vec![vec![nv(2.0), nv(3.0)]]); + // Other lifted functions aren't metered: INDEX reads one entry per position. + let picks: Vec = (1..=1000u32).map(f64::from).collect(); + let out = ev("INDEX", vec![tall(1_048_576), av(col(&picks))]); + assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]); + } + #[test] fn value_params_reject_multi_cell_references() { let s = FnSpec { value_args: &[true, false], ..spec() }; diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 9824da7bf..7c84d553b 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -11,6 +11,14 @@ use crate::util::{ }; use crate::{Arg, Ctx, FnSpec, VAR}; +mod criteria; +mod matrix; +mod roman_base; + +use criteria::{averageif, averageifs, countif, countifs, maxifs, minifs, sumif, sumifs}; +use matrix::{mdeterm, minverse, mmult, munit}; +use roman_base::{arabic, base, decimal, roman}; + // --------------------------------------------------------------------------------------------- // Lifting tables @@ -306,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. @@ -635,307 +647,6 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { unary(a, |x| Ok(x * PI / 180.0)) } -// --------------------------------------------------------------------------------------------- -// Number systems - -const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; - -/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher -/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: -/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 -/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. -fn roman_tokens(form: u32) -> Vec<(String, u32)> { - let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); - let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); - for (i, (sc, sv)) in NUMERALS.iter().enumerate() { - for (lc, lv) in NUMERALS.iter().skip(i + 1) { - let ratio = lv / sv; - let pow10 = matches!(sc, 'I' | 'X' | 'C'); - let ok = ratio <= if pow10 { pow10_max } else { five_max }; - let value = lv - sv; - // Skip pairs that equal a single numeral (VX = V) – they are never shorter. - if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { - toks.push((format!("{sc}{lc}"), value)); - } - } - } - // Largest value first; on ties prefer the shorter / classic token. - toks.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.len().cmp(&b.0.len()))); - toks -} - -fn roman(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let form = match a.get(1).map(|x| x.value.scalar()) { - None => 0.0, - Some(Value::Bool(true)) => 0.0, - Some(Value::Bool(false)) => 4.0, - Some(_) => num(a, 1)?.trunc(), - }; - if !(0.0..=3999.0).contains(&n) || !(0.0..=4.0).contains(&form) { - return Err(CellError::Value); - } - let toks = roman_tokens(form as u32); - let mut rem = n as u32; - let mut out = String::new(); - while rem > 0 { - let Some((t, v)) = toks.iter().find(|(_, v)| *v <= rem) else { break }; - out.push_str(t); - rem -= v; - } - text_val(out) -} - -fn arabic(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = text(a, 0)?; - let s = s.trim(); - if s.chars().count() > 255 { - return Err(CellError::Value); - } - let (neg, body) = match s.strip_prefix('-') { - Some(r) => (true, r), - None => (false, s), - }; - let mut vals = Vec::new(); - for ch in body.chars() { - let u = ch.to_ascii_uppercase(); - let Some((_, v)) = NUMERALS.iter().find(|(c, _)| *c == u) else { return Err(CellError::Value) }; - vals.push(*v as i64); - } - let mut total = 0i64; - for (i, v) in vals.iter().enumerate() { - if vals.get(i + 1).is_some_and(|next| next > v) { - total -= v; - } else { - total += v; - } - } - num_val(if neg { -total as f64 } else { total as f64 }) -} - -fn base(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let radix = num(a, 1)?.trunc(); - let min_len = opt_num(a, 2, 0.0)?.trunc(); - if !(0.0..9.007_199_254_740_992e15).contains(&n) || !(2.0..=36.0).contains(&radix) || !(0.0..=255.0).contains(&min_len) { - return Err(CellError::Num); - } - let radix = radix as u64; - let mut v = n as u64; - let mut digits = Vec::new(); - loop { - let d = (v % radix) as u32; - digits.push(char::from_digit(d, radix as u32).unwrap_or('0').to_ascii_uppercase()); - v /= radix; - if v == 0 { - break; - } - } - while digits.len() < min_len as usize { - digits.push('0'); - } - text_val(digits.iter().rev().collect::()) -} - -fn decimal(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = text(a, 0)?; - let radix = num(a, 1)?.trunc(); - if !(2.0..=36.0).contains(&radix) || s.chars().count() > 255 { - return Err(CellError::Num); - } - let radix = radix as u32; - let mut total = 0f64; - for ch in s.trim().chars() { - let Some(d) = ch.to_digit(radix) else { return Err(CellError::Num) }; - total = total * radix as f64 + d as f64; - } - if total >= 9.007_199_254_740_992e15 { - return Err(CellError::Num); - } - num_val(total) -} - -// --------------------------------------------------------------------------------------------- -// Matrices - -/// A numeric matrix (every element must be a number, else `#VALUE!`). -fn matrix(v: &Value) -> R<(usize, usize, Vec)> { - let arr = as_array(v); - let mut out = Vec::with_capacity(arr.data.len()); - for x in arr.iter() { - match x { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => return Err(CellError::Value), - } - } - Ok((arr.rows, arr.cols, out)) -} - -fn to_value_array(rows: usize, cols: usize, data: Vec) -> R { - let vals = data.into_iter().map(|x| if x.is_finite() { Value::Number(x) } else { Value::Error(CellError::Num) }).collect(); - array_val(rows, cols, vals) -} - -fn mmult(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (ar, ac, ad) = matrix(&crate::util::arg(a, 0)?.value)?; - let (br, bc, bd) = matrix(&crate::util::arg(a, 1)?.value)?; - if ac != br { - return Err(CellError::Value); - } - if ar.saturating_mul(bc) > MAX_CELLS || ar.saturating_mul(bc).saturating_mul(ac) > 2_000_000_000 { - return Err(CellError::Num); - } - let mut out = vec![0.0; ar * bc]; - for i in 0..ar { - for k in 0..ac { - let x = ad.get(i * ac + k).copied().unwrap_or(0.0); - if x == 0.0 { - continue; - } - for j in 0..bc { - if let (Some(o), Some(y)) = (out.get_mut(i * bc + j), bd.get(k * bc + j)) { - *o += x * y; - } - } - } - } - to_value_array(ar, bc, out) -} - -/// LU decomposition with partial pivoting; returns (lu, perm, sign) or None when singular. -fn square(a: &[Arg]) -> R<(usize, Vec)> { - let (r, c, d) = matrix(&crate::util::arg(a, 0)?.value)?; - if r != c { - return Err(CellError::Value); - } - if r > 2000 { - return Err(CellError::Num); - } - Ok((r, d)) -} - -fn determinant(n: usize, mut m: Vec) -> f64 { - let mut det = 1.0; - for col in 0..n { - let mut piv = col; - let mut best = 0.0; - for row in col..n { - let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); - if v > best { - best = v; - piv = row; - } - } - if best == 0.0 { - return 0.0; - } - if piv != col { - for k in 0..n { - m.swap(piv * n + k, col * n + k); - } - det = -det; - } - let p = m.get(col * n + col).copied().unwrap_or(0.0); - det *= p; - for row in col + 1..n { - let f = m.get(row * n + col).copied().unwrap_or(0.0) / p; - if f == 0.0 { - continue; - } - for k in col..n { - let v = m.get(col * n + k).copied().unwrap_or(0.0); - if let Some(x) = m.get_mut(row * n + k) { - *x -= f * v; - } - } - } - } - det -} - -fn mdeterm(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (n, m) = square(a)?; - let d = determinant(n, m); - // Tidy results that should be integers (e.g. 0.9999999999999998 → 1). - num_val(if d.abs() > 1e-300 { to_sig_digits(d, 15) } else { d }) -} - -fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (n, mut m) = square(a)?; - let mut inv = vec![0.0; n * n]; - for i in 0..n { - if let Some(x) = inv.get_mut(i * n + i) { - *x = 1.0; - } - } - let scale = m.iter().fold(0.0f64, |acc, x| acc.max(x.abs())).max(1e-300); - for col in 0..n { - let mut piv = col; - let mut best = 0.0; - for row in col..n { - let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); - if v > best { - best = v; - piv = row; - } - } - if best <= scale * 1e-15 { - return Err(CellError::Num); - } - if piv != col { - for k in 0..n { - m.swap(piv * n + k, col * n + k); - inv.swap(piv * n + k, col * n + k); - } - } - let p = m.get(col * n + col).copied().unwrap_or(1.0); - for k in 0..n { - if let Some(x) = m.get_mut(col * n + k) { - *x /= p; - } - if let Some(x) = inv.get_mut(col * n + k) { - *x /= p; - } - } - for row in 0..n { - if row == col { - continue; - } - let f = m.get(row * n + col).copied().unwrap_or(0.0); - if f == 0.0 { - continue; - } - for k in 0..n { - let mv = m.get(col * n + k).copied().unwrap_or(0.0); - let iv = inv.get(col * n + k).copied().unwrap_or(0.0); - if let Some(x) = m.get_mut(row * n + k) { - *x -= f * mv; - } - if let Some(x) = inv.get_mut(row * n + k) { - *x -= f * iv; - } - } - } - } - // Rounding noise in the inverse scales with 1/scale of the input matrix. - let noise = 1e-15 / scale; - let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect(); - to_value_array(n, n, inv) -} - -fn munit(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let (r, _) = check_size(n, n)?; - let mut data = vec![Value::Number(0.0); r * r]; - for i in 0..r { - if let Some(x) = data.get_mut(i * r + i) { - *x = Value::Number(1.0); - } - } - array_val(r, r, data) -} - fn seriessum(a: &[Arg], _c: &mut dyn Ctx) -> R { let (x, n, m) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); let coeffs = as_array(&crate::util::arg(a, 3)?.value); @@ -951,128 +662,6 @@ fn seriessum(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(total) } -// --------------------------------------------------------------------------------------------- -// Criteria aggregates - -/// Values at the matched positions of `target` for every (range, criteria) pair. -fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R> { - let rest = a.get(first_pair..).unwrap_or(&[]); - if rest.is_empty() || rest.len() % 2 != 0 { - return Err(CellError::Value); - } - // A text criterion over 255 characters is `#VALUE!` only when there is a single criteria - // pair. With two or more pairs Excel matches its first 255 characters instead. - let single_pair = rest.len() == 2; - let mut mask = vec![true; rows.saturating_mul(cols)]; - for pair in rest.chunks(2) { - let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) }; - let r = as_array(&range.value); - if r.rows != rows || r.cols != cols { - return Err(CellError::Value); - } - let scalar = crit.value.scalar(); - let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) }; - for (m, v) in mask.iter_mut().zip(r.iter()) { - if *m && !c.matches(v) { - *m = false; - } - } - } - Ok(mask) -} - -/// Numbers in `target` where `mask` is set (errors at matched positions propagate). -fn masked_numbers(target: &Array, mask: &[bool]) -> R> { - let mut out = Vec::new(); - for (v, m) in target.iter().zip(mask.iter()) { - if !*m { - continue; - } - match v { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - } - Ok(out) -} - -fn countifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let first = as_array(&crate::util::arg(a, 0)?.value); - let mask = criteria_mask(a, 0, first.rows, first.cols)?; - num_val(mask.iter().filter(|m| **m).count() as f64) -} - -/// The values of the `*IFS` target range with the mask from its pairs. -fn ifs_numbers(a: &[Arg]) -> R> { - let target = as_array(&crate::util::arg(a, 0)?.value); - let mask = criteria_mask(a, 1, target.rows, target.cols)?; - masked_numbers(&target, &mask) -} - -fn sumifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(ifs_numbers(a)?.iter().sum()) -} -fn averageifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - if v.is_empty() { - return Err(CellError::Div0); - } - num_val(v.iter().sum::() / v.len() as f64) -} -fn maxifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) -} -fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) -} - -fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { - let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; - num_val(range.iter().filter(|v| c.matches(v)).count() as f64) -} - -/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. -/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; -/// the evaluator does that resizing (it has the references), so cells line up one to one here. -fn if_numbers(a: &[Arg]) -> R> { - let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; - let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; - let mut out = Vec::new(); - for r in 0..range.rows { - for col in 0..range.cols { - let Some(v) = range.get(r, col) else { continue }; - if !c.matches(v) { - continue; - } - let target = match &sum_range { - Some(s) => s.get(r, col), - None => Some(v), - }; - match target { - Some(Value::Number(n)) => out.push(*n), - Some(Value::Error(e)) => return Err(*e), - _ => {} - } - } - } - Ok(out) -} -fn sumif(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(if_numbers(a)?.iter().sum()) -} -fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = if_numbers(a)?; - if v.is_empty() { - return Err(CellError::Div0); - } - num_val(v.iter().sum::() / v.len() as f64) -} - // --------------------------------------------------------------------------------------------- // Registry @@ -1109,6 +698,16 @@ pub(crate) fn specs() -> Vec { f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown), f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround), f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling), + f!( + "ECMA.CEILING", + 2, + 2, + Compatibility, + S, + "ECMA.CEILING(number, significance)", + "Rounds a number up to a multiple of significance.", + ceiling + ), f!( "CEILING.MATH", 1, @@ -1305,497 +904,4 @@ pub(crate) fn specs() -> Vec { const NUM_RANGE_REV: &[bool] = &[false, true]; #[cfg(test)] -mod tests { - use super::*; - use crate::util::testutil::*; - use CellError::{Div0, NA, Num}; - - fn ev1(name: &str, x: f64) -> Value { - ev(name, vec![n(x)]) - } - fn ev2(name: &str, x: f64, y: f64) -> Value { - ev(name, vec![n(x), n(y)]) - } - - #[test] - fn percentof() { - // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. - let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); - let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); - close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); - close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); - is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); - is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); - is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); - } - - #[test] - fn sums() { - close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); - let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); - close(ev("SUM", vec![rf(r.clone())]), 5.0); - is_err(ev("SUM", vec![t("abc")]), CellError::Value); - is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); - close(ev("SUM", vec![rf(tv("abc"))]), 0.0); - close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); - close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); - close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); - let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); - close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); - close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); - is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); - close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); - let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); - close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); - close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); - is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); - } - - /// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and - /// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. - #[test] - fn sumproduct_scalar_arguments() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), - (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), - (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), - (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), - (vec![t("4"), n(2.0)], Err(CellError::Value)), - (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), - (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), - ]; - for (args, want) in cases { - match want { - Ok(x) => close(ev("SUMPRODUCT", args), x), - Err(err) => is_err(ev("SUMPRODUCT", args), err), - } - } - } - - #[test] - fn rounding() { - close(ev2("ROUND", 2.675, 2.0), 2.68); - close(ev2("ROUND", -1.5, 0.0), -2.0); - close(ev2("ROUND", 1234.567, -2.0), 1200.0); - close(ev2("ROUND", 2.15, 1.0), 2.2); - close(ev2("ROUNDUP", 3.2, 0.0), 4.0); - close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); - close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); - close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); - close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); - close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); - for digits in [-308.0, -309.0, -400.0] { - for x in [1234.0, -1234.0, 0.5] { - close(ev2("ROUNDDOWN", x, digits), 0.0); - close(ev2("TRUNC", x, digits), 0.0); - } - } - assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); - close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); - close(ev2("TRUNC", 8.987, 2.0), 8.98); - close(ev1("TRUNC", -8.9), -8.0); - close(ev1("INT", -8.9), -9.0); - close(ev1("INT", 8.9), 8.0); - close(ev2("MROUND", 10.0, 3.0), 9.0); - close(ev2("MROUND", -10.0, -3.0), -9.0); - close(ev2("MROUND", 1.3, 0.2), 1.4); - is_err(ev2("MROUND", 5.0, -2.0), Num); - close(ev2("CEILING", 2.5, 1.0), 3.0); - close(ev2("CEILING", -2.5, -2.0), -4.0); - close(ev2("CEILING", -2.5, 2.0), -2.0); - close(ev2("CEILING", 1.5, 0.1), 1.5); - close(ev2("CEILING", 0.234, 0.01), 0.24); - is_err(ev2("CEILING", 2.5, -1.0), Num); - close(ev2("FLOOR", 3.7, 2.0), 2.0); - close(ev2("FLOOR", -2.5, -2.0), -2.0); - close(ev2("FLOOR", -2.5, 2.0), -4.0); - is_err(ev2("FLOOR", 2.5, -2.0), Num); - is_err(ev2("FLOOR", 2.5, 0.0), Div0); - close(ev2("FLOOR", 1.58, 0.1), 1.5); - close(ev2("FLOOR", 0.234, 0.01), 0.23); - close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); - close(ev1("CEILING.MATH", 6.7), 7.0); - close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); - close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); - close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); - close(ev1("FLOOR.MATH", 6.7), 6.0); - close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); - close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); - close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); - close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); - close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); - close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); - close(ev1("FLOOR.PRECISE", 3.2), 3.0); - close(ev1("EVEN", 1.5), 2.0); - close(ev1("EVEN", 3.0), 4.0); - close(ev1("EVEN", 2.0), 2.0); - close(ev1("EVEN", -1.0), -2.0); - close(ev1("EVEN", 0.0), 0.0); - close(ev1("ODD", 1.5), 3.0); - close(ev1("ODD", 3.0), 3.0); - close(ev1("ODD", 2.0), 3.0); - close(ev1("ODD", -1.0), -1.0); - close(ev1("ODD", -2.0), -3.0); - close(ev1("ODD", 0.0), 1.0); - } - - #[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:?}"), - } - } - } - - #[test] - fn arithmetic() { - close(ev2("MOD", 3.0, 2.0), 1.0); - close(ev2("MOD", -3.0, 2.0), 1.0); - close(ev2("MOD", 3.0, -2.0), -1.0); - close(ev2("MOD", -3.0, -2.0), -1.0); - close(ev2("MOD", 5.5, 1.0), 0.5); - is_err(ev2("MOD", 1.0, 0.0), Div0); - close(ev2("QUOTIENT", 5.0, 2.0), 2.0); - close(ev2("QUOTIENT", -10.0, 3.0), -3.0); - is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); - close(ev1("ABS", -4.0), 4.0); - close(ev1("SIGN", -0.0001), -1.0); - close(ev1("SIGN", 0.0), 0.0); - close(ev1("SQRT", 16.0), 4.0); - is_err(ev1("SQRT", -16.0), Num); - close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); - close(ev2("POWER", 5.0, 2.0), 25.0); - close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); - close(ev2("POWER", -2.0, 3.0), -8.0); - is_err(ev2("POWER", 0.0, 0.0), Num); - is_err(ev2("POWER", 0.0, -1.0), Div0); - is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); - is_err(ev2("POWER", 10.0, 400.0), Num); - close(ev1("EXP", 1.0), std::f64::consts::E); - close(ev2("LOG", 8.0, 2.0), 3.0); - close(ev1("LOG", 10.0), 1.0); - close(ev2("LOG", 81.0, 3.0), 4.0); - is_err(ev1("LN", 0.0), Num); - is_err(ev2("LOG", 10.0, 1.0), Div0); - is_err(ev1("LOG10", -1.0), Num); - close(ev1("LN", std::f64::consts::E), 1.0); - close(ev1("LOG10", 1e5), 5.0); - close(ev("PI", vec![]), PI); - close(ev("SQRT", vec![t("16")]), 4.0); - is_err(ev("SQRT", vec![t("x")]), CellError::Value); - } - - #[test] - fn combinatorics() { - close(ev1("FACT", 5.0), 120.0); - close(ev1("FACT", 1.9), 1.0); - close(ev1("FACT", 0.0), 1.0); - is_err(ev1("FACT", -1.0), Num); - is_err(ev1("FACT", 171.0), Num); - close(ev1("FACTDOUBLE", 6.0), 48.0); - close(ev1("FACTDOUBLE", 7.0), 105.0); - close(ev1("FACTDOUBLE", -1.0), 1.0); - is_err(ev1("FACTDOUBLE", -2.0), Num); - close(ev2("COMBIN", 8.0, 2.0), 28.0); - close(ev2("COMBIN", 8.9, 2.9), 28.0); - is_err(ev2("COMBIN", 2.0, 3.0), Num); - close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); - close(ev2("COMBINA", 4.0, 3.0), 20.0); - close(ev2("COMBINA", 10.0, 3.0), 220.0); - close(ev2("PERMUT", 100.0, 3.0), 970_200.0); - close(ev2("PERMUT", 3.0, 2.0), 6.0); - is_err(ev2("PERMUT", 2.0, 3.0), Num); - close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); - close(ev2("GCD", 24.0, 36.0), 12.0); - close(ev2("GCD", 5.0, 0.0), 5.0); - close(ev2("GCD", 7.0, 1.0), 1.0); - is_err(ev2("GCD", -1.0, 5.0), Num); - close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); - close(ev2("LCM", 24.0, 36.0), 72.0); - close(ev2("LCM", 5.0, 2.0), 10.0); - close(ev2("LCM", 5.0, 0.0), 0.0); - close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); - is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); - } - - #[test] - fn trig() { - close(ev1("SIN", PI / 2.0), 1.0); - close(ev1("COS", 0.0), 1.0); - close(ev1("TAN", PI / 4.0), 1.0); - is_err(ev1("SIN", 1e10), Num); - close(ev1("ASIN", 1.0), PI / 2.0); - is_err(ev1("ACOS", 1.5), Num); - close(ev1("ACOT", 2.0), 0.463_647_609_000_806); - close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); - is_err(ev1("ACOTH", 0.5), Num); - close(ev1("COT", 30.0), -0.156_119_952_161_659); - is_err(ev1("COT", 0.0), Div0); - close(ev1("COTH", 2.0), 1.037_314_720_727_548); - close(ev1("CSC", 15.0), 1.537_780_562_208_209); - close(ev1("CSCH", 1.5), 0.469_642_440_595_653); - close(ev1("SEC", 45.0), 1.903_594_407_014_6); - close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); - close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); - close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); - is_err(ev2("ATAN2", 0.0, 0.0), Div0); - close(ev1("ASINH", -2.5), -1.647_231_146_371_096); - close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); - is_err(ev1("ACOSH", 0.5), Num); - close(ev1("ATANH", 0.5), 0.549_306_144_334_055); - is_err(ev1("ATANH", 1.0), Num); - close(ev1("SINH", 1.0), 1.175_201_193_643_801); - close(ev1("DEGREES", PI), 180.0); - close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); - } - - #[test] - fn number_systems() { - is_text(ev1("ROMAN", 499.0), "CDXCIX"); - is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); - is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); - is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); - is_text(ev2("ROMAN", 499.0, 4.0), "ID"); - is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); - is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); - is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); - is_text(ev1("ROMAN", 0.0), ""); - is_err(ev1("ROMAN", 4000.0), CellError::Value); - is_err(ev1("ROMAN", -1.0), CellError::Value); - close(ev("ARABIC", vec![t("LVII")]), 57.0); - close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); - close(ev("ARABIC", vec![t("")]), 0.0); - close(ev("ARABIC", vec![t("-XIV")]), -14.0); - is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); - is_text(ev2("BASE", 7.0, 2.0), "111"); - is_text(ev2("BASE", 100.0, 16.0), "64"); - is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); - is_text(ev2("BASE", 0.0, 36.0), "0"); - is_err(ev2("BASE", 7.0, 1.0), Num); - is_err(ev2("BASE", -7.0, 2.0), Num); - close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); - close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); - close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); - is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); - } - - /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): - /// form 2 subtracts I from C and X from M, form 3 also V from M. - #[test] - fn roman_forms_two_and_three_match_excel() { - const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; - const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; - let mut cases = Vec::new(); - for thousands in 0..4usize { - let m = "M".repeat(thousands); - for (h, hundreds) in HUNDREDS.iter().enumerate() { - if h != 4 && h != 9 { - cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); - } - } - for (o, ones) in ONES.iter().enumerate() { - cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); - } - for (o, ones) in ONES.iter().enumerate().take(5) { - cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); - } - } - assert_eq!(cases.len(), 92); - for (number, form, want) in &cases { - is_text(ev2("ROMAN", *number as f64, *form), want); - } - // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. - is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); - is_text(ev2("ROMAN", 495.0, 3.0), "VD"); - is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); - is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); - is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); - is_text(ev2("ROMAN", 999.0, 4.0), "IM"); - is_text(ev2("ROMAN", 995.0, 4.0), "VM"); - is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); - is_text(ev2("ROMAN", 99.0, 4.0), "IC"); - } - - #[test] - fn minverse_scale_relative() { - let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); - let r = rows_of(&big); - close_tol(r[0][0].clone(), 1e-20, 1e-25); - close_tol(r[1][1].clone(), 1e-20, 1e-25); - let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); - let r = rows_of(&small); - close_tol(r[0][0].clone(), 1e20, 1e10); - } - - #[test] - fn matrices() { - let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); - let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); - assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); - is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); - is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); - close(ev("MDETERM", vec![av(a.clone())]), -2.0); - let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); - close(ev("MDETERM", vec![av(m3)]), 1.0); - let m4 = arr(vec![ - vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], - vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], - vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], - vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], - ]); - close(ev("MDETERM", vec![av(m4)]), 88.0); - is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); - let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); - let r = rows_of(&inv); - close(r[0][0].clone(), 0.0); - close(r[0][1].clone(), 0.5); - close(r[1][0].clone(), -1.0); - close(r[1][1].clone(), 2.0); - is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); - assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); - is_err(ev1("MUNIT", 0.0), CellError::Value); - is_err(ev1("MUNIT", 1e6), Num); - let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); - close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); - } - - #[test] - fn random() { - let mut ctx = TestCtx::default(); - let spec = crate::lookup("RAND").unwrap(); - assert!(spec.volatile); - for _ in 0..50 { - let v = crate::call(spec, &[], &mut ctx); - let x = v.as_f64().unwrap(); - assert!((0.0..1.0).contains(&x)); - } - let spec = crate::lookup("RANDBETWEEN").unwrap(); - for _ in 0..50 { - let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); - assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); - } - is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); - } - - #[test] - fn lifting() { - assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); - let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); - assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); - close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); - } - - fn data() -> (Value, Value) { - let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); - let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); - (names, qty) - } - - #[test] - fn criteria_functions() { - let (names, qty) = data(); - close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); - close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); - close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); - let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); - assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); - close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); - close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); - close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); - close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); - is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); - close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); - close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); - is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); - } - - #[test] - fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { - let ok = "x".repeat(255); - let long = "x".repeat(256); - let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); - let qty = col(&[1.0, 2.0]); - close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); - // With two or more pairs Excel matches the first 255 characters: the 255-character cell - // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. - let over = format!("{ok}y"); - close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); - } - - #[test] - fn criteria_over_255_utf16_units_follow_excel() { - // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these - // strings, B is the row number, and each row uses its own string as the criterion. - let e = "😀"; - let strings = - [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; - let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); - let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); - // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. - let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; - for (s, want) in strings.iter().zip(expected) { - let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); - let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); - let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); - let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); - match want { - Some([a, b, c, d]) => { - close(countif, a); - close(sumif, b); - close(countifs, c); - close(sumifs, d); - } - // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which - // matches no cell (for emoji it splits a surrogate pair). - None => { - is_err(countif, CellError::Value); - is_err(sumif, CellError::Value); - is_err(countifs, CellError::Value); - close(sumifs, 0.0); - } - } - } - } - - #[test] - fn criteria_cut_inside_a_surrogate_pair() { - let e = "😀"; - let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); - let rows = col(&[1.0, 2.0, 3.0, 4.0]); - let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); - // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. - close(sumifs(format!("={}", e.repeat(128))), 1.0); - // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion - // equals no cell text: "=" matches nothing and "<>" everything, blanks included. - close(sumifs(format!("=a{}", e.repeat(128))), 0.0); - close(sumifs(format!("<>{}", e.repeat(128))), 10.0); - // Comparisons treat it as sorting just above the 126 kept emoji. - close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); - close(sumifs(format!("<={}", e.repeat(128))), 3.0); - } -} +mod tests; diff --git a/crates/functions/src/math/criteria.rs b/crates/functions/src/math/criteria.rs new file mode 100644 index 000000000..ac968b991 --- /dev/null +++ b/crates/functions/src/math/criteria.rs @@ -0,0 +1,128 @@ +use super::{Arg, Array, CellError, Criterion, Ctx, R, Value, as_array, has, num_val}; + +// --------------------------------------------------------------------------------------------- +// Criteria aggregates + +/// Values at the matched positions of `target` for every (range, criteria) pair. +fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R> { + let rest = a.get(first_pair..).unwrap_or(&[]); + if rest.is_empty() || rest.len() % 2 != 0 { + return Err(CellError::Value); + } + // A text criterion over 255 characters is `#VALUE!` only when there is a single criteria + // pair. With two or more pairs Excel matches its first 255 characters instead. + let single_pair = rest.len() == 2; + let mut mask = vec![true; rows.saturating_mul(cols)]; + for pair in rest.chunks(2) { + let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) }; + let r = as_array(&range.value); + if r.rows != rows || r.cols != cols { + return Err(CellError::Value); + } + let scalar = crit.value.scalar(); + let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) }; + for (m, v) in mask.iter_mut().zip(r.iter()) { + if *m && !c.matches(v) { + *m = false; + } + } + } + Ok(mask) +} + +/// Numbers in `target` where `mask` is set (errors at matched positions propagate). +fn masked_numbers(target: &Array, mask: &[bool]) -> R> { + let mut out = Vec::new(); + for (v, m) in target.iter().zip(mask.iter()) { + if !*m { + continue; + } + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + Ok(out) +} + +pub(super) fn countifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let first = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 0, first.rows, first.cols)?; + num_val(mask.iter().filter(|m| **m).count() as f64) +} + +/// The values of the `*IFS` target range with the mask from its pairs. +fn ifs_numbers(a: &[Arg]) -> R> { + let target = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 1, target.rows, target.cols)?; + masked_numbers(&target, &mask) +} + +pub(super) fn sumifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(ifs_numbers(a)?.iter().sum()) +} + +pub(super) fn averageifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} + +pub(super) fn maxifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) +} + +pub(super) fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) +} + +pub(super) fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; + num_val(range.iter().filter(|v| c.matches(v)).count() as f64) +} + +/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. +/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; +/// the evaluator does that resizing (it has the references), so cells line up one to one here. +fn if_numbers(a: &[Arg]) -> R> { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; + let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; + let mut out = Vec::new(); + for r in 0..range.rows { + for col in 0..range.cols { + let Some(v) = range.get(r, col) else { continue }; + if !c.matches(v) { + continue; + } + let target = match &sum_range { + Some(s) => s.get(r, col), + None => Some(v), + }; + match target { + Some(Value::Number(n)) => out.push(*n), + Some(Value::Error(e)) => return Err(*e), + _ => {} + } + } + } + Ok(out) +} + +pub(super) fn sumif(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(if_numbers(a)?.iter().sum()) +} + +pub(super) fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = if_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} diff --git a/crates/functions/src/math/matrix.rs b/crates/functions/src/math/matrix.rs new file mode 100644 index 000000000..69917eae0 --- /dev/null +++ b/crates/functions/src/math/matrix.rs @@ -0,0 +1,182 @@ +use super::{Arg, CellError, Ctx, MAX_CELLS, R, Value, array_val, as_array, check_size, num, num_val, to_sig_digits}; + +// --------------------------------------------------------------------------------------------- +// Matrices + +/// A numeric matrix (every element must be a number, else `#VALUE!`). +fn matrix(v: &Value) -> R<(usize, usize, Vec)> { + let arr = as_array(v); + let mut out = Vec::with_capacity(arr.data.len()); + for x in arr.iter() { + match x { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + } + Ok((arr.rows, arr.cols, out)) +} + +fn to_value_array(rows: usize, cols: usize, data: Vec) -> R { + let vals = data.into_iter().map(|x| if x.is_finite() { Value::Number(x) } else { Value::Error(CellError::Num) }).collect(); + array_val(rows, cols, vals) +} + +pub(super) fn mmult(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (ar, ac, ad) = matrix(&crate::util::arg(a, 0)?.value)?; + let (br, bc, bd) = matrix(&crate::util::arg(a, 1)?.value)?; + if ac != br { + return Err(CellError::Value); + } + if ar.saturating_mul(bc) > MAX_CELLS || ar.saturating_mul(bc).saturating_mul(ac) > 2_000_000_000 { + return Err(CellError::Num); + } + let mut out = vec![0.0; ar * bc]; + for i in 0..ar { + for k in 0..ac { + let x = ad.get(i * ac + k).copied().unwrap_or(0.0); + if x == 0.0 { + continue; + } + for j in 0..bc { + if let (Some(o), Some(y)) = (out.get_mut(i * bc + j), bd.get(k * bc + j)) { + *o += x * y; + } + } + } + } + to_value_array(ar, bc, out) +} + +/// LU decomposition with partial pivoting; returns (lu, perm, sign) or None when singular. +fn square(a: &[Arg]) -> R<(usize, Vec)> { + let (r, c, d) = matrix(&crate::util::arg(a, 0)?.value)?; + if r != c { + return Err(CellError::Value); + } + if r > 2000 { + return Err(CellError::Num); + } + Ok((r, d)) +} + +fn determinant(n: usize, mut m: Vec) -> f64 { + let mut det = 1.0; + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best == 0.0 { + return 0.0; + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + } + det = -det; + } + let p = m.get(col * n + col).copied().unwrap_or(0.0); + det *= p; + for row in col + 1..n { + let f = m.get(row * n + col).copied().unwrap_or(0.0) / p; + if f == 0.0 { + continue; + } + for k in col..n { + let v = m.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * v; + } + } + } + } + det +} + +pub(super) fn mdeterm(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, m) = square(a)?; + let d = determinant(n, m); + // Tidy results that should be integers (e.g. 0.9999999999999998 → 1). + num_val(if d.abs() > 1e-300 { to_sig_digits(d, 15) } else { d }) +} + +pub(super) fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, mut m) = square(a)?; + let mut inv = vec![0.0; n * n]; + for i in 0..n { + if let Some(x) = inv.get_mut(i * n + i) { + *x = 1.0; + } + } + let scale = m.iter().fold(0.0f64, |acc, x| acc.max(x.abs())).max(1e-300); + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best <= scale * 1e-15 { + return Err(CellError::Num); + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + inv.swap(piv * n + k, col * n + k); + } + } + let p = m.get(col * n + col).copied().unwrap_or(1.0); + for k in 0..n { + if let Some(x) = m.get_mut(col * n + k) { + *x /= p; + } + if let Some(x) = inv.get_mut(col * n + k) { + *x /= p; + } + } + for row in 0..n { + if row == col { + continue; + } + let f = m.get(row * n + col).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + for k in 0..n { + let mv = m.get(col * n + k).copied().unwrap_or(0.0); + let iv = inv.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * mv; + } + if let Some(x) = inv.get_mut(row * n + k) { + *x -= f * iv; + } + } + } + } + // Rounding noise in the inverse scales with 1/scale of the input matrix. + let noise = 1e-15 / scale; + let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect(); + to_value_array(n, n, inv) +} + +pub(super) fn munit(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let (r, _) = check_size(n, n)?; + let mut data = vec![Value::Number(0.0); r * r]; + for i in 0..r { + if let Some(x) = data.get_mut(i * r + i) { + *x = Value::Number(1.0); + } + } + array_val(r, r, data) +} diff --git a/crates/functions/src/math/roman_base.rs b/crates/functions/src/math/roman_base.rs new file mode 100644 index 000000000..16376c4ec --- /dev/null +++ b/crates/functions/src/math/roman_base.rs @@ -0,0 +1,121 @@ +use super::{Arg, CellError, Ctx, R, Value, num, num_val, opt_num, text, text_val}; + +// --------------------------------------------------------------------------------------------- +// Number systems + +const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; + +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher +/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: +/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 +/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. +fn roman_tokens(form: u32) -> Vec<(String, u32)> { + let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); + let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); + for (i, (sc, sv)) in NUMERALS.iter().enumerate() { + for (lc, lv) in NUMERALS.iter().skip(i + 1) { + let ratio = lv / sv; + let pow10 = matches!(sc, 'I' | 'X' | 'C'); + let ok = ratio <= if pow10 { pow10_max } else { five_max }; + let value = lv - sv; + // Skip pairs that equal a single numeral (VX = V) – they are never shorter. + if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { + toks.push((format!("{sc}{lc}"), value)); + } + } + } + // Largest value first; on ties prefer the shorter / classic token. + toks.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.len().cmp(&b.0.len()))); + toks +} + +pub(super) fn roman(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let form = match a.get(1).map(|x| x.value.scalar()) { + None => 0.0, + Some(Value::Bool(true)) => 0.0, + Some(Value::Bool(false)) => 4.0, + Some(_) => num(a, 1)?.trunc(), + }; + if !(0.0..=3999.0).contains(&n) || !(0.0..=4.0).contains(&form) { + return Err(CellError::Value); + } + let toks = roman_tokens(form as u32); + let mut rem = n as u32; + let mut out = String::new(); + while rem > 0 { + let Some((t, v)) = toks.iter().find(|(_, v)| *v <= rem) else { break }; + out.push_str(t); + rem -= v; + } + text_val(out) +} + +pub(super) fn arabic(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let s = s.trim(); + if s.chars().count() > 255 { + return Err(CellError::Value); + } + let (neg, body) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let mut vals = Vec::new(); + for ch in body.chars() { + let u = ch.to_ascii_uppercase(); + let Some((_, v)) = NUMERALS.iter().find(|(c, _)| *c == u) else { return Err(CellError::Value) }; + vals.push(*v as i64); + } + let mut total = 0i64; + for (i, v) in vals.iter().enumerate() { + if vals.get(i + 1).is_some_and(|next| next > v) { + total -= v; + } else { + total += v; + } + } + num_val(if neg { -total as f64 } else { total as f64 }) +} + +pub(super) fn base(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let radix = num(a, 1)?.trunc(); + let min_len = opt_num(a, 2, 0.0)?.trunc(); + if !(0.0..9.007_199_254_740_992e15).contains(&n) || !(2.0..=36.0).contains(&radix) || !(0.0..=255.0).contains(&min_len) { + return Err(CellError::Num); + } + let radix = radix as u64; + let mut v = n as u64; + let mut digits = Vec::new(); + loop { + let d = (v % radix) as u32; + digits.push(char::from_digit(d, radix as u32).unwrap_or('0').to_ascii_uppercase()); + v /= radix; + if v == 0 { + break; + } + } + while digits.len() < min_len as usize { + digits.push('0'); + } + text_val(digits.iter().rev().collect::()) +} + +pub(super) fn decimal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let radix = num(a, 1)?.trunc(); + if !(2.0..=36.0).contains(&radix) || s.chars().count() > 255 { + return Err(CellError::Num); + } + let radix = radix as u32; + let mut total = 0f64; + for ch in s.trim().chars() { + let Some(d) = ch.to_digit(radix) else { return Err(CellError::Num) }; + total = total * radix as f64 + d as f64; + } + if total >= 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + num_val(total) +} diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs new file mode 100644 index 000000000..92449eb26 --- /dev/null +++ b/crates/functions/src/math/tests.rs @@ -0,0 +1,503 @@ +use super::*; +use crate::util::testutil::*; +use CellError::{Div0, NA, Num}; + +fn ev1(name: &str, x: f64) -> Value { + ev(name, vec![n(x)]) +} +fn ev2(name: &str, x: f64, y: f64) -> Value { + ev(name, vec![n(x), n(y)]) +} + +#[test] +fn percentof() { + // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. + let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); + let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); + close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); + close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); + is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); + is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); + is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); +} + +#[test] +fn sums() { + close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); + let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); + close(ev("SUM", vec![rf(r.clone())]), 5.0); + is_err(ev("SUM", vec![t("abc")]), CellError::Value); + is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); + close(ev("SUM", vec![rf(tv("abc"))]), 0.0); + close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); + close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); + close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); + let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); + close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); + close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); + is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); + close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); + let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); + close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); + close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); + is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); +} + +/// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and +/// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. +#[test] +fn sumproduct_scalar_arguments() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), + (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), + (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), + (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), + (vec![t("4"), n(2.0)], Err(CellError::Value)), + (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), + (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), + ]; + for (args, want) in cases { + match want { + Ok(x) => close(ev("SUMPRODUCT", args), x), + Err(err) => is_err(ev("SUMPRODUCT", args), err), + } + } +} + +#[test] +fn rounding() { + close(ev2("ROUND", 2.675, 2.0), 2.68); + close(ev2("ROUND", -1.5, 0.0), -2.0); + close(ev2("ROUND", 1234.567, -2.0), 1200.0); + close(ev2("ROUND", 2.15, 1.0), 2.2); + close(ev2("ROUNDUP", 3.2, 0.0), 4.0); + close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); + close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); + close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); + close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); + close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); + for digits in [-308.0, -309.0, -400.0] { + for x in [1234.0, -1234.0, 0.5] { + close(ev2("ROUNDDOWN", x, digits), 0.0); + close(ev2("TRUNC", x, digits), 0.0); + } + } + assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); + close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); + close(ev2("TRUNC", 8.987, 2.0), 8.98); + close(ev1("TRUNC", -8.9), -8.0); + close(ev1("INT", -8.9), -9.0); + close(ev1("INT", 8.9), 8.0); + close(ev2("MROUND", 10.0, 3.0), 9.0); + close(ev2("MROUND", -10.0, -3.0), -9.0); + close(ev2("MROUND", 1.3, 0.2), 1.4); + is_err(ev2("MROUND", 5.0, -2.0), Num); + close(ev2("CEILING", 2.5, 1.0), 3.0); + close(ev2("CEILING", -2.5, -2.0), -4.0); + close(ev2("CEILING", -2.5, 2.0), -2.0); + close(ev2("CEILING", 1.5, 0.1), 1.5); + close(ev2("CEILING", 0.234, 0.01), 0.24); + is_err(ev2("CEILING", 2.5, -1.0), Num); + // ECMA.CEILING is CEILING under a second name (Excel table from #2776). + close(ev2("ECMA.CEILING", 1.5, 1.0), 2.0); + close(ev2("ECMA.CEILING", -2.5, -2.0), -4.0); + close(ev2("ECMA.CEILING", 2.5, 0.0), 0.0); + is_err(ev2("ECMA.CEILING", 1.5, -1.0), Num); + close(ev2("FLOOR", 3.7, 2.0), 2.0); + close(ev2("FLOOR", -2.5, -2.0), -2.0); + close(ev2("FLOOR", -2.5, 2.0), -4.0); + is_err(ev2("FLOOR", 2.5, -2.0), Num); + is_err(ev2("FLOOR", 2.5, 0.0), Div0); + close(ev2("FLOOR", 1.58, 0.1), 1.5); + close(ev2("FLOOR", 0.234, 0.01), 0.23); + close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); + close(ev1("CEILING.MATH", 6.7), 7.0); + close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); + close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); + close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); + close(ev1("FLOOR.MATH", 6.7), 6.0); + close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); + close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); + close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); + close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); + close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); + close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); + close(ev1("FLOOR.PRECISE", 3.2), 3.0); + close(ev1("EVEN", 1.5), 2.0); + close(ev1("EVEN", 3.0), 4.0); + close(ev1("EVEN", 2.0), 2.0); + close(ev1("EVEN", -1.0), -2.0); + close(ev1("EVEN", 0.0), 0.0); + close(ev1("ODD", 1.5), 3.0); + close(ev1("ODD", 3.0), 3.0); + close(ev1("ODD", 2.0), 3.0); + close(ev1("ODD", -1.0), -1.0); + close(ev1("ODD", -2.0), -3.0); + close(ev1("ODD", 0.0), 1.0); +} + +#[test] +fn mod_quotient_limit() { + // Excel 16: #NUM! whenever |number / divisor| >= 1.1259E12. + for (x, d) in [ + (3.0, 1e-300), + (-1.0, 1e-300), + (3.0, -1e-300), + (-1.0, -1e-300), + (1e15, 7.0), + (1e10, 0.001), + (1_125_900_000_000.0, 1.0), + (-1_125_900_000_000.0, 1.0), + ] { + is_err(ev2("MOD", x, d), Num); + } + 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] +fn arithmetic() { + close(ev2("MOD", 3.0, 2.0), 1.0); + close(ev2("MOD", -3.0, 2.0), 1.0); + close(ev2("MOD", 3.0, -2.0), -1.0); + close(ev2("MOD", -3.0, -2.0), -1.0); + close(ev2("MOD", 5.5, 1.0), 0.5); + is_err(ev2("MOD", 1.0, 0.0), Div0); + close(ev2("QUOTIENT", 5.0, 2.0), 2.0); + close(ev2("QUOTIENT", -10.0, 3.0), -3.0); + is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); + close(ev1("ABS", -4.0), 4.0); + close(ev1("SIGN", -0.0001), -1.0); + close(ev1("SIGN", 0.0), 0.0); + close(ev1("SQRT", 16.0), 4.0); + is_err(ev1("SQRT", -16.0), Num); + close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); + close(ev2("POWER", 5.0, 2.0), 25.0); + close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); + close(ev2("POWER", -2.0, 3.0), -8.0); + is_err(ev2("POWER", 0.0, 0.0), Num); + is_err(ev2("POWER", 0.0, -1.0), Div0); + is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); + is_err(ev2("POWER", 10.0, 400.0), Num); + close(ev1("EXP", 1.0), std::f64::consts::E); + close(ev2("LOG", 8.0, 2.0), 3.0); + close(ev1("LOG", 10.0), 1.0); + close(ev2("LOG", 81.0, 3.0), 4.0); + is_err(ev1("LN", 0.0), Num); + is_err(ev2("LOG", 10.0, 1.0), Div0); + is_err(ev1("LOG10", -1.0), Num); + close(ev1("LN", std::f64::consts::E), 1.0); + close(ev1("LOG10", 1e5), 5.0); + close(ev("PI", vec![]), PI); + close(ev("SQRT", vec![t("16")]), 4.0); + is_err(ev("SQRT", vec![t("x")]), CellError::Value); +} + +#[test] +fn combinatorics() { + close(ev1("FACT", 5.0), 120.0); + close(ev1("FACT", 1.9), 1.0); + close(ev1("FACT", 0.0), 1.0); + is_err(ev1("FACT", -1.0), Num); + is_err(ev1("FACT", 171.0), Num); + close(ev1("FACTDOUBLE", 6.0), 48.0); + close(ev1("FACTDOUBLE", 7.0), 105.0); + close(ev1("FACTDOUBLE", -1.0), 1.0); + is_err(ev1("FACTDOUBLE", -2.0), Num); + close(ev2("COMBIN", 8.0, 2.0), 28.0); + close(ev2("COMBIN", 8.9, 2.9), 28.0); + is_err(ev2("COMBIN", 2.0, 3.0), Num); + close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); + close(ev2("COMBINA", 4.0, 3.0), 20.0); + close(ev2("COMBINA", 10.0, 3.0), 220.0); + close(ev2("PERMUT", 100.0, 3.0), 970_200.0); + close(ev2("PERMUT", 3.0, 2.0), 6.0); + is_err(ev2("PERMUT", 2.0, 3.0), Num); + close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); + close(ev2("GCD", 24.0, 36.0), 12.0); + close(ev2("GCD", 5.0, 0.0), 5.0); + close(ev2("GCD", 7.0, 1.0), 1.0); + is_err(ev2("GCD", -1.0, 5.0), Num); + close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); + close(ev2("LCM", 24.0, 36.0), 72.0); + close(ev2("LCM", 5.0, 2.0), 10.0); + close(ev2("LCM", 5.0, 0.0), 0.0); + close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); + is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); +} + +#[test] +fn trig() { + close(ev1("SIN", PI / 2.0), 1.0); + close(ev1("COS", 0.0), 1.0); + close(ev1("TAN", PI / 4.0), 1.0); + is_err(ev1("SIN", 1e10), Num); + close(ev1("ASIN", 1.0), PI / 2.0); + is_err(ev1("ACOS", 1.5), Num); + close(ev1("ACOT", 2.0), 0.463_647_609_000_806); + close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); + is_err(ev1("ACOTH", 0.5), Num); + close(ev1("COT", 30.0), -0.156_119_952_161_659); + is_err(ev1("COT", 0.0), Div0); + close(ev1("COTH", 2.0), 1.037_314_720_727_548); + close(ev1("CSC", 15.0), 1.537_780_562_208_209); + close(ev1("CSCH", 1.5), 0.469_642_440_595_653); + close(ev1("SEC", 45.0), 1.903_594_407_014_6); + close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); + close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); + close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); + is_err(ev2("ATAN2", 0.0, 0.0), Div0); + close(ev1("ASINH", -2.5), -1.647_231_146_371_096); + close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); + is_err(ev1("ACOSH", 0.5), Num); + close(ev1("ATANH", 0.5), 0.549_306_144_334_055); + is_err(ev1("ATANH", 1.0), Num); + close(ev1("SINH", 1.0), 1.175_201_193_643_801); + close(ev1("DEGREES", PI), 180.0); + close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); +} + +#[test] +fn number_systems() { + is_text(ev1("ROMAN", 499.0), "CDXCIX"); + is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); + is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); + is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); + is_text(ev2("ROMAN", 499.0, 4.0), "ID"); + is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); + is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); + is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); + is_text(ev1("ROMAN", 0.0), ""); + is_err(ev1("ROMAN", 4000.0), CellError::Value); + is_err(ev1("ROMAN", -1.0), CellError::Value); + close(ev("ARABIC", vec![t("LVII")]), 57.0); + close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); + close(ev("ARABIC", vec![t("")]), 0.0); + close(ev("ARABIC", vec![t("-XIV")]), -14.0); + is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); + is_text(ev2("BASE", 7.0, 2.0), "111"); + is_text(ev2("BASE", 100.0, 16.0), "64"); + is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); + is_text(ev2("BASE", 0.0, 36.0), "0"); + is_err(ev2("BASE", 7.0, 1.0), Num); + is_err(ev2("BASE", -7.0, 2.0), Num); + close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); + close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); + close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); + is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); +} + +/// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): +/// form 2 subtracts I from C and X from M, form 3 also V from M. +#[test] +fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); +} + +#[test] +fn minverse_scale_relative() { + let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); + let r = rows_of(&big); + close_tol(r[0][0].clone(), 1e-20, 1e-25); + close_tol(r[1][1].clone(), 1e-20, 1e-25); + let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); + let r = rows_of(&small); + close_tol(r[0][0].clone(), 1e20, 1e10); +} + +#[test] +fn matrices() { + let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); + let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); + assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); + is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); + is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); + close(ev("MDETERM", vec![av(a.clone())]), -2.0); + let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); + close(ev("MDETERM", vec![av(m3)]), 1.0); + let m4 = arr(vec![ + vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], + vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], + vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], + vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], + ]); + close(ev("MDETERM", vec![av(m4)]), 88.0); + is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); + let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); + let r = rows_of(&inv); + close(r[0][0].clone(), 0.0); + close(r[0][1].clone(), 0.5); + close(r[1][0].clone(), -1.0); + close(r[1][1].clone(), 2.0); + is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); + assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); + is_err(ev1("MUNIT", 0.0), CellError::Value); + is_err(ev1("MUNIT", 1e6), Num); + let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); + close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); +} + +#[test] +fn random() { + let mut ctx = TestCtx::default(); + let spec = crate::lookup("RAND").unwrap(); + assert!(spec.volatile); + for _ in 0..50 { + let v = crate::call(spec, &[], &mut ctx); + let x = v.as_f64().unwrap(); + assert!((0.0..1.0).contains(&x)); + } + let spec = crate::lookup("RANDBETWEEN").unwrap(); + for _ in 0..50 { + let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); + assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); + } + is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); +} + +#[test] +fn lifting() { + assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); + assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); + close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); +} + +fn data() -> (Value, Value) { + let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); + let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); + (names, qty) +} + +#[test] +fn criteria_functions() { + let (names, qty) = data(); + close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); + close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); + close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); + let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); + assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); + close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); + close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); + close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); + close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); + is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); + close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); + close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); + is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); +} + +#[test] +fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); + // With two or more pairs Excel matches the first 255 characters: the 255-character cell + // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. + let over = format!("{ok}y"); + close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); +} + +#[test] +fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } +} + +#[test] +fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8..270bd2373 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -7,1593 +7,28 @@ use crate::special::{erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, use crate::util::{A, R, S, arg, array_numbers, array_val, as_array, boolean, has, num, num_val, numbers, numbers_a, opt_num}; use crate::{Arg, Ctx, FnSpec, VAR}; +mod descriptive; +mod distributions; +mod regression; + +pub use descriptive::{aggregate_values, aggregate_values_k}; +use descriptive::{ + arg_nums, arg_pairs, avedev, correl, count_impl, counta_impl, countblank, devsq, frequency, geomean, harmean, intercept, kurt, large, max_of, + mean, median, min_of, mode_mult, mode_numbers, mode_sngl, percentile_exc, percentile_inc, percentrank_impl, prob, quartile_exc, quartile_inc, + rank_impl, skew, skew_p, slope, small, standardize, trimmean, var_p, var_s, +}; +use distributions::{ + beta_dist, beta_inv, betadist_legacy, binom_dist, binom_dist_range, binom_inv, chisq_dist, chisq_dist_rt, chisq_inv, chisq_inv_rt, chisq_test, + confidence_norm, confidence_t, expon_dist, f_dist, f_dist_rt, f_inv, f_inv_rt, f_test, fisher, gamma_dist, gamma_fn, gamma_inv, gammaln, gauss, + hypgeom_dist, lognorm_dist, lognorm_inv, negbinom_dist, norm_dist, norm_inv_fn, norm_s_dist, norm_s_inv, poisson_dist, t_dist, t_dist_2t, + t_dist_rt, t_inv, t_inv_2t, t_test, tdist_legacy, weibull_dist, z_test, +}; +#[cfg(test)] +use regression::{MAX_REGRESSORS, check_regression_size}; +use regression::{covariance, forecast, linest_impl, steyx, trend_impl}; + type C<'a> = &'a mut dyn Ctx; -// --------------------------------------------------------------------------------------------- -// Basic helpers over number lists -// --------------------------------------------------------------------------------------------- - -fn mean(x: &[f64]) -> R { - if x.is_empty() { - return Err(CellError::Div0); - } - Ok(x.iter().sum::() / x.len() as f64) -} - -fn devsq(x: &[f64]) -> f64 { - if x.is_empty() { - return 0.0; - } - let m = x.iter().sum::() / x.len() as f64; - x.iter().map(|v| (v - m) * (v - m)).sum() -} - -fn var_s(x: &[f64]) -> R { - if x.len() < 2 { - return Err(CellError::Div0); - } - Ok(devsq(x) / (x.len() - 1) as f64) -} - -fn var_p(x: &[f64]) -> R { - if x.is_empty() { - return Err(CellError::Div0); - } - Ok(devsq(x) / x.len() as f64) -} - -fn sorted(mut v: Vec) -> Vec { - v.sort_by(f64::total_cmp); - v -} - -fn median(v: Vec) -> R { - if v.is_empty() { - return Err(CellError::Num); - } - let s = sorted(v); - let n = s.len(); - let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } -} - -/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. -fn modes(v: &[f64]) -> Vec { - let mut counts: Vec<(f64, usize)> = Vec::new(); - for &x in v { - if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { - e.1 += 1; - } else { - counts.push((x, 1)); - } - } - let best = counts.iter().map(|e| e.1).max().unwrap_or(0); - if best < 2 { - return Vec::new(); - } - counts.into_iter().filter(|e| e.1 == best).map(|e| e.0).collect() -} - -fn mode_sngl(v: &[f64]) -> R { - modes(v).first().copied().ok_or(CellError::NA) -} - -/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical -/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so -/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference -/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to -/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an -/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. -fn mode_numbers(args: &[Arg]) -> R> { - let mut out = Vec::new(); - for a in args { - match &a.value { - Value::Array(arr) => { - for v in arr.iter() { - match v { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - } - } - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - Value::Text(_) | Value::Bool(_) => {} - Value::Empty => return Err(CellError::Value), - } - } - Ok(out) -} - -fn product(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().product() } -} - -fn min_of(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) } -} - -fn max_of(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } -} - -/// k-th largest (k is rounded up like Excel). -fn kth(v: Vec, k: f64, largest: bool) -> R { - let k = k.ceil(); - if k.is_nan() || k < 1.0 || k > v.len() as f64 { - return Err(CellError::Num); - } - let s = sorted(v); - let k = k as usize; - let idx = if largest { s.len() - k } else { k - 1 }; - s.get(idx).copied().ok_or(CellError::Num) -} - -fn interp(s: &[f64], pos: f64) -> R { - let i = pos.floor(); - let frac = pos - i; - let i = i as usize; - let lo = *s.get(i).ok_or(CellError::Num)?; - match s.get(i + 1) { - Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), - _ => Ok(lo), - } -} - -fn percentile_inc(v: Vec, k: f64) -> R { - if v.is_empty() || !(0.0..=1.0).contains(&k) { - return Err(CellError::Num); - } - let s = sorted(v); - interp(&s, k * (s.len() - 1) as f64) -} - -fn percentile_exc(v: Vec, k: f64) -> R { - let n = v.len() as f64; - if v.is_empty() || !(k > 0.0 && k < 1.0) { - return Err(CellError::Num); - } - let pos = k * (n + 1.0); - if pos < 1.0 || pos > n { - return Err(CellError::Num); - } - let s = sorted(v); - interp(&s, pos - 1.0) -} - -fn quartile_inc(v: Vec, q: f64) -> R { - let q = q.trunc(); - if !(0.0..=4.0).contains(&q) { - return Err(CellError::Num); - } - percentile_inc(v, q / 4.0) -} - -fn quartile_exc(v: Vec, q: f64) -> R { - let q = q.trunc(); - if !(1.0..=3.0).contains(&q) { - return Err(CellError::Num); - } - percentile_exc(v, q / 4.0) -} - -fn arg_nums(a: &[Arg], i: usize) -> R> { - array_numbers(&arg(a, i)?.value) -} - -/// Pairs of numbers at matching positions of two arrays (pairs with a non-number skipped). -/// Different sizes give `#N/A`; errors propagate. -fn pairs(x: &Value, y: &Value) -> R<(Vec, Vec)> { - let xa = as_array(x); - let ya = as_array(y); - if xa.data.len() != ya.data.len() { - return Err(CellError::NA); - } - let mut xs = Vec::new(); - let mut ys = Vec::new(); - for (a, b) in xa.data.iter().zip(ya.data.iter()) { - if let Value::Error(e) = a { - return Err(*e); - } - if let Value::Error(e) = b { - return Err(*e); - } - if let (Value::Number(p), Value::Number(q)) = (a, b) { - xs.push(*p); - ys.push(*q); - } - } - Ok((xs, ys)) -} - -fn arg_pairs(a: &[Arg], i: usize, j: usize) -> R<(Vec, Vec)> { - pairs(&arg(a, i)?.value, &arg(a, j)?.value) -} - -/// (sum of cross deviations, devsq x, devsq y, mean x, mean y). -fn moments(x: &[f64], y: &[f64]) -> R<(f64, f64, f64, f64, f64)> { - let mx = mean(x)?; - let my = mean(y)?; - let mut sxy = 0.0; - let mut sxx = 0.0; - let mut syy = 0.0; - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - Ok((sxy, sxx, syy, mx, my)) -} - -fn correl(x: &[f64], y: &[f64]) -> R { - if x.len() < 2 { - return Err(CellError::Div0); - } - let (sxy, sxx, syy, _, _) = moments(x, y)?; - if sxx == 0.0 || syy == 0.0 { - return Err(CellError::Div0); - } - Ok(sxy / (sxx * syy).sqrt()) -} - -fn slope(y: &[f64], x: &[f64]) -> R { - let (sxy, sxx, _, _, _) = moments(x, y)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - Ok(sxy / sxx) -} - -fn intercept(y: &[f64], x: &[f64]) -> R { - let (sxy, sxx, _, mx, my) = moments(x, y)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - Ok(my - sxy / sxx * mx) -} - -fn trunc_digits(r: f64, sig: f64) -> f64 { - let f = 10f64.powi(sig as i32); - if !f.is_finite() { - return r; - } - ((r * f) + 1e-9).floor() / f -} - -fn col_value(v: Vec) -> R { - let n = v.len(); - array_val(n, 1, v.into_iter().map(Value::Number).collect()) -} - -// --------------------------------------------------------------------------------------------- -// Aggregates -// --------------------------------------------------------------------------------------------- - -fn count_impl(a: &[Arg]) -> f64 { - let mut c = 0usize; - for x in a { - match &x.value { - Value::Array(arr) => c += arr.iter().filter(|v| v.is_number()).count(), - Value::Number(_) => c += 1, - _ if x.from_ref => {} - Value::Bool(_) => c += 1, - Value::Text(t) if gridcraft_core::parse::parse_number_text(t).is_some() => c += 1, - _ => {} - } - } - c as f64 -} - -fn counta_impl(a: &[Arg]) -> f64 { - let mut c = 0usize; - for x in a { - match &x.value { - Value::Array(arr) => c += arr.iter().filter(|v| !v.is_empty()).count(), - Value::Empty if x.from_ref => {} - _ => c += 1, - } - } - c as f64 -} - -fn countblank(a: &[Arg], _: C) -> R { - let v = &arg(a, 0)?.value; - let blank = |x: &Value| matches!(x, Value::Empty) || matches!(x, Value::Text(t) if t.is_empty()); - let c = match v { - Value::Array(arr) => arr.iter().filter(|x| blank(x)).count(), - x => usize::from(blank(x)), - }; - num_val(c as f64) -} - -fn mode_mult(a: &[Arg], _: C) -> R { - let v = mode_numbers(a)?; - let m = modes(&v); - if m.is_empty() { - return Err(CellError::NA); - } - col_value(m) -} - -fn large(a: &[Arg], _: C) -> R { - num_val(kth(arg_nums(a, 0)?, num(a, 1)?, true)?) -} - -fn small(a: &[Arg], _: C) -> R { - num_val(kth(arg_nums(a, 0)?, num(a, 1)?, false)?) -} - -fn rank_impl(a: &[Arg], avg: bool) -> R { - let x = num(a, 0)?; - let v = arg_nums(a, 1)?; - let asc = opt_num(a, 2, 0.0)? != 0.0; - let eq = v.iter().filter(|&&y| y == x).count(); - if eq == 0 { - return Err(CellError::NA); - } - let before = v.iter().filter(|&&y| if asc { y < x } else { y > x }).count(); - let r = before as f64 + 1.0; - num_val(if avg { r + (eq as f64 - 1.0) / 2.0 } else { r }) -} - -fn percentrank_impl(a: &[Arg], exc: bool) -> R { - let s = sorted(arg_nums(a, 0)?); - let x = num(a, 1)?; - let sig = opt_num(a, 2, 3.0)?.trunc(); - if sig < 1.0 || s.is_empty() { - return Err(CellError::Num); - } - 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); - } - let n = s.len() as f64; - let rank_of = |v: f64| -> f64 { - let less = s.iter().filter(|&&y| y < v).count() as f64; - if exc { - (less + 1.0) / (n + 1.0) - } else if n <= 1.0 { - 1.0 - } else { - less / (n - 1.0) - } - }; - let r = if s.contains(&x) { - rank_of(x) - } else { - let lo = s.iter().copied().filter(|&y| y < x).fold(f64::NEG_INFINITY, f64::max); - let hi = s.iter().copied().filter(|&y| y > x).fold(f64::INFINITY, f64::min); - let (rl, rh) = (rank_of(lo), rank_of(hi)); - rl + (x - lo) / (hi - lo) * (rh - rl) - }; - num_val(trunc_digits(r, sig)) -} - -fn trimmean(a: &[Arg], _: C) -> R { - let v = arg_nums(a, 0)?; - let p = num(a, 1)?; - if !(0.0..1.0).contains(&p) || v.is_empty() { - return Err(CellError::Num); - } - let n = v.len(); - let per_side = ((n as f64 * p) / 2.0).floor() as usize; - let s = sorted(v); - let kept = s.get(per_side..n - per_side).unwrap_or(&[]); - num_val(mean(kept)?) -} - -fn kurt(v: &[f64]) -> R { - let n = v.len() as f64; - if v.len() < 4 { - return Err(CellError::Div0); - } - let m = mean(v)?; - let s = var_s(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - let sum4: f64 = v.iter().map(|x| ((x - m) / s).powi(4)).sum(); - Ok(n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * sum4 - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0))) -} - -fn skew(v: &[f64]) -> R { - let n = v.len() as f64; - if v.len() < 3 { - return Err(CellError::Div0); - } - let m = mean(v)?; - let s = var_s(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - let sum3: f64 = v.iter().map(|x| ((x - m) / s).powi(3)).sum(); - Ok(n / ((n - 1.0) * (n - 2.0)) * sum3) -} - -fn skew_p(v: &[f64]) -> R { - let m = mean(v)?; - let s = var_p(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - Ok(v.iter().map(|x| ((x - m) / s).powi(3)).sum::() / v.len() as f64) -} - -fn geomean(v: &[f64]) -> R { - if v.is_empty() || v.iter().any(|&x| x <= 0.0) { - return Err(CellError::Num); - } - Ok((v.iter().map(|x| x.ln()).sum::() / v.len() as f64).exp()) -} - -fn harmean(v: &[f64]) -> R { - if v.is_empty() || v.iter().any(|&x| x <= 0.0) { - return Err(CellError::Num); - } - Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) -} - -fn avedev(v: &[f64]) -> R { - if v.is_empty() { - return Err(CellError::Num); - } - let m = mean(v)?; - Ok(v.iter().map(|x| (x - m).abs()).sum::() / v.len() as f64) -} - -fn frequency(a: &[Arg], _: C) -> R { - let data = arg_nums(a, 0)?; - let bins = arg_nums(a, 1)?; - if bins.is_empty() { - return col_value(vec![data.len() as f64]); - } - let mut order: Vec = (0..bins.len()).collect(); - order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); - let mut counts = vec![0.0; bins.len() + 1]; - for x in data { - let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); - if let Some(c) = counts.get_mut(slot) { - *c += 1.0; - } - } - col_value(counts) -} - -fn prob(a: &[Arg], _: C) -> R { - let (xs, ps) = arg_pairs(a, 0, 1)?; - let lo = num(a, 2)?; - let hi = if has(a, 3) { num(a, 3)? } else { lo }; - if ps.iter().any(|&p| p <= 0.0 || p > 1.0) { - return Err(CellError::Num); - } - let total: f64 = ps.iter().sum(); - if (total - 1.0).abs() > 1e-7 { - return Err(CellError::Num); - } - num_val(xs.iter().zip(&ps).filter(|(x, _)| **x >= lo && **x <= hi).map(|(_, p)| p).sum()) -} - -fn standardize(a: &[Arg], _: C) -> R { - let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if sd <= 0.0 { - return Err(CellError::Num); - } - num_val((x - m) / sd) -} - -fn forecast(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let (ys, xs) = arg_pairs(a, 1, 2)?; - if ys.is_empty() { - return Err(CellError::Div0); - } - num_val(intercept(&ys, &xs)? + slope(&ys, &xs)? * x) -} - -fn steyx(a: &[Arg], _: C) -> R { - let (ys, xs) = arg_pairs(a, 0, 1)?; - let n = ys.len() as f64; - if ys.len() < 3 { - return Err(CellError::Div0); - } - let (sxy, sxx, syy, _, _) = moments(&xs, &ys)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - num_val(((syy - sxy * sxy / sxx) / (n - 2.0)).max(0.0).sqrt()) -} - -fn covariance(a: &[Arg], sample: bool) -> R { - let (xs, ys) = arg_pairs(a, 0, 1)?; - let n = xs.len(); - if n == 0 || (sample && n < 2) { - return Err(CellError::Div0); - } - let (sxy, ..) = moments(&xs, &ys)?; - num_val(sxy / if sample { (n - 1) as f64 } else { n as f64 }) -} - -// --------------------------------------------------------------------------------------------- -// Regression (LINEST, LOGEST, TREND, GROWTH) -// --------------------------------------------------------------------------------------------- - -struct Xy { - y: Vec, - /// n rows of k predictor values. - x: Vec>, - k: usize, - y_is_row: bool, - y_rows: usize, - y_cols: usize, -} - -fn strict_num(v: &Value) -> R { - match v { - Value::Number(n) => Ok(*n), - Value::Error(e) => Err(*e), - _ => Err(CellError::Value), - } -} - -/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a -/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated -/// gigabytes and could abort the process. -const MAX_REGRESSORS: usize = 255; -/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. -const MAX_REGRESSION_WORK: usize = 1_000_000_000; - -/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, -/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`. -fn check_regression_size(n: usize, k: usize) -> R<()> { - if k > MAX_REGRESSORS { - return Err(CellError::Value); - } - match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) { - Some(work) if work <= MAX_REGRESSION_WORK => Ok(()), - _ => Err(CellError::Num), - } -} - -fn parse_xy(y: &Value, x: Option<&Value>) -> R { - let ya = as_array(y); - let n = ya.data.len(); - let y_is_row = ya.rows == 1 && ya.cols > 1; - let yv: Vec = ya.data.iter().map(strict_num).collect::>()?; - let (xs, k) = match x { - None => ((1..=n).map(|i| vec![i as f64]).collect(), 1), - Some(xv) => { - let xa = as_array(xv); - if xa.rows == ya.rows && xa.cols == ya.cols { - (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) - } else if !y_is_row && xa.rows == n { - check_regression_size(n, xa.cols)?; - let mut rows = Vec::with_capacity(n); - for r in 0..n { - rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); - } - (rows, xa.cols) - } else if y_is_row && xa.cols == n { - check_regression_size(n, xa.rows)?; - let mut rows = Vec::with_capacity(n); - for c in 0..n { - rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); - } - (rows, xa.rows) - } else { - return Err(CellError::Ref); - } - } - }; - Ok(Xy { y: yv, x: xs, k, y_is_row, y_rows: ya.rows, y_cols: ya.cols }) -} - -struct Fit { - coef: Vec, - b: f64, - se: Vec, - se_b: Option, - r2: R, - sey: R, - f: R, - df: f64, - ssreg: f64, - ssresid: f64, -} - -/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping -/// (zeroing) collinear variables. Returns the inverse and the dropped flags. -fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { - let p = m.len(); - let mut a: Vec> = m.to_vec(); - let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); - let mut dropped = vec![false; p]; - let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); - for j in 0..p { - let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - let scale = diag.get(j).copied().unwrap_or(0.0); - if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { - if let Some(d) = dropped.get_mut(j) { - *d = true; - } - for row in a.iter_mut() { - if let Some(v) = row.get_mut(j) { - *v = 0.0; - } - } - if let Some(r) = a.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } - if let Some(r) = inv.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } - continue; - } - let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - for i in 0..p { - if i == j { - continue; - } - let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - if f == 0.0 { - continue; - } - if let Some(r) = a.get_mut(i) { - r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); - } - if let Some(r) = inv.get_mut(i) { - r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); - } - } - if let Some(r) = a.get_mut(j) { - *r = arow; - } - if let Some(r) = inv.get_mut(j) { - *r = irow; - } - } - (inv, dropped) -} - -fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { - let n = y.len(); - if n == 0 || x.len() != n { - return Err(CellError::Value); - } - check_regression_size(n, k)?; - let nf = n as f64; - let (xm, ym) = if constant { - let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); - (xm, y.iter().sum::() / nf) - } else { - (vec![0.0; k], 0.0) - }; - // Normal equations on (centred) data. - let mut xtx = vec![vec![0.0; k]; k]; - let mut xty = vec![0.0; k]; - for (row, yv) in x.iter().zip(y) { - let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); - let yd = yv - ym; - for (i, di) in d.iter().enumerate() { - if let Some(t) = xty.get_mut(i) { - *t += di * yd; - } - if let Some(r) = xtx.get_mut(i) { - for (j, dj) in d.iter().enumerate() { - if let Some(v) = r.get_mut(j) { - *v += di * dj; - } - } - } - } - } - let (inv, dropped) = sym_inverse(&xtx); - let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); - let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; - let mut ssresid = 0.0; - let mut sstot = 0.0; - for (row, yv) in x.iter().zip(y) { - let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); - ssresid += (yv - pred).powi(2); - sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; - } - let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); - let df = nf - used as f64; - let ssreg = sstot - ssresid; - let r2 = if sstot == 0.0 { Err(CellError::Num) } else { Ok(ssreg / sstot) }; - let sey2 = if df > 0.0 { Ok(ssresid / df) } else { Err(CellError::Num) }; - let sey = sey2.map(f64::sqrt); - let v1 = if constant { nf - df - 1.0 } else { nf - df }; - let f = match sey2 { - Ok(s2) if s2 > 0.0 && v1 > 0.0 => Ok((ssreg / v1) / s2), - _ => Err(CellError::Num), - }; - let s2 = sey2.unwrap_or(f64::NAN); - let se: Vec = (0..k) - .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) - .collect(); - let se_b = if constant { - let mut q = 0.0; - for (i, mi) in xm.iter().enumerate() { - for (j, mj) in xm.iter().enumerate() { - q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - } - } - Some((s2 * (1.0 / nf + q)).sqrt()) - } else { - None - }; - Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) -} - -fn const_arg(a: &[Arg], i: usize) -> R { - if has(a, i) { boolean(a, i) } else { Ok(true) } -} - -fn opt_value(a: &[Arg], i: usize) -> Option<&Value> { - if has(a, i) { a.get(i).map(|x| &x.value) } else { None } -} - -fn linest_impl(a: &[Arg], log: bool) -> R { - let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; - if log { - if xy.y.iter().any(|&v| v <= 0.0) { - return Err(CellError::Num); - } - xy.y.iter_mut().for_each(|v| *v = v.ln()); - } - let constant = const_arg(a, 2)?; - let stats = if has(a, 3) { boolean(a, 3)? } else { false }; - let fit = regress(&xy.y, &xy.x, xy.k, constant)?; - let tr = |v: f64| if log { v.exp() } else { v }; - let cols = xy.k + 1; - let mut data: Vec = Vec::new(); - for c in fit.coef.iter().rev() { - data.push(Value::number(tr(*c))); - } - data.push(Value::number(tr(fit.b))); - if stats { - let rv = |r: R| r.map(Value::number).unwrap_or_else(Value::Error); - let na = Value::Error(CellError::NA); - for s in fit.se.iter().rev() { - data.push(Value::number(*s)); - } - data.push(fit.se_b.map(Value::number).unwrap_or(na.clone())); - let rows: [[Value; 2]; 3] = - [[rv(fit.r2), rv(fit.sey)], [rv(fit.f), Value::number(fit.df)], [Value::number(fit.ssreg), Value::number(fit.ssresid)]]; - for [p, q] in rows { - data.push(p); - if cols > 1 { - data.push(q); - } - for _ in 2..cols { - data.push(na.clone()); - } - } - return array_val(5, cols, data); - } - array_val(1, cols, data) -} - -fn trend_impl(a: &[Arg], log: bool) -> R { - let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; - if log { - if xy.y.iter().any(|&v| v <= 0.0) { - return Err(CellError::Num); - } - xy.y.iter_mut().for_each(|v| *v = v.ln()); - } - let constant = const_arg(a, 3)?; - let fit = regress(&xy.y, &xy.x, xy.k, constant)?; - let predict = |row: &[f64]| -> Value { - let v = fit.b + fit.coef.iter().zip(row).map(|(c, x)| c * x).sum::(); - Value::number(if log { v.exp() } else { v }) - }; - match opt_value(a, 2) { - None => { - let data: Vec = xy.x.iter().map(|r| predict(r)).collect(); - array_val(xy.y_rows, xy.y_cols, data) - } - Some(nv) => { - let na = as_array(nv); - if xy.k == 1 { - let data = na.data.iter().map(|v| strict_num(v).map(|x| predict(&[x]))).collect::>>()?; - array_val(na.rows, na.cols, data) - } else if !xy.y_is_row && na.cols == xy.k { - let mut data = Vec::with_capacity(na.rows); - for r in 0..na.rows { - let row = (0..na.cols).map(|c| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; - data.push(predict(&row)); - } - array_val(na.rows, 1, data) - } else if xy.y_is_row && na.rows == xy.k { - let mut data = Vec::with_capacity(na.cols); - for c in 0..na.cols { - let row = (0..na.rows).map(|r| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; - data.push(predict(&row)); - } - array_val(1, na.cols, data) - } else { - Err(CellError::Ref) - } - } - } -} - -// --------------------------------------------------------------------------------------------- -// Distributions -// --------------------------------------------------------------------------------------------- - -fn ln_comb(n: f64, k: f64) -> f64 { - ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) -} - -fn t_upper(t: f64, v: f64) -> f64 { - // P(T > t) for t ≥ 0. - 0.5 * reg_inc_beta(v / (v + t * t), v / 2.0, 0.5) -} - -fn t_cdf(t: f64, v: f64) -> f64 { - if t >= 0.0 { 1.0 - t_upper(t, v) } else { t_upper(-t, v) } -} - -fn t_pdf(t: f64, v: f64) -> f64 { - (ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() -} - -/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. -fn t_inv_upper(q: f64, v: f64) -> f64 { - if q >= 0.5 { - return 0.0; - } - invert(|t| t_upper(t, v), q, 0.0, 1.0, 1e60) -} - -fn chi_inv(p: f64, v: f64) -> f64 { - if p <= 0.0 { - return 0.0; - } - invert(|x| reg_gamma_p(v / 2.0, x / 2.0), p, 0.0, v.max(1.0), 1e60) -} - -fn chi_inv_rt(q: f64, v: f64) -> f64 { - if q >= 1.0 { - return 0.0; - } - invert(|x| reg_gamma_q(v / 2.0, x / 2.0), q, 0.0, v.max(1.0), 1e60) -} - -fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { - if x <= 0.0 { - return 0.0; - } - reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) -} - -fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { - if x <= 0.0 { - return 1.0; - } - reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) -} - -fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { - if x == 0.0 { - return if d1 < 2.0 { - f64::INFINITY - } else if d1 == 2.0 { - 1.0 - } else { - 0.0 - }; - } - ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() -} - -fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { - if k < 0.0 || k > n { - return 0.0; - } - if p == 0.0 { - return if k == 0.0 { 1.0 } else { 0.0 }; - } - if p == 1.0 { - return if k == n { 1.0 } else { 0.0 }; - } - (ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp() -} - -fn binom_cdf(k: f64, n: f64, p: f64) -> f64 { - if k < 0.0 { - return 0.0; - } - if k >= n { - return 1.0; - } - if n <= 10_000.0 { - let mut s = 0.0; - let mut i = 0.0; - while i <= k { - s += binom_pmf(i, n, p); - i += 1.0; - } - return s.min(1.0); - } - if p == 0.0 { - return 1.0; - } - if p == 1.0 { - return 0.0; - } - reg_inc_beta(1.0 - p, n - k, k + 1.0) -} - -fn poisson_pmf(x: f64, m: f64) -> f64 { - if m == 0.0 { - return if x == 0.0 { 1.0 } else { 0.0 }; - } - (-m + x * m.ln() - ln_gamma(x + 1.0)).exp() -} - -fn poisson_cdf(x: f64, m: f64) -> f64 { - if m == 0.0 { - return 1.0; - } - if x <= 1000.0 { - let mut s = 0.0; - let mut i = 0.0; - while i <= x { - s += poisson_pmf(i, m); - i += 1.0; - } - return s.min(1.0); - } - reg_gamma_q(x + 1.0, m) -} - -fn norm_dist(a: &[Arg], _: C) -> R { - let (x, m, sd, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if sd <= 0.0 { - return Err(CellError::Num); - } - let z = (x - m) / sd; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / sd }) -} - -fn norm_inv_fn(a: &[Arg], _: C) -> R { - let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(p > 0.0 && p < 1.0) || sd <= 0.0 { - return Err(CellError::Num); - } - num_val(m + sd * norm_inv(p)) -} - -fn norm_s_dist(a: &[Arg], _: C) -> R { - let z = num(a, 0)?; - let cum = if a.len() > 1 { boolean(a, 1)? } else { true }; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) }) -} - -fn norm_s_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - if !(p > 0.0 && p < 1.0) { - return Err(CellError::Num); - } - num_val(norm_inv(p)) -} - -fn df_arg(a: &[Arg], i: usize) -> R { - let v = num(a, i)?.trunc(); - if v < 1.0 { - return Err(CellError::Num); - } - Ok(v) -} - -fn t_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let cum = boolean(a, 2)?; - num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) -} - -fn t_dist_2t(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(2.0 * t_upper(x, v)) -} - -fn t_dist_rt(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - num_val(1.0 - t_cdf(x, v)) -} - -fn tdist_legacy(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let tails = num(a, 2)?.trunc(); - if x < 0.0 || !(tails == 1.0 || tails == 2.0) { - return Err(CellError::Num); - } - num_val(tails * t_upper(x, v)) -} - -fn t_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p < 1.0) { - return Err(CellError::Num); - } - num_val(if p < 0.5 { -t_inv_upper(p, v) } else { t_inv_upper(1.0 - p, v) }) -} - -fn t_inv_2t(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p <= 1.0) { - return Err(CellError::Num); - } - num_val(t_inv_upper(p / 2.0, v)) -} - -fn t_test(a: &[Arg], _: C) -> R { - let tails = num(a, 2)?.trunc(); - let ty = num(a, 3)?.trunc(); - if !(tails == 1.0 || tails == 2.0) || !(1.0..=3.0).contains(&ty) { - return Err(CellError::Num); - } - let (t, v) = if ty == 1.0 { - let (x, y) = arg_pairs(a, 0, 1)?; - let d: Vec = x.iter().zip(&y).map(|(p, q)| p - q).collect(); - let n = d.len() as f64; - let sd = var_s(&d)?.sqrt(); - if sd == 0.0 { - return Err(CellError::Div0); - } - (mean(&d)? / (sd / n.sqrt()), n - 1.0) - } else { - let x = arg_nums(a, 0)?; - let y = arg_nums(a, 1)?; - let (n1, n2) = (x.len() as f64, y.len() as f64); - let (v1, v2) = (var_s(&x)?, var_s(&y)?); - let diff = mean(&x)? - mean(&y)?; - if ty == 2.0 { - let df = n1 + n2 - 2.0; - let sp = ((n1 - 1.0) * v1 + (n2 - 1.0) * v2) / df; - let se = (sp * (1.0 / n1 + 1.0 / n2)).sqrt(); - if se == 0.0 { - return Err(CellError::Div0); - } - (diff / se, df) - } else { - let (a1, a2) = (v1 / n1, v2 / n2); - let se = (a1 + a2).sqrt(); - if se == 0.0 { - return Err(CellError::Div0); - } - let df = (a1 + a2).powi(2) / (a1 * a1 / (n1 - 1.0) + a2 * a2 / (n2 - 1.0)); - (diff / se, df) - } - }; - num_val(tails * t_upper(t.abs(), v)) -} - -fn chisq_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let cum = boolean(a, 2)?; - if x < 0.0 || v > 1e10 { - return Err(CellError::Num); - } - if cum { - return num_val(reg_gamma_p(v / 2.0, x / 2.0)); - } - if x == 0.0 { - return if v == 2.0 { - num_val(0.5) - } else if v < 2.0 { - Err(CellError::Num) - } else { - num_val(0.0) - }; - } - num_val(((v / 2.0 - 1.0) * x.ln() - x / 2.0 - (v / 2.0) * 2f64.ln() - ln_gamma(v / 2.0)).exp()) -} - -fn chisq_dist_rt(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - if x < 0.0 || v > 1e10 { - return Err(CellError::Num); - } - num_val(reg_gamma_q(v / 2.0, x / 2.0)) -} - -fn chisq_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(0.0..1.0).contains(&p) || v > 1e10 { - return Err(CellError::Num); - } - num_val(chi_inv(p, v)) -} - -fn chisq_inv_rt(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p <= 1.0) || v > 1e10 { - return Err(CellError::Num); - } - num_val(chi_inv_rt(p, v)) -} - -fn chisq_test(a: &[Arg], _: C) -> R { - let act = as_array(&arg(a, 0)?.value).into_owned(); - let exp = as_array(&arg(a, 1)?.value).into_owned(); - if act.rows != exp.rows || act.cols != exp.cols { - return Err(CellError::NA); - } - let mut x2 = 0.0; - for (o, e) in act.data.iter().zip(&exp.data) { - if let Value::Error(err) = o { - return Err(*err); - } - if let Value::Error(err) = e { - return Err(*err); - } - if let (Value::Number(o), Value::Number(e)) = (o, e) { - if *e == 0.0 { - return Err(CellError::Div0); - } - x2 += (o - e).powi(2) / e; - } - } - let df = if act.rows > 1 && act.cols > 1 { ((act.rows - 1) * (act.cols - 1)) as f64 } else { (act.data.len().saturating_sub(1)) as f64 }; - if df < 1.0 { - return Err(CellError::NA); - } - num_val(reg_gamma_q(df / 2.0, x2 / 2.0)) -} - -fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { - let x = num(a, 0)?; - let d1 = num(a, 1)?.trunc(); - let d2 = num(a, 2)?.trunc(); - if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { - return Err(CellError::Num); - } - Ok((x, d1, d2)) -} - -fn f_dist(a: &[Arg], _: C) -> R { - let (x, d1, d2) = f_args(a)?; - let cum = boolean(a, 3)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(if cum { f_cdf(x, d1, d2) } else { f_pdf(x, d1, d2) }) -} - -fn f_dist_rt(a: &[Arg], _: C) -> R { - let (x, d1, d2) = f_args(a)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(f_upper(x, d1, d2)) -} - -fn f_inv(a: &[Arg], _: C) -> R { - let (p, d1, d2) = f_args(a)?; - if !(0.0..1.0).contains(&p) { - return Err(CellError::Num); - } - if p == 0.0 { - return num_val(0.0); - } - num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) -} - -fn f_inv_rt(a: &[Arg], _: C) -> R { - let (p, d1, d2) = f_args(a)?; - if !(p > 0.0 && p <= 1.0) { - return Err(CellError::Num); - } - if p == 1.0 { - return num_val(0.0); - } - num_val(invert(|x| f_upper(x, d1, d2), p, 0.0, 1.0, 1e60)) -} - -fn f_test(a: &[Arg], _: C) -> R { - let x = arg_nums(a, 0)?; - let y = arg_nums(a, 1)?; - let (v1, v2) = (var_s(&x)?, var_s(&y)?); - if v1 == 0.0 || v2 == 0.0 { - return Err(CellError::Div0); - } - let f = v1 / v2; - let (d1, d2) = ((x.len() - 1) as f64, (y.len() - 1) as f64); - let c = f_cdf(f, d1, d2); - num_val((2.0 * c.min(1.0 - c)).min(1.0)) -} - -fn binom_dist(a: &[Arg], _: C) -> R { - let k = num(a, 0)?.trunc(); - let n = num(a, 1)?.trunc(); - let p = num(a, 2)?; - let cum = boolean(a, 3)?; - if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) -} - -fn binom_dist_range(a: &[Arg], _: C) -> R { - let n = num(a, 0)?.trunc(); - let p = num(a, 1)?; - let s1 = num(a, 2)?.trunc(); - let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { - return Err(CellError::Num); - } - if s2 - s1 > 10_000.0 { - return num_val(binom_cdf(s2, n, p) - binom_cdf(s1 - 1.0, n, p)); - } - let mut s = 0.0; - let mut k = s1; - while k <= s2 { - s += binom_pmf(k, n, p); - k += 1.0; - } - num_val(s) -} - -fn binom_inv(a: &[Arg], _: C) -> R { - let n = num(a, 0)?.trunc(); - let p = num(a, 1)?; - let alpha = num(a, 2)?; - if n < 0.0 || !(0.0..=1.0).contains(&p) || !(0.0..=1.0).contains(&alpha) { - return Err(CellError::Num); - } - // Smallest k with cdf(k) ≥ alpha, by bisection on the integer k. - let (mut lo, mut hi) = (0.0f64, n); - if binom_cdf(0.0, n, p) >= alpha { - return num_val(0.0); - } - while hi - lo > 1.0 { - let mid = ((lo + hi) / 2.0).floor(); - // Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking. - if mid <= lo || mid >= hi { - break; - } - if binom_cdf(mid, n, p) >= alpha { - hi = mid; - } else { - lo = mid; - } - } - num_val(hi) -} - -fn poisson_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?.trunc(); - let m = num(a, 1)?; - let cum = boolean(a, 2)?; - if x < 0.0 || m < 0.0 { - return Err(CellError::Num); - } - num_val(if cum { poisson_cdf(x, m) } else { poisson_pmf(x, m) }) -} - -fn expon_dist(a: &[Arg], _: C) -> R { - let (x, l, cum) = (num(a, 0)?, num(a, 1)?, boolean(a, 2)?); - if x < 0.0 || l <= 0.0 { - return Err(CellError::Num); - } - num_val(if cum { -(-l * x).exp_m1() } else { l * (-l * x).exp() }) -} - -fn gamma_fn(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let g = gamma(x); - if !g.is_finite() { - return Err(CellError::Num); - } - num_val(g) -} - -fn gamma_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if x < 0.0 || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - if cum { - return num_val(reg_gamma_p(al, x / be)); - } - if x == 0.0 { - return if al < 1.0 { - Err(CellError::Num) - } else if al == 1.0 { - num_val(1.0 / be) - } else { - num_val(0.0) - }; - } - num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) -} - -fn gamma_inv(a: &[Arg], _: C) -> R { - let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(0.0..1.0).contains(&p) || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - if p == 0.0 { - return num_val(0.0); - } - num_val(invert(|x| reg_gamma_p(al, x / be), p, 0.0, (al * be).max(1e-3), 1e60)) -} - -fn gammaln(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(ln_gamma(x)) -} - -fn beta_bounds(a: &[Arg], i: usize) -> R<(f64, f64)> { - let lo = if has(a, i) { num(a, i)? } else { 0.0 }; - let hi = if has(a, i + 1) { num(a, i + 1)? } else { 1.0 }; - if lo >= hi { - return Err(CellError::Num); - } - Ok((lo, hi)) -} - -fn beta_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - let (lo, hi) = beta_bounds(a, 4)?; - if al <= 0.0 || be <= 0.0 || x < lo || x > hi { - return Err(CellError::Num); - } - let z = (x - lo) / (hi - lo); - if cum { - return num_val(reg_inc_beta(z, al, be)); - } - if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { - return Err(CellError::Num); - } - let lp = if z == 0.0 || z == 1.0 { - let base = if z == 0.0 { al } else { be }; - if base == 1.0 { -ln_beta(al, be) } else { f64::NEG_INFINITY } - } else { - (al - 1.0) * z.ln() + (be - 1.0) * (1.0 - z).ln() - ln_beta(al, be) - }; - num_val(lp.exp() / (hi - lo)) -} - -fn betadist_legacy(a: &[Arg], _: C) -> R { - let (x, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (lo, hi) = beta_bounds(a, 3)?; - if al <= 0.0 || be <= 0.0 || x < lo || x > hi { - return Err(CellError::Num); - } - num_val(reg_inc_beta((x - lo) / (hi - lo), al, be)) -} - -fn beta_inv(a: &[Arg], _: C) -> R { - let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (lo, hi) = beta_bounds(a, 3)?; - if al <= 0.0 || be <= 0.0 || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - let z = if p == 0.0 { - 0.0 - } else if p == 1.0 { - 1.0 - } else { - invert(|x| reg_inc_beta(x, al, be), p, 0.0, 1.0, 1.0) - }; - num_val(lo + z * (hi - lo)) -} - -fn lognorm_dist(a: &[Arg], _: C) -> R { - let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let cum = if a.len() > 3 { boolean(a, 3)? } else { true }; - if x <= 0.0 || sd <= 0.0 { - return Err(CellError::Num); - } - let z = (x.ln() - m) / sd; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / (x * sd) }) -} - -fn lognorm_inv(a: &[Arg], _: C) -> R { - let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(p > 0.0 && p < 1.0) || sd <= 0.0 { - return Err(CellError::Num); - } - num_val((m + sd * norm_inv(p)).exp()) -} - -fn weibull_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if x < 0.0 || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - let t = (x / be).powf(al); - num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) -} - -fn hypgeom_dist(a: &[Arg], _: C) -> R { - let k = num(a, 0)?.trunc(); - let n = num(a, 1)?.trunc(); - let kk = num(a, 2)?.trunc(); - let nn = num(a, 3)?.trunc(); - let cum = if a.len() > 4 { boolean(a, 4)? } else { false }; - if k < 0.0 || n <= 0.0 || kk <= 0.0 || nn <= 0.0 || n > nn || kk > nn || k > n.min(kk) || k < (n - nn + kk).max(0.0) { - return Err(CellError::Num); - } - let pmf = |i: f64| (ln_comb(kk, i) + ln_comb(nn - kk, n - i) - ln_comb(nn, n)).exp(); - if !cum { - return num_val(pmf(k)); - } - let mut s = 0.0; - let mut i = (n - nn + kk).max(0.0); - if k - i > 1e6 || i + 1.0 == i { - return Err(CellError::Num); - } - while i <= k { - s += pmf(i); - i += 1.0; - } - num_val(s.min(1.0)) -} - -fn negbinom_dist(a: &[Arg], _: C) -> R { - let f = num(a, 0)?.trunc(); - let s = num(a, 1)?.trunc(); - let p = num(a, 2)?; - let cum = if a.len() > 3 { boolean(a, 3)? } else { false }; - if f < 0.0 || s < 1.0 || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - if cum { - if p == 0.0 { - return num_val(0.0); - } - return num_val(reg_inc_beta(p, s, f + 1.0)); - } - if p == 0.0 || p == 1.0 { - return num_val(if p == 1.0 && f == 0.0 { 1.0 } else { 0.0 }); - } - num_val((ln_comb(f + s - 1.0, s - 1.0) + s * p.ln() + f * (1.0 - p).ln()).exp()) -} - -fn confidence_norm(a: &[Arg], _: C) -> R { - let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); - if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val(norm_inv(1.0 - al / 2.0) * sd / n.sqrt()) -} - -fn confidence_t(a: &[Arg], _: C) -> R { - let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); - if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - if n == 1.0 { - return Err(CellError::Div0); - } - num_val(t_inv_upper(al / 2.0, n - 1.0) * sd / n.sqrt()) -} - -fn z_test(a: &[Arg], _: C) -> R { - let v = arg_nums(a, 0)?; - let x = num(a, 1)?; - if v.is_empty() { - return Err(CellError::NA); - } - let sd = if has(a, 2) { num(a, 2)? } else { var_s(&v)?.sqrt() }; - if sd <= 0.0 { - return Err(if has(a, 2) { CellError::Num } else { CellError::Div0 }); - } - let z = (mean(&v)? - x) / (sd / (v.len() as f64).sqrt()); - num_val(1.0 - norm_cdf(z)) -} - -fn fisher(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - if x <= -1.0 || x >= 1.0 { - return Err(CellError::Num); - } - num_val(0.5 * ((1.0 + x) / (1.0 - x)).ln()) -} - -fn gauss(a: &[Arg], _: C) -> R { - let z = num(a, 0)?; - // Φ(z) − 0.5 = erf(z/√2)/2, accurate near zero. - num_val(0.5 * erf(z / std::f64::consts::SQRT_2)) -} - -// --------------------------------------------------------------------------------------------- -// SUBTOTAL / AGGREGATE support -// --------------------------------------------------------------------------------------------- - -/// Evaluates SUBTOTAL/AGGREGATE function `function_num` over already-filtered cell values. -/// -/// Codes 1–11 (and 101–111) are AVERAGE, COUNT, COUNTA, MAX, MIN, PRODUCT, STDEV.S, STDEV.P, -/// SUM, VAR.S, VAR.P; 12 = MEDIAN, 13 = MODE.SNGL. Codes 14–19 need `k`: use -/// [`aggregate_values_k`] (they give `#VALUE!` here). Values that are not numbers are ignored -/// (COUNTA counts non-empty values); the first error value present is returned. -pub fn aggregate_values(function_num: u32, values: &[Value]) -> Value { - crate::util::finish(aggregate_impl(function_num, values, None)) -} - -/// AGGREGATE codes 14 = LARGE, 15 = SMALL, 16 = PERCENTILE.INC, 17 = QUARTILE.INC, -/// 18 = PERCENTILE.EXC, 19 = QUARTILE.EXC with their `k` argument; codes 1–13 delegate to -/// [`aggregate_values`]. -pub fn aggregate_values_k(function_num: u32, values: &[Value], k: &Value) -> Value { - crate::util::finish(aggregate_impl(function_num, values, Some(k))) -} - -fn aggregate_impl(function_num: u32, values: &[Value], k: Option<&Value>) -> R { - let code = if (101..=111).contains(&function_num) { function_num - 100 } else { function_num }; - let mut xs = Vec::new(); - let mut nonempty = 0usize; - let mut push = |v: &Value| -> R<()> { - match v { - Value::Number(n) => xs.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - if !v.is_empty() { - nonempty += 1; - } - Ok(()) - }; - for v in values { - match v { - Value::Array(arr) => arr.iter().try_for_each(&mut push)?, - other => push(other)?, - } - } - let k = match (code, k) { - (14..=19, Some(k)) => k.scalar().to_number()?, - (14..=19, None) => return Err(CellError::Value), - _ => 0.0, - }; - let r = match code { - 1 => mean(&xs)?, - 2 => xs.len() as f64, - 3 => nonempty as f64, - 4 => max_of(&xs), - 5 => min_of(&xs), - 6 => product(&xs), - 7 => var_s(&xs)?.sqrt(), - 8 => var_p(&xs)?.sqrt(), - 9 => xs.iter().sum(), - 10 => var_s(&xs)?, - 11 => var_p(&xs)?, - 12 => median(xs)?, - 13 => mode_sngl(&xs)?, - 14 => kth(xs, k, true)?, - 15 => kth(xs, k, false)?, - 16 => percentile_inc(xs, k)?, - 17 => quartile_inc(xs, k)?, - 18 => percentile_exc(xs, k)?, - 19 => quartile_exc(xs, k)?, - _ => return Err(CellError::Value), - }; - num_val(r) -} - // --------------------------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------------------------- @@ -2273,513 +708,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; - use crate::util::testutil::*; - use gridcraft_core::{CellError, Value}; - - const D7: f64 = 1e-6; - - fn c(xs: &[f64]) -> crate::Arg { - av(col(xs)) - } - - // The two middle values are finite and their midpoint is representable even when - // adding them first would overflow. Exercise both callers of the shared helper. - #[test] - fn median_finite_midpoint_does_not_overflow() { - for (values, expected) in [ - (vec![1e308, 1e308], 1e308), - (vec![-1e308, -1e308], -1e308), - (vec![1.6e308, 1e308], 1.3e308), - (vec![-1e308, -1.6e308], -1.3e308), - (vec![f64::MAX, f64::MAX], f64::MAX), - (vec![-f64::MAX, -f64::MAX], -f64::MAX), - (vec![-f64::MAX, f64::MAX], 0.0), - (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), - ] { - close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); - close(ev("MEDIAN", vec![rf(col(&values))]), expected); - close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); - } - } - - #[test] - fn median_midpoint_keeps_small_values_and_argument_semantics() { - // Halving each operand unconditionally can lose the smallest numbers. Use exact - // equality here: the usual aggregate-test tolerance would accept zero. - for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { - assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); - assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); - } - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); - close(ev("MEDIAN", vec![n(1e308)]), 1e308); - close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); - close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); - is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); - is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); - is_err(aggregate_values(12, &[]), CellError::Num); - is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); - } - - #[test] - fn averages_and_counts() { - close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); - let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); - close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); - close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); - close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); - is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); - close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); - close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); - close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); - close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); - let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); - close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); - close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); - close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); - is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); - assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); - } - - /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): - /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is - /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an - /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and - /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. - #[test] - fn mode_scalar_arguments() { - let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), - (vec![rf(data)], Ok(1.0)), - (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), - // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - // MODE(B9,2,D7) with D7 blank. - (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), - (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); - } - - /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). - #[test] - fn mode_skips_typed_text_and_logicals() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), - (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), - (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), - (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); - is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); - } - - #[test] - fn spread() { - let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; - close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); - close(ev("VAR.P", vec![c(&d)]), 678.84); - close(ev("VARP", vec![c(&d)]), 678.84); - is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); - close(ev("STDEV.P", vec![n(1.0)]), 0.0); - let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); - close(ev("VARA", vec![rf(mixed.clone())]), 1.0); - close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); - close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); - close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); - let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; - close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); - close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); - let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; - close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); - close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); - is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); - close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); - is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); - let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; - close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); - close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); - close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); - is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); - close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); - is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); - } - - #[test] - fn min_max_order() { - close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); - close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); - close(ev("MIN", vec![]), 0.0); - close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); - close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); - let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); - close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); - close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); - is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); - is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); - assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); - let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); - close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); - close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); - is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); - let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); - close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); - close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); - close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); - } - - #[test] - fn percentiles() { - close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); - close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); - is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); - let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); - close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); - close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); - close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); - let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); - close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); - close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); - is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); - let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); - close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); - is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); - is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); - close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); - 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); - } - - #[test] - fn pairs_and_regression() { - close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); - close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); - is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); - is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); - let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); - close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); - close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); - close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); - close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); - // Pairs with text are skipped. - let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); - close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); - } - - #[test] - fn linest_family() { - let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - assert_eq!(r.len(), 1); - close(r[0][0].clone(), 2.0); - close(r[0][1].clone(), 1.0); - // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. - let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); - assert_eq!((s.len(), s[0].len()), (5, 2)); - close(s[0][0].clone(), 1.5); - close(s[0][1].clone(), -2.0 / 3.0); - close(s[2][0].clone(), 0.9642857142857143); // r² - close(s[3][1].clone(), 1.0); // df - close(s[4][0].clone(), 4.5); // ssreg - close(s[4][1].clone(), 1.0 / 6.0); // ssresid - close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey - close(s[3][0].clone(), 27.0); // F - close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope - // No constant. - let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); - close(z[0][0].clone(), 2.0); - close(z[0][1].clone(), 0.0); - // Two predictors: y = 1 + 2a + 3b. - let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); - let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); - let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); - close(m[0][0].clone(), 3.0); - close(m[0][1].clone(), 2.0); - close(m[0][2].clone(), 1.0); - let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; - let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; - let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); - close_tol(l[0][0].clone(), 1.463275628, 1e-9); - close_tol(l[0][1].clone(), 495.3047702, 1e-9); - let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); - close_tol(g[0][0].clone(), 320196.7184, 1e-9); - close_tol(g[1][0].clone(), 468536.0539, 1e-9); - let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; - let tx: Vec = (1..=12).map(f64::from).collect(); - let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); - close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); - let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - close(fitted[1][0].clone(), 9.0); - is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); - is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); - } - - // #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) - // inversion; a wider TOROW aborted the process. It is now refused before allocating. - #[test] - fn linest_family_caps_regressor_count() { - let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); - let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); - for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { - // y is one cell, so every column of x is a predictor. - is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); - // y is a row, so every row of x is a predictor. - is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); - } - is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); - // At the cap the fit still runs (one observation: coefficients are zero, not an error). - assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); - // The rows × k² work bound. - assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); - assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); - assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); - assert_eq!(check_regression_size(10_000_000, 1), Ok(())); - } - - #[test] - fn frequency_prob() { - let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); - assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); - let x = c(&[0.0, 1.0, 2.0, 3.0]); - let p = c(&[0.2, 0.3, 0.1, 0.4]); - close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); - close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); - // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); - is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); - } - - #[test] - fn normal() { - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); - close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); - close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); - close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); - close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); - close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); - close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); - close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); - close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); - is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); - close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); - is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); - let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); - close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); - close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); - close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); - close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - } - - #[test] - fn t_chi_f() { - close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); - close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); - close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); - close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); - close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); - is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); - close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); - close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); - close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); - let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); - is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); - close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); - close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); - close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); - close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); - close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); - close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); - is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); - let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); - let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); - close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); - close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); - close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); - close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); - } - - #[test] - fn binom_inv_huge_trials_returns() { - // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. - let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; - for (name, trials, p, alpha) in cases { - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let r = ev(name, vec![n(trials), n(p), n(alpha)]); - let _ = tx.send(r); - }); - match rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} - Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), - Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), - } - } - } - - #[test] - fn discrete() { - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); - close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); - close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); - close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); - is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); - close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); - } - - #[test] - fn continuous() { - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); - close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); - close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); - is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); - close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); - close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); - is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); - is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); - close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); - close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - // Lifting over an array of x values. - let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); - close(r[0][0].clone(), 0.5); - close_tol(r[0][1].clone(), 0.841344746, 1e-8); - } - - #[test] - fn aggregate_helper() { - let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; - close(aggregate_values(1, &vals), 3.0); - close(aggregate_values(2, &vals), 3.0); - close(aggregate_values(3, &vals), 5.0); - close(aggregate_values(4, &vals), 6.0); - close(aggregate_values(105, &vals), 1.0); - close(aggregate_values(6, &vals), 12.0); - close(aggregate_values(9, &vals), 9.0); - close(aggregate_values(109, &vals), 9.0); - close(aggregate_values(11, &vals), 14.0 / 3.0); - close(aggregate_values(10, &vals), 7.0); - close(aggregate_values(12, &vals), 2.0); - is_err(aggregate_values(13, &vals), CellError::NA); - is_err(aggregate_values(14, &vals), CellError::Value); - is_err(aggregate_values(99, &vals), CellError::Value); - is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); - close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); - close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); - close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); - is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); - } -} +mod tests; diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs new file mode 100644 index 000000000..f4e4b0160 --- /dev/null +++ b/crates/functions/src/stat/descriptive.rs @@ -0,0 +1,558 @@ +use super::{Arg, C, CellError, R, Value, arg, array_numbers, array_val, as_array, has, num, num_val, opt_num}; + +// --------------------------------------------------------------------------------------------- +// Basic helpers over number lists +// --------------------------------------------------------------------------------------------- + +pub(super) fn mean(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(x.iter().sum::() / x.len() as f64) +} + +pub(super) fn devsq(x: &[f64]) -> f64 { + if x.is_empty() { + return 0.0; + } + let m = x.iter().sum::() / x.len() as f64; + x.iter().map(|v| (v - m) * (v - m)).sum() +} + +pub(super) fn var_s(x: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + Ok(devsq(x) / (x.len() - 1) as f64) +} + +pub(super) fn var_p(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(devsq(x) / x.len() as f64) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort_by(f64::total_cmp); + v +} + +pub(super) fn median(v: Vec) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let s = sorted(v); + let n = s.len(); + let hi = s.get(n / 2).copied().unwrap_or(0.0); + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } +} + +/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. +fn modes(v: &[f64]) -> Vec { + let mut counts: Vec<(f64, usize)> = Vec::new(); + for &x in v { + if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { + e.1 += 1; + } else { + counts.push((x, 1)); + } + } + let best = counts.iter().map(|e| e.1).max().unwrap_or(0); + if best < 2 { + return Vec::new(); + } + counts.into_iter().filter(|e| e.1 == best).map(|e| e.0).collect() +} + +pub(super) fn mode_sngl(v: &[f64]) -> R { + modes(v).first().copied().ok_or(CellError::NA) +} + +/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers +/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical +/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so +/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference +/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to +/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an +/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. +pub(super) fn mode_numbers(args: &[Arg]) -> R> { + let mut out = Vec::new(); + for a in args { + match &a.value { + Value::Array(arr) => { + for v in arr.iter() { + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + Value::Text(_) | Value::Bool(_) => {} + Value::Empty => return Err(CellError::Value), + } + } + Ok(out) +} + +fn product(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().product() } +} + +pub(super) fn min_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) } +} + +pub(super) fn max_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } +} + +/// k-th largest (k is rounded up like Excel). +fn kth(v: Vec, k: f64, largest: bool) -> R { + let k = k.ceil(); + if k.is_nan() || k < 1.0 || k > v.len() as f64 { + return Err(CellError::Num); + } + let s = sorted(v); + let k = k as usize; + let idx = if largest { s.len() - k } else { k - 1 }; + s.get(idx).copied().ok_or(CellError::Num) +} + +fn interp(s: &[f64], pos: f64) -> R { + let i = pos.floor(); + let frac = pos - i; + let i = i as usize; + let lo = *s.get(i).ok_or(CellError::Num)?; + match s.get(i + 1) { + Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), + _ => Ok(lo), + } +} + +pub(super) fn percentile_inc(v: Vec, k: f64) -> R { + if v.is_empty() || !(0.0..=1.0).contains(&k) { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, k * (s.len() - 1) as f64) +} + +pub(super) fn percentile_exc(v: Vec, k: f64) -> R { + let n = v.len() as f64; + if v.is_empty() || !(k > 0.0 && k < 1.0) { + return Err(CellError::Num); + } + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, pos - 1.0) +} + +pub(super) fn quartile_inc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(0.0..=4.0).contains(&q) { + return Err(CellError::Num); + } + percentile_inc(v, q / 4.0) +} + +pub(super) fn quartile_exc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(1.0..=3.0).contains(&q) { + return Err(CellError::Num); + } + percentile_exc(v, q / 4.0) +} + +pub(super) fn arg_nums(a: &[Arg], i: usize) -> R> { + array_numbers(&arg(a, i)?.value) +} + +/// Pairs of numbers at matching positions of two arrays (pairs with a non-number skipped). +/// Different sizes give `#N/A`; errors propagate. +fn pairs(x: &Value, y: &Value) -> R<(Vec, Vec)> { + let xa = as_array(x); + let ya = as_array(y); + if xa.data.len() != ya.data.len() { + return Err(CellError::NA); + } + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for (a, b) in xa.data.iter().zip(ya.data.iter()) { + if let Value::Error(e) = a { + return Err(*e); + } + if let Value::Error(e) = b { + return Err(*e); + } + if let (Value::Number(p), Value::Number(q)) = (a, b) { + xs.push(*p); + ys.push(*q); + } + } + Ok((xs, ys)) +} + +pub(super) fn arg_pairs(a: &[Arg], i: usize, j: usize) -> R<(Vec, Vec)> { + pairs(&arg(a, i)?.value, &arg(a, j)?.value) +} + +/// (sum of cross deviations, devsq x, devsq y, mean x, mean y). +pub(super) fn moments(x: &[f64], y: &[f64]) -> R<(f64, f64, f64, f64, f64)> { + let mx = mean(x)?; + let my = mean(y)?; + let mut sxy = 0.0; + let mut sxx = 0.0; + let mut syy = 0.0; + for (a, b) in x.iter().zip(y) { + sxy += (a - mx) * (b - my); + sxx += (a - mx) * (a - mx); + syy += (b - my) * (b - my); + } + Ok((sxy, sxx, syy, mx, my)) +} + +pub(super) fn correl(x: &[f64], y: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(x, y)?; + if sxx == 0.0 || syy == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / (sxx * syy).sqrt()) +} + +pub(super) fn slope(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, _, _) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / sxx) +} + +pub(super) fn intercept(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, mx, my) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(my - sxy / sxx * mx) +} + +fn trunc_digits(r: f64, sig: f64) -> f64 { + let f = 10f64.powi(sig as i32); + if !f.is_finite() { + return r; + } + ((r * f) + 1e-9).floor() / f +} + +fn col_value(v: Vec) -> R { + let n = v.len(); + array_val(n, 1, v.into_iter().map(Value::Number).collect()) +} + +// --------------------------------------------------------------------------------------------- +// Aggregates +// --------------------------------------------------------------------------------------------- + +pub(super) fn count_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| v.is_number()).count(), + Value::Number(_) => c += 1, + _ if x.from_ref => {} + Value::Bool(_) => c += 1, + Value::Text(t) if gridcraft_core::parse::parse_number_text(t).is_some() => c += 1, + _ => {} + } + } + c as f64 +} + +pub(super) fn counta_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| !v.is_empty()).count(), + Value::Empty if x.from_ref => {} + _ => c += 1, + } + } + c as f64 +} + +pub(super) fn countblank(a: &[Arg], _: C) -> R { + let v = &arg(a, 0)?.value; + let blank = |x: &Value| matches!(x, Value::Empty) || matches!(x, Value::Text(t) if t.is_empty()); + let c = match v { + Value::Array(arr) => arr.iter().filter(|x| blank(x)).count(), + x => usize::from(blank(x)), + }; + num_val(c as f64) +} + +pub(super) fn mode_mult(a: &[Arg], _: C) -> R { + let v = mode_numbers(a)?; + let m = modes(&v); + if m.is_empty() { + return Err(CellError::NA); + } + col_value(m) +} + +pub(super) fn large(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, true)?) +} + +pub(super) fn small(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, false)?) +} + +pub(super) fn rank_impl(a: &[Arg], avg: bool) -> R { + let x = num(a, 0)?; + let v = arg_nums(a, 1)?; + let asc = opt_num(a, 2, 0.0)? != 0.0; + let eq = v.iter().filter(|&&y| y == x).count(); + if eq == 0 { + return Err(CellError::NA); + } + let before = v.iter().filter(|&&y| if asc { y < x } else { y > x }).count(); + let r = before as f64 + 1.0; + num_val(if avg { r + (eq as f64 - 1.0) / 2.0 } else { r }) +} + +pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { + let s = sorted(arg_nums(a, 0)?); + let x = num(a, 1)?; + let sig = opt_num(a, 2, 3.0)?.trunc(); + 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); + } + let n = s.len() as f64; + let rank_of = |v: f64| -> f64 { + let less = s.iter().filter(|&&y| y < v).count() as f64; + if exc { + (less + 1.0) / (n + 1.0) + } else if n <= 1.0 { + 1.0 + } else { + less / (n - 1.0) + } + }; + let r = if s.contains(&x) { + rank_of(x) + } else { + let lo = s.iter().copied().filter(|&y| y < x).fold(f64::NEG_INFINITY, f64::max); + let hi = s.iter().copied().filter(|&y| y > x).fold(f64::INFINITY, f64::min); + 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)) +} + +pub(super) fn trimmean(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let p = num(a, 1)?; + if !(0.0..1.0).contains(&p) || v.is_empty() { + return Err(CellError::Num); + } + let n = v.len(); + let per_side = ((n as f64 * p) / 2.0).floor() as usize; + let s = sorted(v); + let kept = s.get(per_side..n - per_side).unwrap_or(&[]); + num_val(mean(kept)?) +} + +pub(super) fn kurt(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 4 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum4: f64 = v.iter().map(|x| ((x - m) / s).powi(4)).sum(); + Ok(n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * sum4 - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0))) +} + +pub(super) fn skew(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 3 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum3: f64 = v.iter().map(|x| ((x - m) / s).powi(3)).sum(); + Ok(n / ((n - 1.0) * (n - 2.0)) * sum3) +} + +pub(super) fn skew_p(v: &[f64]) -> R { + let m = mean(v)?; + let s = var_p(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + Ok(v.iter().map(|x| ((x - m) / s).powi(3)).sum::() / v.len() as f64) +} + +pub(super) fn geomean(v: &[f64]) -> R { + if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok((v.iter().map(|x| x.ln()).sum::() / v.len() as f64).exp()) +} + +pub(super) fn harmean(v: &[f64]) -> R { + if v.is_empty() { + return Err(CellError::NA); + } + if v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) +} + +pub(super) fn avedev(v: &[f64]) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let m = mean(v)?; + Ok(v.iter().map(|x| (x - m).abs()).sum::() / v.len() as f64) +} + +pub(super) fn frequency(a: &[Arg], _: C) -> R { + let data = arg_nums(a, 0)?; + let bins = arg_nums(a, 1)?; + if bins.is_empty() { + return col_value(vec![data.len() as f64]); + } + let mut order: Vec = (0..bins.len()).collect(); + order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); + let mut counts = vec![0.0; bins.len() + 1]; + for x in data { + let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); + if let Some(c) = counts.get_mut(slot) { + *c += 1.0; + } + } + col_value(counts) +} + +pub(super) fn prob(a: &[Arg], _: C) -> R { + let (xs, ps) = arg_pairs(a, 0, 1)?; + let lo = num(a, 2)?; + let hi = if has(a, 3) { num(a, 3)? } else { lo }; + if ps.iter().any(|&p| p <= 0.0 || p > 1.0) { + return Err(CellError::Num); + } + let total: f64 = ps.iter().sum(); + if (total - 1.0).abs() > 1e-7 { + return Err(CellError::Num); + } + num_val(xs.iter().zip(&ps).filter(|(x, _)| **x >= lo && **x <= hi).map(|(_, p)| p).sum()) +} + +pub(super) fn standardize(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + num_val((x - m) / sd) +} + +// --------------------------------------------------------------------------------------------- +// SUBTOTAL / AGGREGATE support +// --------------------------------------------------------------------------------------------- + +/// Evaluates SUBTOTAL/AGGREGATE function `function_num` over already-filtered cell values. +/// +/// Codes 1–11 (and 101–111) are AVERAGE, COUNT, COUNTA, MAX, MIN, PRODUCT, STDEV.S, STDEV.P, +/// SUM, VAR.S, VAR.P; 12 = MEDIAN, 13 = MODE.SNGL. Codes 14–19 need `k`: use +/// [`aggregate_values_k`] (they give `#VALUE!` here). Values that are not numbers are ignored +/// (COUNTA counts non-empty values); the first error value present is returned. +pub fn aggregate_values(function_num: u32, values: &[Value]) -> Value { + crate::util::finish(aggregate_impl(function_num, values, None)) +} + +/// AGGREGATE codes 14 = LARGE, 15 = SMALL, 16 = PERCENTILE.INC, 17 = QUARTILE.INC, +/// 18 = PERCENTILE.EXC, 19 = QUARTILE.EXC with their `k` argument; codes 1–13 delegate to +/// [`aggregate_values`]. +pub fn aggregate_values_k(function_num: u32, values: &[Value], k: &Value) -> Value { + crate::util::finish(aggregate_impl(function_num, values, Some(k))) +} + +fn aggregate_impl(function_num: u32, values: &[Value], k: Option<&Value>) -> R { + let code = if (101..=111).contains(&function_num) { function_num - 100 } else { function_num }; + let mut xs = Vec::new(); + let mut nonempty = 0usize; + let mut push = |v: &Value| -> R<()> { + match v { + Value::Number(n) => xs.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + if !v.is_empty() { + nonempty += 1; + } + Ok(()) + }; + for v in values { + match v { + Value::Array(arr) => arr.iter().try_for_each(&mut push)?, + other => push(other)?, + } + } + let k = match (code, k) { + (14..=19, Some(k)) => k.scalar().to_number()?, + (14..=19, None) => return Err(CellError::Value), + _ => 0.0, + }; + let r = match code { + 1 => mean(&xs)?, + 2 => xs.len() as f64, + 3 => nonempty as f64, + 4 => max_of(&xs), + 5 => min_of(&xs), + 6 => product(&xs), + 7 => var_s(&xs)?.sqrt(), + 8 => var_p(&xs)?.sqrt(), + 9 => xs.iter().sum(), + 10 => var_s(&xs)?, + 11 => var_p(&xs)?, + 12 => median(xs)?, + 13 => mode_sngl(&xs)?, + 14 => kth(xs, k, true)?, + 15 => kth(xs, k, false)?, + 16 => percentile_inc(xs, k)?, + 17 => quartile_inc(xs, k)?, + 18 => percentile_exc(xs, k)?, + 19 => quartile_exc(xs, k)?, + _ => return Err(CellError::Value), + }; + num_val(r) +} diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs new file mode 100644 index 000000000..a3964ad80 --- /dev/null +++ b/crates/functions/src/stat/distributions.rs @@ -0,0 +1,731 @@ +use super::descriptive::{arg_nums, arg_pairs, mean, var_s}; +use super::{ + Arg, C, CellError, R, Value, arg, as_array, boolean, erf, gamma, has, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, num, num_val, + reg_gamma_p, reg_gamma_q, reg_inc_beta, +}; + +// --------------------------------------------------------------------------------------------- +// Distributions +// --------------------------------------------------------------------------------------------- + +fn ln_comb(n: f64, k: f64) -> f64 { + ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) +} + +fn t_upper(t: f64, v: f64) -> f64 { + // P(T > t) for t ≥ 0. + 0.5 * reg_inc_beta(v / (v + t * t), v / 2.0, 0.5) +} + +fn t_cdf(t: f64, v: f64) -> f64 { + if t >= 0.0 { 1.0 - t_upper(t, v) } else { t_upper(-t, v) } +} + +fn t_pdf(t: f64, v: f64) -> f64 { + let z = v / 2.0; + let log_gamma_ratio = if z >= 16.0 { + let inv_z = 1.0 / z; + 0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0 + } else { + ln_gamma(z + 0.5) - ln_gamma(z) + }; + (log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp() +} + +/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. +fn t_inv_upper(q: f64, v: f64) -> f64 { + if q >= 0.5 { + return 0.0; + } + invert(|t| t_upper(t, v), q, 0.0, 1.0, 1e60) +} + +fn chi_inv(p: f64, v: f64) -> f64 { + if p <= 0.0 { + return 0.0; + } + invert(|x| reg_gamma_p(v / 2.0, x / 2.0), p, 0.0, v.max(1.0), 1e60) +} + +fn chi_inv_rt(q: f64, v: f64) -> f64 { + if q >= 1.0 { + return 0.0; + } + invert(|x| reg_gamma_q(v / 2.0, x / 2.0), q, 0.0, v.max(1.0), 1e60) +} + +fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 0.0; + } + if x == 1.0 && d1 == d2 { + return 0.5; + } + if d1 >= 1e8 && d2 / d1 <= 1e-8 { + return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1))); + } + reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) +} + +fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 1.0; + } + if d1 >= 1e8 && d2 / d1 <= 1e-8 { + // With a much larger numerator degree, the scaled beta tail converges to a gamma tail. + return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1))); + } + reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) +} + +fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { + if x == 0.0 { + return if d1 < 2.0 { + f64::INFINITY + } else if d1 == 2.0 { + 1.0 + } else { + 0.0 + }; + } + ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() +} + +const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0; + +fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 || k > n { + return 0.0; + } + if p == 0.0 { + return if k == 0.0 { 1.0 } else { 0.0 }; + } + if p == 1.0 { + return if k == n { 1.0 } else { 0.0 }; + } + (ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp() +} + +fn binom_cdf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 { + return 0.0; + } + if k >= n { + return 1.0; + } + if n <= 10_000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= k { + s += binom_pmf(i, n, p); + i += 1.0; + } + return s.min(1.0); + } + if p == 0.0 { + return 1.0; + } + if p == 1.0 { + return 0.0; + } + reg_inc_beta(1.0 - p, n - k, k + 1.0) +} + +fn poisson_pmf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return if x == 0.0 { 1.0 } else { 0.0 }; + } + (-m + x * m.ln() - ln_gamma(x + 1.0)).exp() +} + +fn poisson_cdf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return 1.0; + } + if x <= 1000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= x { + s += poisson_pmf(i, m); + i += 1.0; + } + return s.min(1.0); + } + reg_gamma_q(x + 1.0, m) +} + +pub(super) fn norm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / sd }) +} + +pub(super) fn norm_inv_fn(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val(m + sd * norm_inv(p)) +} + +pub(super) fn norm_s_dist(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + let cum = if a.len() > 1 { boolean(a, 1)? } else { true }; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) }) +} + +pub(super) fn norm_s_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(norm_inv(p)) +} + +fn df_arg(a: &[Arg], i: usize) -> R { + let v = num(a, i)?.trunc(); + if v < 1.0 { + return Err(CellError::Num); + } + Ok(v) +} + +pub(super) fn t_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = num(a, 1)?.trunc(); + let cum = boolean(a, 2)?; + if v == 0.0 { + return Err(if cum { CellError::Num } else { CellError::Div0 }); + } + if v < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) +} + +pub(super) fn t_dist_2t(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(2.0 * t_upper(x, v)) +} + +pub(super) fn t_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + num_val(1.0 - t_cdf(x, v)) +} + +pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let tails = num(a, 2)?.trunc(); + if x < 0.0 || !(tails == 1.0 || tails == 2.0) { + return Err(CellError::Num); + } + num_val(tails * t_upper(x, v)) +} + +pub(super) fn t_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if p == 0.0 { + return Err(CellError::Div0); + } + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(if p < 0.5 { -t_inv_upper(p, v) } else { t_inv_upper(1.0 - p, v) }) +} + +pub(super) fn t_inv_2t(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + num_val(t_inv_upper(p / 2.0, v)) +} + +pub(super) fn t_test(a: &[Arg], _: C) -> R { + let tails = num(a, 2)?.trunc(); + let ty = num(a, 3)?.trunc(); + if !(tails == 1.0 || tails == 2.0) || !(1.0..=3.0).contains(&ty) { + return Err(CellError::Num); + } + let (t, v) = if ty == 1.0 { + let (x, y) = arg_pairs(a, 0, 1)?; + let d: Vec = x.iter().zip(&y).map(|(p, q)| p - q).collect(); + let n = d.len() as f64; + let sd = var_s(&d)?.sqrt(); + if sd == 0.0 { + return Err(CellError::Div0); + } + (mean(&d)? / (sd / n.sqrt()), n - 1.0) + } else { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (n1, n2) = (x.len() as f64, y.len() as f64); + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + let diff = mean(&x)? - mean(&y)?; + if ty == 2.0 { + let df = n1 + n2 - 2.0; + let sp = ((n1 - 1.0) * v1 + (n2 - 1.0) * v2) / df; + let se = (sp * (1.0 / n1 + 1.0 / n2)).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + (diff / se, df) + } else { + let (a1, a2) = (v1 / n1, v2 / n2); + let se = (a1 + a2).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + let df = (a1 + a2).powi(2) / (a1 * a1 / (n1 - 1.0) + a2 * a2 / (n2 - 1.0)); + (diff / se, df) + } + }; + num_val(tails * t_upper(t.abs(), v)) +} + +pub(super) fn chisq_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(v / 2.0, x / 2.0)); + } + if x == 0.0 { + return if v == 2.0 { + num_val(0.5) + } else if v < 2.0 { + Err(CellError::Num) + } else { + num_val(0.0) + }; + } + num_val(((v / 2.0 - 1.0) * x.ln() - x / 2.0 - (v / 2.0) * 2f64.ln() - ln_gamma(v / 2.0)).exp()) +} + +pub(super) fn chisq_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + num_val(reg_gamma_q(v / 2.0, x / 2.0)) +} + +pub(super) fn chisq_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(0.0..1.0).contains(&p) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv(p, v)) +} + +pub(super) fn chisq_inv_rt(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv_rt(p, v)) +} + +pub(super) fn chisq_test(a: &[Arg], _: C) -> R { + let act = as_array(&arg(a, 0)?.value).into_owned(); + let exp = as_array(&arg(a, 1)?.value).into_owned(); + if act.rows != exp.rows || act.cols != exp.cols { + return Err(CellError::NA); + } + let mut x2 = 0.0; + for (o, e) in act.data.iter().zip(&exp.data) { + if let Value::Error(err) = o { + return Err(*err); + } + if let Value::Error(err) = e { + return Err(*err); + } + if let (Value::Number(o), Value::Number(e)) = (o, e) { + if *e == 0.0 { + return Err(CellError::Div0); + } + x2 += (o - e).powi(2) / e; + } + } + let df = if act.rows > 1 && act.cols > 1 { ((act.rows - 1) * (act.cols - 1)) as f64 } else { (act.data.len().saturating_sub(1)) as f64 }; + if df < 1.0 { + return Err(CellError::NA); + } + num_val(reg_gamma_q(df / 2.0, x2 / 2.0)) +} + +fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { + let x = num(a, 0)?; + let d1 = num(a, 1)?.trunc(); + let d2 = num(a, 2)?.trunc(); + if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 { + return Err(CellError::Num); + } + Ok((x, d1, d2)) +} + +pub(super) fn f_dist(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + let cum = boolean(a, 3)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { f_cdf(x, d1, d2) } else { f_pdf(x, d1, d2) }) +} + +pub(super) fn f_dist_rt(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(f_upper(x, d1, d2)) +} + +pub(super) fn f_inv(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(0.0..1.0).contains(&p) { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) }; + num_val(value) +} + +pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + if p == 1.0 { + return num_val(0.0); + } + num_val(invert(|x| f_upper(x, d1, d2), p, 0.0, 1.0, 1e60)) +} + +pub(super) fn f_test(a: &[Arg], _: C) -> R { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + if v1 == 0.0 || v2 == 0.0 { + return Err(CellError::Div0); + } + let f = v1 / v2; + let (d1, d2) = ((x.len() - 1) as f64, (y.len() - 1) as f64); + let c = f_cdf(f, d1, d2); + num_val((2.0 * c.min(1.0 - c)).min(1.0)) +} + +pub(super) fn binom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = boolean(a, 3)?; + if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) +} + +pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let s1 = num(a, 2)?.trunc(); + let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; + if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + return Err(CellError::Num); + } + if s2 - s1 > 10_000.0 { + return num_val(binom_cdf(s2, n, p) - binom_cdf(s1 - 1.0, n, p)); + } + let mut s = 0.0; + let mut k = s1; + while k <= s2 { + s += binom_pmf(k, n, p); + k += 1.0; + } + num_val(s) +} + +pub(super) fn binom_inv(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let alpha = num(a, 2)?; + if n < 0.0 || !(0.0..=1.0).contains(&p) || !(0.0..=1.0).contains(&alpha) { + return Err(CellError::Num); + } + // Smallest k with cdf(k) ≥ alpha, by bisection on the integer k. + let (mut lo, mut hi) = (0.0f64, n); + if binom_cdf(0.0, n, p) >= alpha { + return num_val(0.0); + } + while hi - lo > 1.0 { + let mid = ((lo + hi) / 2.0).floor(); + // Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking. + if mid <= lo || mid >= hi { + break; + } + if binom_cdf(mid, n, p) >= alpha { + hi = mid; + } else { + lo = mid; + } + } + num_val(hi) +} + +pub(super) fn poisson_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?.trunc(); + let m = num(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || m < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { poisson_cdf(x, m) } else { poisson_pmf(x, m) }) +} + +pub(super) fn expon_dist(a: &[Arg], _: C) -> R { + let (x, l, cum) = (num(a, 0)?, num(a, 1)?, boolean(a, 2)?); + if x < 0.0 || l <= 0.0 { + return Err(CellError::Num); + } + num_val(if cum { -(-l * x).exp_m1() } else { l * (-l * x).exp() }) +} + +pub(super) fn gamma_fn(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let g = gamma(x); + if !g.is_finite() { + return Err(CellError::Num); + } + num_val(g) +} + +pub(super) fn gamma_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(al, x / be)); + } + if x == 0.0 { + return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) }; + } + num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) +} + +pub(super) fn gamma_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(0.0..1.0).contains(&p) || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + num_val(invert(|x| reg_gamma_p(al, x / be), p, 0.0, (al * be).max(1e-3), 1e60)) +} + +pub(super) fn gammaln(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(ln_gamma(x)) +} + +fn beta_bounds(a: &[Arg], i: usize) -> R<(f64, f64)> { + let lo = if has(a, i) { num(a, i)? } else { 0.0 }; + let hi = if has(a, i + 1) { num(a, i + 1)? } else { 1.0 }; + if lo >= hi { + return Err(CellError::Num); + } + Ok((lo, hi)) +} + +pub(super) fn beta_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + let (lo, hi) = beta_bounds(a, 4)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + let z = (x - lo) / (hi - lo); + if cum { + return num_val(reg_inc_beta(z, al, be)); + } + if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) { + return Err(CellError::Num); + } + let lp = if z == 0.0 || z == 1.0 { + let base = if z == 0.0 { al } else { be }; + if base == 1.0 { -ln_beta(al, be) } else { f64::NEG_INFINITY } + } else { + (al - 1.0) * z.ln() + (be - 1.0) * (1.0 - z).ln() - ln_beta(al, be) + }; + num_val(lp.exp() / (hi - lo)) +} + +pub(super) fn betadist_legacy(a: &[Arg], _: C) -> R { + let (x, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + num_val(reg_inc_beta((x - lo) / (hi - lo), al, be)) +} + +pub(super) fn beta_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + let z = if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else { + invert(|x| reg_inc_beta(x, al, be), p, 0.0, 1.0, 1.0) + }; + num_val(lo + z * (hi - lo)) +} + +pub(super) fn lognorm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let cum = if a.len() > 3 { boolean(a, 3)? } else { true }; + if x <= 0.0 || sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x.ln() - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / (x * sd) }) +} + +pub(super) fn lognorm_inv(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val((m + sd * norm_inv(p)).exp()) +} + +pub(super) fn weibull_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if x == 0.0 { + return num_val(0.0); + } + let t = (x / be).powf(al); + num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) +} + +pub(super) fn hypgeom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let kk = num(a, 2)?.trunc(); + let nn = num(a, 3)?.trunc(); + let cum = if a.len() > 4 { boolean(a, 4)? } else { false }; + if k < 0.0 || n <= 0.0 || kk <= 0.0 || nn <= 0.0 || n > nn || kk > nn || k > n.min(kk) || k < (n - nn + kk).max(0.0) { + return Err(CellError::Num); + } + let pmf = |i: f64| (ln_comb(kk, i) + ln_comb(nn - kk, n - i) - ln_comb(nn, n)).exp(); + if !cum { + return num_val(pmf(k)); + } + let mut s = 0.0; + let mut i = (n - nn + kk).max(0.0); + if k - i > 1e6 || i + 1.0 == i { + return Err(CellError::Num); + } + while i <= k { + s += pmf(i); + i += 1.0; + } + num_val(s.min(1.0)) +} + +pub(super) fn negbinom_dist(a: &[Arg], _: C) -> R { + let f = num(a, 0)?.trunc(); + let s = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = if a.len() > 3 { boolean(a, 3)? } else { false }; + if f < 0.0 || s < 1.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + if cum { + if p == 0.0 { + return num_val(0.0); + } + return num_val(reg_inc_beta(p, s, f + 1.0)); + } + if p == 0.0 || p == 1.0 { + return num_val(if p == 1.0 && f == 0.0 { 1.0 } else { 0.0 }); + } + num_val((ln_comb(f + s - 1.0, s - 1.0) + s * p.ln() + f * (1.0 - p).ln()).exp()) +} + +pub(super) fn confidence_norm(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(norm_inv(1.0 - al / 2.0) * sd / n.sqrt()) +} + +pub(super) fn confidence_t(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + if n == 1.0 { + return Err(CellError::Div0); + } + num_val(t_inv_upper(al / 2.0, n - 1.0) * sd / n.sqrt()) +} + +pub(super) fn z_test(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let x = num(a, 1)?; + if v.is_empty() { + return Err(CellError::NA); + } + let sd = if has(a, 2) { num(a, 2)? } else { var_s(&v)?.sqrt() }; + if sd <= 0.0 { + return Err(if has(a, 2) { CellError::Num } else { CellError::Div0 }); + } + let z = (mean(&v)? - x) / (sd / (v.len() as f64).sqrt()); + num_val(1.0 - norm_cdf(z)) +} + +pub(super) fn fisher(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= -1.0 || x >= 1.0 { + return Err(CellError::Num); + } + num_val(0.5 * ((1.0 + x) / (1.0 - x)).ln()) +} + +pub(super) fn gauss(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + // Φ(z) − 0.5 = erf(z/√2)/2, accurate near zero. + num_val(0.5 * erf(z / std::f64::consts::SQRT_2)) +} diff --git a/crates/functions/src/stat/regression.rs b/crates/functions/src/stat/regression.rs new file mode 100644 index 000000000..55bf0aec7 --- /dev/null +++ b/crates/functions/src/stat/regression.rs @@ -0,0 +1,342 @@ +use super::descriptive::{arg_pairs, intercept, moments, slope}; +use super::{Arg, C, CellError, R, Value, arg, array_val, as_array, boolean, has, num, num_val}; + +pub(super) fn forecast(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let (ys, xs) = arg_pairs(a, 1, 2)?; + if ys.is_empty() { + return Err(CellError::Div0); + } + num_val(intercept(&ys, &xs)? + slope(&ys, &xs)? * x) +} + +pub(super) fn steyx(a: &[Arg], _: C) -> R { + let (ys, xs) = arg_pairs(a, 0, 1)?; + let n = ys.len() as f64; + if ys.len() < 3 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(&xs, &ys)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + num_val(((syy - sxy * sxy / sxx) / (n - 2.0)).max(0.0).sqrt()) +} + +pub(super) fn covariance(a: &[Arg], sample: bool) -> R { + let (xs, ys) = arg_pairs(a, 0, 1)?; + let n = xs.len(); + if n == 0 || (sample && n < 2) { + return Err(CellError::Div0); + } + let (sxy, ..) = moments(&xs, &ys)?; + num_val(sxy / if sample { (n - 1) as f64 } else { n as f64 }) +} + +// --------------------------------------------------------------------------------------------- +// Regression (LINEST, LOGEST, TREND, GROWTH) +// --------------------------------------------------------------------------------------------- + +struct Xy { + y: Vec, + /// n rows of k predictor values. + x: Vec>, + k: usize, + y_is_row: bool, + y_rows: usize, + y_cols: usize, +} + +fn strict_num(v: &Value) -> R { + match v { + Value::Number(n) => Ok(*n), + Value::Error(e) => Err(*e), + _ => Err(CellError::Value), + } +} + +/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a +/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated +/// gigabytes and could abort the process. +pub(super) const MAX_REGRESSORS: usize = 255; + +/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. +const MAX_REGRESSION_WORK: usize = 1_000_000_000; + +/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, +/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`. +pub(super) fn check_regression_size(n: usize, k: usize) -> R<()> { + if k > MAX_REGRESSORS { + return Err(CellError::Value); + } + match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) { + Some(work) if work <= MAX_REGRESSION_WORK => Ok(()), + _ => Err(CellError::Num), + } +} + +fn parse_xy(y: &Value, x: Option<&Value>) -> R { + let ya = as_array(y); + let n = ya.data.len(); + let y_is_row = ya.rows == 1 && ya.cols > 1; + let yv: Vec = ya.data.iter().map(strict_num).collect::>()?; + let (xs, k) = match x { + None => ((1..=n).map(|i| vec![i as f64]).collect(), 1), + Some(xv) => { + let xa = as_array(xv); + if xa.rows == ya.rows && xa.cols == ya.cols { + (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) + } else if !y_is_row && xa.rows == n { + check_regression_size(n, xa.cols)?; + let mut rows = Vec::with_capacity(n); + for r in 0..n { + rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.cols) + } else if y_is_row && xa.cols == n { + check_regression_size(n, xa.rows)?; + let mut rows = Vec::with_capacity(n); + for c in 0..n { + rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.rows) + } else { + return Err(CellError::Ref); + } + } + }; + Ok(Xy { y: yv, x: xs, k, y_is_row, y_rows: ya.rows, y_cols: ya.cols }) +} + +struct Fit { + coef: Vec, + b: f64, + se: Vec, + se_b: Option, + r2: R, + sey: R, + f: R, + df: f64, + ssreg: f64, + ssresid: f64, +} + +/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping +/// (zeroing) collinear variables. Returns the inverse and the dropped flags. +fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { + let p = m.len(); + let mut a: Vec> = m.to_vec(); + let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); + let mut dropped = vec![false; p]; + let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); + for j in 0..p { + let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + let scale = diag.get(j).copied().unwrap_or(0.0); + if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { + if let Some(d) = dropped.get_mut(j) { + *d = true; + } + for row in a.iter_mut() { + if let Some(v) = row.get_mut(j) { + *v = 0.0; + } + } + if let Some(r) = a.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + if let Some(r) = inv.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + continue; + } + let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + for i in 0..p { + if i == j { + continue; + } + let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + if let Some(r) = a.get_mut(i) { + r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); + } + if let Some(r) = inv.get_mut(i) { + r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); + } + } + if let Some(r) = a.get_mut(j) { + *r = arow; + } + if let Some(r) = inv.get_mut(j) { + *r = irow; + } + } + (inv, dropped) +} + +fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { + let n = y.len(); + if n == 0 || x.len() != n { + return Err(CellError::Value); + } + check_regression_size(n, k)?; + let nf = n as f64; + let (xm, ym) = if constant { + let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); + (xm, y.iter().sum::() / nf) + } else { + (vec![0.0; k], 0.0) + }; + // Normal equations on (centred) data. + let mut xtx = vec![vec![0.0; k]; k]; + let mut xty = vec![0.0; k]; + for (row, yv) in x.iter().zip(y) { + let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); + let yd = yv - ym; + for (i, di) in d.iter().enumerate() { + if let Some(t) = xty.get_mut(i) { + *t += di * yd; + } + if let Some(r) = xtx.get_mut(i) { + for (j, dj) in d.iter().enumerate() { + if let Some(v) = r.get_mut(j) { + *v += di * dj; + } + } + } + } + } + let (inv, dropped) = sym_inverse(&xtx); + let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); + let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; + let mut ssresid = 0.0; + let mut sstot = 0.0; + for (row, yv) in x.iter().zip(y) { + let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); + ssresid += (yv - pred).powi(2); + sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; + } + let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); + let df = nf - used as f64; + let ssreg = sstot - ssresid; + let r2 = if sstot == 0.0 { Err(CellError::Num) } else { Ok(ssreg / sstot) }; + let sey2 = if df > 0.0 { Ok(ssresid / df) } else { Err(CellError::Num) }; + let sey = sey2.map(f64::sqrt); + let v1 = if constant { nf - df - 1.0 } else { nf - df }; + let f = match sey2 { + Ok(s2) if s2 > 0.0 && v1 > 0.0 => Ok((ssreg / v1) / s2), + _ => Err(CellError::Num), + }; + let s2 = sey2.unwrap_or(f64::NAN); + let se: Vec = (0..k) + .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) + .collect(); + let se_b = if constant { + let mut q = 0.0; + for (i, mi) in xm.iter().enumerate() { + for (j, mj) in xm.iter().enumerate() { + q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + } + } + Some((s2 * (1.0 / nf + q)).sqrt()) + } else { + None + }; + Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) +} + +fn const_arg(a: &[Arg], i: usize) -> R { + if has(a, i) { boolean(a, i) } else { Ok(true) } +} + +fn opt_value(a: &[Arg], i: usize) -> Option<&Value> { + if has(a, i) { a.get(i).map(|x| &x.value) } else { None } +} + +pub(super) fn linest_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 2)?; + let stats = if has(a, 3) { boolean(a, 3)? } else { false }; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let tr = |v: f64| if log { v.exp() } else { v }; + let cols = xy.k + 1; + let mut data: Vec = Vec::new(); + for c in fit.coef.iter().rev() { + data.push(Value::number(tr(*c))); + } + data.push(Value::number(tr(fit.b))); + if stats { + let rv = |r: R| r.map(Value::number).unwrap_or_else(Value::Error); + let na = Value::Error(CellError::NA); + for s in fit.se.iter().rev() { + data.push(Value::number(*s)); + } + data.push(fit.se_b.map(Value::number).unwrap_or(na.clone())); + let rows: [[Value; 2]; 3] = + [[rv(fit.r2), rv(fit.sey)], [rv(fit.f), Value::number(fit.df)], [Value::number(fit.ssreg), Value::number(fit.ssresid)]]; + for [p, q] in rows { + data.push(p); + if cols > 1 { + data.push(q); + } + for _ in 2..cols { + data.push(na.clone()); + } + } + return array_val(5, cols, data); + } + array_val(1, cols, data) +} + +pub(super) fn trend_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 3)?; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let predict = |row: &[f64]| -> Value { + let v = fit.b + fit.coef.iter().zip(row).map(|(c, x)| c * x).sum::(); + Value::number(if log { v.exp() } else { v }) + }; + match opt_value(a, 2) { + None => { + let data: Vec = xy.x.iter().map(|r| predict(r)).collect(); + array_val(xy.y_rows, xy.y_cols, data) + } + Some(nv) => { + let na = as_array(nv); + if xy.k == 1 { + let data = na.data.iter().map(|v| strict_num(v).map(|x| predict(&[x]))).collect::>>()?; + array_val(na.rows, na.cols, data) + } else if !xy.y_is_row && na.cols == xy.k { + let mut data = Vec::with_capacity(na.rows); + for r in 0..na.rows { + let row = (0..na.cols).map(|c| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(na.rows, 1, data) + } else if xy.y_is_row && na.rows == xy.k { + let mut data = Vec::with_capacity(na.cols); + for c in 0..na.cols { + let row = (0..na.rows).map(|r| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(1, na.cols, data) + } else { + Err(CellError::Ref) + } + } + } +} diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs new file mode 100644 index 000000000..ba59116cd --- /dev/null +++ b/crates/functions/src/stat/tests.rs @@ -0,0 +1,619 @@ +use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; +use crate::util::testutil::*; +use gridcraft_core::{CellError, Value}; + +const D7: f64 = 1e-6; + +fn c(xs: &[f64]) -> crate::Arg { + av(col(xs)) +} + +// The two middle values are finite and their midpoint is representable even when +// adding them first would overflow. Exercise both callers of the shared helper. +#[test] +fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } +} + +#[test] +fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); +} + +#[test] +fn averages_and_counts() { + close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); + let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); + close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); + close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); + close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); + is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); + close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); + close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); + close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); + close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); + let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); + close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); + close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); + close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); + is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); +} + +/// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): +/// text is skipped, typed or referenced, but a single-cell reference to a blank cell is +/// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an +/// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and +/// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. +#[test] +fn mode_scalar_arguments() { + let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), + (vec![rf(data)], Ok(1.0)), + (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), + // MODE(C9,E16,"1",B8): E16 holds the text "1.1". + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + // MODE(B9,2,D7) with D7 blank. + (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), + (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); +} + +/// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). +#[test] +fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); +} + +#[test] +fn spread() { + let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; + close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); + close(ev("VAR.P", vec![c(&d)]), 678.84); + close(ev("VARP", vec![c(&d)]), 678.84); + is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); + close(ev("STDEV.P", vec![n(1.0)]), 0.0); + let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); + close(ev("VARA", vec![rf(mixed.clone())]), 1.0); + close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); + close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); + close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); + let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; + close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); + close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); + let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; + close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); + close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); + let text_values = arr(vec![vec![tv("1"), tv("2")]]); + let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]); + let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]); + is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA); + is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA); + is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA); + is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA); + close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0); + close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0); + close(ev("HARMEAN", vec![b(true)]), 1.0); + close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0); + is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num); + is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num); + is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); + close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); + is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); + let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; + close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); + close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); + close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); + is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); + close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); + is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); +} + +#[test] +fn min_max_order() { + close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); + close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); + close(ev("MIN", vec![]), 0.0); + close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); + close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); + let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); + close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); + close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); + is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); + is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); + assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); + let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); + close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); + close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); + is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); + let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); + close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); + close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); + close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); +} + +#[test] +fn percentiles() { + close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); + close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); + is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); + let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); + close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); + close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); + close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); + let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); + close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); + close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); + is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); + let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); + close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); + is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); + is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); + close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); + let t = c(&[1.0, 2.0, 3.0]); + // Excel 16: significance 309 and up is #DIV/0!, 308 still works. + for name in ["PERCENTRANK", "PERCENTRANK.INC", "PERCENTRANK.EXC"] { + is_err(ev(name, vec![t.clone(), n(2.0), n(309.0)]), CellError::Div0); + is_err(ev(name, vec![t.clone(), n(1.0), n(400.0)]), CellError::Div0); + close(ev(name, vec![t.clone(), n(2.0), n(308.0)]), 0.5); + } + // From significance 15 up the result has 15 significant digits. + let k = c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + for sig in [15.0, 16.0, 308.0] { + close_tol(ev("PERCENTRANK.INC", vec![k.clone(), n(2.123_456_789), n(sig)]), 0.187_242_798_166_667, 1e-16); + } + close_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] +fn pairs_and_regression() { + close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); + close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); + is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); + is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); + let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); + close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); + close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); + close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); + close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); + // Pairs with text are skipped. + let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); + close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); +} + +#[test] +fn linest_family() { + let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + assert_eq!(r.len(), 1); + close(r[0][0].clone(), 2.0); + close(r[0][1].clone(), 1.0); + // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. + let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); + assert_eq!((s.len(), s[0].len()), (5, 2)); + close(s[0][0].clone(), 1.5); + close(s[0][1].clone(), -2.0 / 3.0); + close(s[2][0].clone(), 0.9642857142857143); // r² + close(s[3][1].clone(), 1.0); // df + close(s[4][0].clone(), 4.5); // ssreg + close(s[4][1].clone(), 1.0 / 6.0); // ssresid + close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey + close(s[3][0].clone(), 27.0); // F + close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope + // No constant. + let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); + close(z[0][0].clone(), 2.0); + close(z[0][1].clone(), 0.0); + // Two predictors: y = 1 + 2a + 3b. + let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); + let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); + let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); + close(m[0][0].clone(), 3.0); + close(m[0][1].clone(), 2.0); + close(m[0][2].clone(), 1.0); + let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; + let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; + let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); + close_tol(l[0][0].clone(), 1.463275628, 1e-9); + close_tol(l[0][1].clone(), 495.3047702, 1e-9); + let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); + close_tol(g[0][0].clone(), 320196.7184, 1e-9); + close_tol(g[1][0].clone(), 468536.0539, 1e-9); + let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; + let tx: Vec = (1..=12).map(f64::from).collect(); + let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); + close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); + let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + close(fitted[1][0].clone(), 9.0); + is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); + is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); +} + +// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) +// inversion; a wider TOROW aborted the process. It is now refused before allocating. +#[test] +fn linest_family_caps_regressor_count() { + let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); + let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); + for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { + // y is one cell, so every column of x is a predictor. + is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); + // y is a row, so every row of x is a predictor. + is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); + } + is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); + // At the cap the fit still runs (one observation: coefficients are zero, not an error). + assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); + // The rows × k² work bound. + assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); + assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); + assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); + assert_eq!(check_regression_size(10_000_000, 1), Ok(())); +} + +#[test] +fn frequency_prob() { + let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); + let x = c(&[0.0, 1.0, 2.0, 3.0]); + let p = c(&[0.2, 0.3, 0.1, 0.4]); + close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); + close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); + is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); +} + +#[test] +fn normal() { + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); + close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); + close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); + close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); + close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); + close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); + close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); + close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); + close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); + is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); + close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); + is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); + let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); + close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); + close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); + close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); + close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); + close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); +} + +#[test] +fn t_chi_f() { + close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10); + close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); + close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); + close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); + close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); + close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); + is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); + close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); + close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); + let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); + is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); + close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); + close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); + close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); + close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); + close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); + close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); + is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); + let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); + let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); + close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); + close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); + close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); + close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); +} + +#[test] +fn f_distributions_accept_ten_billion_degrees() { + close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0); + close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28); + close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0); + close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8); + close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6); + close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0); + is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num); + is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num); +} + +#[test] +fn t_dist_zero_degrees_error_depends_on_mode() { + for df in [0.0, 0.9, -0.1] { + is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0); + is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num); + } + is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num); + close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15); + close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15); +} + +#[test] +fn t_density_is_stable_for_large_degrees() { + close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12); + close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12); + close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12); +} + +#[test] +fn t_inv_zero_probability_returns_div0() { + is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num); + is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num); + is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num); +} + +#[test] +fn binom_inv_huge_trials_returns() { + // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. + let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; + for (name, trials, p, alpha) in cases { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = ev(name, vec![n(trials), n(p), n(alpha)]); + let _ = tx.send(r); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} + Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), + Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), + } + } +} + +#[test] +fn discrete() { + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); + close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0); + close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0); + is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num); + is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num); + is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); + close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0); + close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0); + is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num); + is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); + close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); + close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); + is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); + close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); +} + +#[test] +fn continuous() { + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); + close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); + close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); + is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); + close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); + close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); + is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); + is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); + is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num); + close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0); + close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0); + close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0); + close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0); + is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num); + is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num); + close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25); + close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0); + close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0); + close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); + close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + // Lifting over an array of x values. + let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); + close(r[0][0].clone(), 0.5); + close_tol(r[0][1].clone(), 0.841344746, 1e-8); +} + +#[test] +fn gamma_density_at_zero_shape_one_is_num() { + for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] { + is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num); + } + is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0); + close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15); + close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15); + is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num); +} + +#[test] +fn weibull_density_at_zero_is_zero() { + for shape in [0.5, 1.0, 2.0] { + close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0); + close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0); + } + close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15); + close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15); + is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num); +} + +#[test] +fn aggregate_helper() { + let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; + close(aggregate_values(1, &vals), 3.0); + close(aggregate_values(2, &vals), 3.0); + close(aggregate_values(3, &vals), 5.0); + close(aggregate_values(4, &vals), 6.0); + close(aggregate_values(105, &vals), 1.0); + close(aggregate_values(6, &vals), 12.0); + close(aggregate_values(9, &vals), 9.0); + close(aggregate_values(109, &vals), 9.0); + close(aggregate_values(11, &vals), 14.0 / 3.0); + close(aggregate_values(10, &vals), 7.0); + close(aggregate_values(12, &vals), 2.0); + is_err(aggregate_values(13, &vals), CellError::NA); + is_err(aggregate_values(14, &vals), CellError::Value); + is_err(aggregate_values(99, &vals), CellError::Value); + is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); + close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); + close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); + close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); + is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); +} diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index df125f022..7f1ba8731 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(text(a, 0)?.chars().count() as f64) } +fn lower_char(ch: char) -> char { + match ch.to_lowercase().next() { + Some(lower) => lower, + None => ch, + } +} + +fn upper_char(ch: char) -> char { + let mut uppercase = ch.to_uppercase(); + match uppercase.next() { + Some(upper) if uppercase.next().is_none() => upper, + _ => ch, + } +} + +fn proper_upper_char(ch: char) -> char { + if ch == '\u{130}' { 'I' } else { upper_char(ch) } +} + fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.to_lowercase()) + text_val(text(a, 0)?.chars().map(lower_char).collect::()) } fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R { - text_val(text(a, 0)?.to_uppercase()) + text_val(text(a, 0)?.chars().map(upper_char).collect::()) } fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R { for ch in s.chars() { if ch.is_alphabetic() { if prev_letter { - out.extend(ch.to_lowercase()); + out.push(lower_char(ch)); } else { - out.extend(ch.to_uppercase()); + out.push(proper_upper_char(ch)); } prev_letter = true; } else { @@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R { if !(1.0..=1_114_111.0).contains(&n) { return Err(CellError::Value); } - match char::from_u32(n as u32) { + let cp = n as u32; + if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE { + return Err(CellError::NA); + } + match char::from_u32(cp) { Some(c) => text_val(c.to_string()), None => Err(CellError::NA), } @@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { /// Formats `x` (already rounded) with `decimals` places and optional thousands separators. fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { let d = decimals.max(0) as usize; - let s = format!("{:.*}", d, x.abs()); + let s = x.abs().to_string(); let (int_part, frac) = match s.split_once('.') { - Some((i, f)) => (i.to_string(), Some(f.to_string())), - None => (s.clone(), None), + Some((i, f)) => (i, f), + None => (s.as_str(), ""), }; let int_part = if commas { let b = int_part.as_bytes(); @@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { } o } else { - int_part + int_part.to_string() }; let mut out = int_part; - if let Some(f) = frac { + if d > 0 { + let frac_len = frac.len().min(d); out.push('.'); - out.push_str(&f); + out.push_str(&frac[..frac_len]); + out.extend(std::iter::repeat_n('0', d - frac_len)); } out } @@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R { let (r, d) = rounded(a, 2.0)?; let no_commas = opt_bool(a, 2, false)?; let body = format_fixed(r, d, !no_commas); + if body.len() + if r < 0.0 { 1 } else { 0 } > 255 { + return Err(CellError::Value); + } text_val(if r < 0.0 { format!("-{body}") } else { body }) } fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R { let (r, d) = rounded(a, 2.0)?; + let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0); let body = format_fixed(r, d, true); - text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) + let out = if negative { format!("(${body})") } else { format!("${body}") }; + if out.len() > 254 { + return Err(CellError::Value); + } + text_val(out) } // --------------------------------------------------------------------------------------------- @@ -1123,6 +1156,20 @@ mod tests { fn case_and_cleanup() { is_text(ev("UPPER", vec![t("abc")]), "ABC"); is_text(ev("LOWER", vec![t("ABC")]), "abc"); + is_text(ev("UPPER", vec![t("Straße")]), "STRAßE"); + is_text(ev("LOWER", vec![t("İ")]), "i"); + is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul"); + is_text(ev("PROPER", vec![t("ß")]), "ß"); + is_text(ev("PROPER", vec![t("ÉCOLE")]), "École"); + let proper = ev("PROPER", vec![t("İSTANBUL Straße")]); + is_text(proper.clone(), "Istanbul Straße"); + assert_eq!( + match proper { + Value::Text(s) => s.chars().count(), + _ => 0, + }, + "Istanbul Straße".chars().count() + ); is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title"); is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S"); is_text(ev("TRIM", vec![t(" a b c ")]), "a b c"); @@ -1181,8 +1228,15 @@ mod tests { close(ev("CODE", vec![t("€")]), 128.0); is_err(ev("CODE", vec![t("")]), CellError::Value); is_text(ev("UNICHAR", vec![n(9731.0)]), "☃"); + for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] { + is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA); + } + for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] { + is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected); + } close(ev("UNICODE", vec![t("☃")]), 9731.0); is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value); + is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value); is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA); } @@ -1237,10 +1291,57 @@ mod tests { is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00"); is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); + let fixed_16 = format!("1.1{}", "0".repeat(15)); + let dollar_16 = format!("$1.1{}", "0".repeat(15)); + let fixed_20 = format!("1.005{}", "0".repeat(17)); + let dollar_20 = format!("$1.005{}", "0".repeat(17)); + let fixed_127 = format!("1.1{}", "0".repeat(126)); + let dollar_127 = format!("$1.1{}", "0".repeat(126)); + is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16); + is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16); + is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20); + is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20); + is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127); + is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127); is_text(ev("T", vec![t("x")]), "x"); is_text(ev("T", vec![n(1.0)]), ""); } + #[test] + fn fixed_and_dollar_output_limits() { + assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255)); + is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value); + assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-'))); + is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value); + assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255)); + is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value); + + assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value); + is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value); + assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254)); + is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value); + } + + #[test] + fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() { + is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)"); + is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)"); + is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0"); + is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)"); + is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)"); + is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)"); + is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0"); + } + #[test] fn before_after() { is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red"); diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 761cf0093..d7d7fff4d 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -13,7 +13,5 @@ gridcraft-core = { workspace = true } sha2 = { workspace = true } gridcraft-formula = { workspace = true } serde = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/model/src/b64.rs b/crates/model/src/b64.rs index b377fe451..f36ede104 100644 --- a/crates/model/src/b64.rs +++ b/crates/model/src/b64.rs @@ -90,10 +90,11 @@ pub mod opt { #[cfg(test)] mod tests { #[test] - fn roundtrip() { + fn roundtrip() -> Result<(), Box> { for data in [&b""[..], b"a", b"ab", b"abc", b"hello world", &[0, 255, 128, 7]] { - assert_eq!(super::decode(&super::encode(data)).unwrap(), data); + assert_eq!(super::decode(&super::encode(data)).ok_or("none")?, data); } assert_eq!(super::encode(b"Man"), "TWFu"); + Ok(()) } } diff --git a/crates/model/src/cellmap.rs b/crates/model/src/cellmap.rs index f72b7b5af..bc120acce 100644 --- a/crates/model/src/cellmap.rs +++ b/crates/model/src/cellmap.rs @@ -27,7 +27,7 @@ mod tests { use gridcraft_core::CellRef; #[test] - fn comments_and_hyperlinks_survive_json() { + fn comments_and_hyperlinks_survive_json() -> Result<(), Box> { let mut s = Sheet::new("S"); let a1 = CellRef::new(0, 0); let c3 = CellRef::new(2, 2); @@ -45,9 +45,10 @@ mod tests { }, ); s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false }); - let json = serde_json::to_string(&s).expect("serialize"); - let back: Sheet = serde_json::from_str(&json).expect("deserialize"); + let json = serde_json::to_string(&s)?; + let back: Sheet = serde_json::from_str(&json)?; assert_eq!(back.comments, s.comments); assert_eq!(back.hyperlinks, s.hyperlinks); + Ok(()) } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 00db5e879..2394319c3 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize}; use crate::style::{Color, Style}; +mod pivot; +mod print; + +pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue}; +pub use print::{EditRange, HfPage, LineInfo, Orientation, PrintSettings, SheetProtection, password_hash}; + // ---------------------------------------------------------------- tables #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -716,396 +722,6 @@ pub struct Slicer { pub columns: u32, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Orientation { - #[default] - Portrait, - Landscape, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct PrintSettings { - pub orientation: Orientation, - /// Paper size name (`Letter`, `A4`, `Legal`…). - pub paper: String, - /// Margins in inches: left, right, top, bottom, header, footer. - pub margins: [f32; 6], - pub print_area: Option, - pub title_rows: Option<(u32, u32)>, - pub title_cols: Option<(u32, u32)>, - /// Percent scale, or fit to pages (wide, tall). - pub scale: u16, - pub fit_to: Option<(u16, u16)>, - pub gridlines: bool, - pub headings: bool, - pub center_h: bool, - pub center_v: bool, - /// The header and footer of odd pages, and of every page unless the variants below apply. - pub header: String, - pub footer: String, - pub row_breaks: Vec, - pub col_breaks: Vec, - /// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`. - #[serde(default)] - pub different_first: bool, - /// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`. - #[serde(default)] - pub different_odd_even: bool, - #[serde(default)] - pub first_header: String, - #[serde(default)] - pub first_footer: String, - #[serde(default)] - pub even_header: String, - #[serde(default)] - pub even_footer: String, - /// Scale with Document / Align with Page Margins (both on unless a file turned them off). - #[serde(default = "yes")] - pub hf_scale_with_doc: bool, - #[serde(default = "yes")] - pub hf_align_with_margins: bool, -} - -fn yes() -> bool { - true -} - -/// Which header/footer pair a printed page uses. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HfPage { - Odd, - Even, - First, -} - -impl HfPage { - pub fn id(self) -> &'static str { - match self { - HfPage::Odd => "odd", - HfPage::Even => "even", - HfPage::First => "first", - } - } - pub fn parse(id: &str) -> Option { - match id { - "odd" | "all" => Some(HfPage::Odd), - "even" => Some(HfPage::Even), - "first" => Some(HfPage::First), - _ => None, - } - } -} - -impl PrintSettings { - /// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page. - pub fn hf_page(&self, first: bool, number: usize) -> HfPage { - if self.different_first && first { - HfPage::First - } else if self.different_odd_even && number.is_multiple_of(2) { - HfPage::Even - } else { - HfPage::Odd - } - } - /// The header (`footer` false) or footer of a page kind. - pub fn hf(&self, page: HfPage, footer: bool) -> &String { - match (page, footer) { - (HfPage::Odd, false) => &self.header, - (HfPage::Odd, true) => &self.footer, - (HfPage::Even, false) => &self.even_header, - (HfPage::Even, true) => &self.even_footer, - (HfPage::First, false) => &self.first_header, - (HfPage::First, true) => &self.first_footer, - } - } - pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String { - match (page, footer) { - (HfPage::Odd, false) => &mut self.header, - (HfPage::Odd, true) => &mut self.footer, - (HfPage::Even, false) => &mut self.even_header, - (HfPage::Even, true) => &mut self.even_footer, - (HfPage::First, false) => &mut self.first_header, - (HfPage::First, true) => &mut self.first_footer, - } - } - /// Any header or footer text at all (on any kind of page). - pub fn has_header_footer(&self) -> bool { - [&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty()) - } -} - -impl Default for PrintSettings { - fn default() -> Self { - PrintSettings { - orientation: Orientation::Portrait, - paper: "Letter".into(), - margins: [0.7, 0.7, 0.75, 0.75, 0.3, 0.3], - print_area: None, - title_rows: None, - title_cols: None, - scale: 100, - fit_to: None, - gridlines: false, - headings: false, - center_h: false, - center_v: false, - header: String::new(), - footer: String::new(), - row_breaks: vec![], - col_breaks: vec![], - different_first: false, - different_odd_even: false, - first_header: String::new(), - first_footer: String::new(), - even_header: String::new(), - even_footer: String::new(), - hf_scale_with_doc: true, - hf_align_with_margins: true, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct SheetProtection { - /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the - /// password itself). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub password: Option, - /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still - /// verified when unprotecting, never written for new passwords. - #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] - pub legacy_password_hash: Option, - pub select_locked: bool, - pub select_unlocked: bool, - pub format_cells: bool, - pub format_columns: bool, - pub format_rows: bool, - pub insert_columns: bool, - pub insert_rows: bool, - pub delete_columns: bool, - pub delete_rows: bool, - pub sort: bool, - pub autofilter: bool, -} - -/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a -/// password (XLSX ``). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct EditRange { - pub title: String, - pub ranges: Vec, - pub password: Option, - /// Windows permissions (`securityDescriptor`), kept as read from the file. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security_descriptor: Option, -} - -impl EditRange { - pub fn contains(&self, c: CellRef) -> bool { - self.ranges.iter().any(|r| r.contains(c)) - } -} - -/// The non-cryptographic password hash older GridCraft versions stored for sheet protection -/// (only verified now, see [`SheetProtection::legacy_password_hash`]). -pub fn password_hash(pw: &str) -> u64 { - let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; - for b in pw.bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h -} - -/// Outline grouping info for a row or column. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct LineInfo { - /// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content). - pub size: Option, - pub hidden: bool, - pub outline: u8, - pub collapsed: bool, - pub style: Option, - /// The size was set by the user (rows): automatic heights follow the content. - #[serde(default)] - pub custom: bool, -} - -// ---------------------------------------------------------------- pivot tables - -/// How a PivotTable lays out its row fields. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotLayout { - /// All row fields in one column, inner items indented ("Row Labels" header). - #[default] - Compact, - /// One column per row field; each item on its own row. - Outline, - /// One column per row field; inner items start on their parent's row, totals below. - Tabular, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotSort { - #[default] - Asc, - Desc, - /// Source order (first appearance). - None, -} - -/// Grouping of a date field (values are date serials). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotDateGroup { - #[default] - None, - Years, - Quarters, - Months, - Days, -} - -/// A field placed in the Rows or Columns area. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotField { - /// Source column header name. - pub source_col: String, - pub sort: PivotSort, - pub date_group: PivotDateGroup, - /// Item labels whose detail is hidden (collapsed). - pub collapsed_items: Vec, -} - -/// Summarize-values-by function. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotFunc { - #[default] - Sum, - Count, - Average, - Max, - Min, - Product, - CountNumbers, - StdDev, - StdDevP, - Var, - VarP, -} - -/// Show-values-as transform. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotShowAs { - #[default] - Normal, - PercentOfGrandTotal, - PercentOfColumnTotal, - PercentOfRowTotal, - /// Running total in the first row field. - RunningTotal, - /// Rank largest to smallest among sibling row items. - Rank, -} - -/// A field in the Values area. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotValue { - pub source_col: String, - pub func: PivotFunc, - /// Caption, e.g. "Sum of Sales". - pub name: String, - pub show_as: PivotShowAs, - pub number_format: Option, -} - -/// A report filter (or, for a field also in Rows/Columns, its hidden-items filter). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotFilter { - pub source_col: String, - /// Item labels shown; `None` = all. - pub selected: Option>, -} - -/// A PivotTable: its definition plus where its output was last written. The output itself is -/// static cell values and styles on the sheet, rewritten on every refresh. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotTable { - pub id: u32, - pub name: String, - /// Source: range formula text (`Sheet1!$A$1:$E$200`), a table name or a defined name. - pub source: String, - /// Top-left of the output body on the pivot's sheet. - pub anchor: CellRef, - pub rows: Vec, - pub columns: Vec, - pub values: Vec, - pub filters: Vec, - pub layout: PivotLayout, - /// PivotTable Options › Data › Enable show details: Show Details lists a value cell's - /// source records only while this is on. - pub enable_show_details: bool, - /// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows". - pub grand_totals_rows: bool, - /// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns". - pub grand_totals_cols: bool, - pub subtotals: bool, - /// Subtotals at the top of each group (Compact/Outline); otherwise below it. - pub subtotals_top: bool, - pub style: String, - pub show_headers: bool, - /// Area written by the last refresh. - pub last_range: Option, - /// Calculated fields, usable in the Values area by name. - pub calculated_fields: Vec, - /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather - /// than Columns (Excel's "values on rows"). - pub values_on_rows: bool, - /// Position of Σ Values among the row fields (`None` = after the last). Only used with - /// `values_on_rows`: in Columns Σ Values always comes after the last column field. - pub values_position: Option, -} - -/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in -/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotCalcField { - pub name: String, - /// Formula text, starting with `=`. - pub formula: String, -} - -impl Default for PivotTable { - fn default() -> Self { - PivotTable { - id: 0, - name: "PivotTable1".into(), - source: String::new(), - anchor: CellRef::default(), - rows: vec![], - columns: vec![], - values: vec![], - filters: vec![], - layout: PivotLayout::Compact, - enable_show_details: true, - grand_totals_rows: true, - grand_totals_cols: true, - subtotals: true, - subtotals_top: true, - style: "PivotStyleLight16".into(), - show_headers: true, - last_range: None, - calculated_fields: vec![], - values_on_rows: false, - values_position: None, - } - } -} - #[cfg(test)] mod trendline_forecast_tests { use super::Trendline; diff --git a/crates/model/src/features/pivot.rs b/crates/model/src/features/pivot.rs new file mode 100644 index 000000000..b34015f4c --- /dev/null +++ b/crates/model/src/features/pivot.rs @@ -0,0 +1,177 @@ +use super::{CellRef, RangeRef}; +use serde::{Deserialize, Serialize}; + +// ---------------------------------------------------------------- pivot tables + +/// How a PivotTable lays out its row fields. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotLayout { + /// All row fields in one column, inner items indented ("Row Labels" header). + #[default] + Compact, + /// One column per row field; each item on its own row. + Outline, + /// One column per row field; inner items start on their parent's row, totals below. + Tabular, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotSort { + #[default] + Asc, + Desc, + /// Source order (first appearance). + None, +} + +/// Grouping of a date field (values are date serials). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotDateGroup { + #[default] + None, + Years, + Quarters, + Months, + Days, +} + +/// A field placed in the Rows or Columns area. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotField { + /// Source column header name. + pub source_col: String, + pub sort: PivotSort, + pub date_group: PivotDateGroup, + /// Item labels whose detail is hidden (collapsed). + pub collapsed_items: Vec, +} + +/// Summarize-values-by function. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotFunc { + #[default] + Sum, + Count, + Average, + Max, + Min, + Product, + CountNumbers, + StdDev, + StdDevP, + Var, + VarP, +} + +/// Show-values-as transform. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotShowAs { + #[default] + Normal, + PercentOfGrandTotal, + PercentOfColumnTotal, + PercentOfRowTotal, + /// Running total in the first row field. + RunningTotal, + /// Rank largest to smallest among sibling row items. + Rank, +} + +/// A field in the Values area. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotValue { + pub source_col: String, + pub func: PivotFunc, + /// Caption, e.g. "Sum of Sales". + pub name: String, + pub show_as: PivotShowAs, + pub number_format: Option, +} + +/// A report filter (or, for a field also in Rows/Columns, its hidden-items filter). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotFilter { + pub source_col: String, + /// Item labels shown; `None` = all. + pub selected: Option>, +} + +/// A PivotTable: its definition plus where its output was last written. The output itself is +/// static cell values and styles on the sheet, rewritten on every refresh. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotTable { + pub id: u32, + pub name: String, + /// Source: range formula text (`Sheet1!$A$1:$E$200`), a table name or a defined name. + pub source: String, + /// Top-left of the output body on the pivot's sheet. + pub anchor: CellRef, + pub rows: Vec, + pub columns: Vec, + pub values: Vec, + pub filters: Vec, + pub layout: PivotLayout, + /// PivotTable Options › Data › Enable show details: Show Details lists a value cell's + /// source records only while this is on. + pub enable_show_details: bool, + /// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows". + pub grand_totals_rows: bool, + /// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns". + pub grand_totals_cols: bool, + pub subtotals: bool, + /// Subtotals at the top of each group (Compact/Outline); otherwise below it. + pub subtotals_top: bool, + pub style: String, + pub show_headers: bool, + /// Area written by the last refresh. + pub last_range: Option, + /// Calculated fields, usable in the Values area by name. + pub calculated_fields: Vec, + /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather + /// than Columns (Excel's "values on rows"). + pub values_on_rows: bool, + /// Position of Σ Values among the row fields (`None` = after the last). Only used with + /// `values_on_rows`: in Columns Σ Values always comes after the last column field. + pub values_position: Option, +} + +/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in +/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotCalcField { + pub name: String, + /// Formula text, starting with `=`. + pub formula: String, +} + +impl Default for PivotTable { + fn default() -> Self { + PivotTable { + id: 0, + name: "PivotTable1".into(), + source: String::new(), + anchor: CellRef::default(), + rows: vec![], + columns: vec![], + values: vec![], + filters: vec![], + layout: PivotLayout::Compact, + enable_show_details: true, + grand_totals_rows: true, + grand_totals_cols: true, + subtotals: true, + subtotals_top: true, + style: "PivotStyleLight16".into(), + show_headers: true, + last_range: None, + calculated_fields: vec![], + values_on_rows: false, + values_position: None, + } + } +} diff --git a/crates/model/src/features/print.rs b/crates/model/src/features/print.rs new file mode 100644 index 000000000..cbcaf3e55 --- /dev/null +++ b/crates/model/src/features/print.rs @@ -0,0 +1,217 @@ +use super::{CellRef, RangeRef}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PrintSettings { + pub orientation: Orientation, + /// Paper size name (`Letter`, `A4`, `Legal`…). + pub paper: String, + /// Margins in inches: left, right, top, bottom, header, footer. + pub margins: [f32; 6], + pub print_area: Option, + pub title_rows: Option<(u32, u32)>, + pub title_cols: Option<(u32, u32)>, + /// Percent scale, or fit to pages (wide, tall). + pub scale: u16, + pub fit_to: Option<(u16, u16)>, + pub gridlines: bool, + pub headings: bool, + pub center_h: bool, + pub center_v: bool, + /// The header and footer of odd pages, and of every page unless the variants below apply. + pub header: String, + pub footer: String, + pub row_breaks: Vec, + pub col_breaks: Vec, + /// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`. + #[serde(default)] + pub different_first: bool, + /// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`. + #[serde(default)] + pub different_odd_even: bool, + #[serde(default)] + pub first_header: String, + #[serde(default)] + pub first_footer: String, + #[serde(default)] + pub even_header: String, + #[serde(default)] + pub even_footer: String, + /// Scale with Document / Align with Page Margins (both on unless a file turned them off). + #[serde(default = "yes")] + pub hf_scale_with_doc: bool, + #[serde(default = "yes")] + pub hf_align_with_margins: bool, +} + +fn yes() -> bool { + true +} + +/// Which header/footer pair a printed page uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HfPage { + Odd, + Even, + First, +} + +impl HfPage { + pub fn id(self) -> &'static str { + match self { + HfPage::Odd => "odd", + HfPage::Even => "even", + HfPage::First => "first", + } + } + pub fn parse(id: &str) -> Option { + match id { + "odd" | "all" => Some(HfPage::Odd), + "even" => Some(HfPage::Even), + "first" => Some(HfPage::First), + _ => None, + } + } +} + +impl PrintSettings { + /// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page. + pub fn hf_page(&self, first: bool, number: usize) -> HfPage { + if self.different_first && first { + HfPage::First + } else if self.different_odd_even && number.is_multiple_of(2) { + HfPage::Even + } else { + HfPage::Odd + } + } + /// The header (`footer` false) or footer of a page kind. + pub fn hf(&self, page: HfPage, footer: bool) -> &String { + match (page, footer) { + (HfPage::Odd, false) => &self.header, + (HfPage::Odd, true) => &self.footer, + (HfPage::Even, false) => &self.even_header, + (HfPage::Even, true) => &self.even_footer, + (HfPage::First, false) => &self.first_header, + (HfPage::First, true) => &self.first_footer, + } + } + pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String { + match (page, footer) { + (HfPage::Odd, false) => &mut self.header, + (HfPage::Odd, true) => &mut self.footer, + (HfPage::Even, false) => &mut self.even_header, + (HfPage::Even, true) => &mut self.even_footer, + (HfPage::First, false) => &mut self.first_header, + (HfPage::First, true) => &mut self.first_footer, + } + } + /// Any header or footer text at all (on any kind of page). + pub fn has_header_footer(&self) -> bool { + [&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty()) + } +} + +impl Default for PrintSettings { + fn default() -> Self { + PrintSettings { + orientation: Orientation::Portrait, + paper: "Letter".into(), + margins: [0.7, 0.7, 0.75, 0.75, 0.3, 0.3], + print_area: None, + title_rows: None, + title_cols: None, + scale: 100, + fit_to: None, + gridlines: false, + headings: false, + center_h: false, + center_v: false, + header: String::new(), + footer: String::new(), + row_breaks: vec![], + col_breaks: vec![], + different_first: false, + different_odd_even: false, + first_header: String::new(), + first_footer: String::new(), + even_header: String::new(), + even_footer: String::new(), + hf_scale_with_doc: true, + hf_align_with_margins: true, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SheetProtection { + /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the + /// password itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still + /// verified when unprotecting, never written for new passwords. + #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] + pub legacy_password_hash: Option, + pub select_locked: bool, + pub select_unlocked: bool, + pub format_cells: bool, + pub format_columns: bool, + pub format_rows: bool, + pub insert_columns: bool, + pub insert_rows: bool, + pub delete_columns: bool, + pub delete_rows: bool, + pub sort: bool, + pub autofilter: bool, +} + +/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a +/// password (XLSX ``). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EditRange { + pub title: String, + pub ranges: Vec, + pub password: Option, + /// Windows permissions (`securityDescriptor`), kept as read from the file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_descriptor: Option, +} + +impl EditRange { + pub fn contains(&self, c: CellRef) -> bool { + self.ranges.iter().any(|r| r.contains(c)) + } +} + +/// The non-cryptographic password hash older GridCraft versions stored for sheet protection +/// (only verified now, see [`SheetProtection::legacy_password_hash`]). +pub fn password_hash(pw: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; + for b in pw.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// Outline grouping info for a row or column. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LineInfo { + /// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content). + pub size: Option, + pub hidden: bool, + pub outline: u8, + pub collapsed: bool, + pub style: Option, + /// The size was set by the user (rows): automatic heights follow the content. + #[serde(default)] + pub custom: bool, +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 300fdc66a..47ae6e8db 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ mod b64; pub mod cell; mod cellmap; pub mod features; +pub mod macros; pub mod password; pub mod preserved; pub mod python; @@ -18,6 +19,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; +pub use macros::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml}; pub use python::{PyCode, PyOutput}; diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs new file mode 100644 index 000000000..3459cdb36 --- /dev/null +++ b/crates/model/src/macros.rs @@ -0,0 +1,539 @@ +//! GridCraft macros: typed, declarative command lists that run as one undo step. They are not VBA: +//! a VBA project in a file is carried through unchanged (`Workbook::vba_project`) and never run. + +use serde::{Deserialize, Serialize}; + +/// The macro format version written by this build. +pub const MACRO_VERSION: u32 = 1; +/// The most macros a workbook keeps. +pub const MAX_MACROS: usize = 1_000; +/// The most steps in one macro, counting the steps inside Repeat and If. +pub const MAX_MACRO_STEPS: usize = 10_000; +/// How deeply Repeat and If may nest (a top-level step is at depth 1). +pub const MAX_MACRO_DEPTH: usize = 4; +/// The longest macro or input name, in characters. +pub const MAX_MACRO_NAME: usize = 255; +/// The longest macro description, step note, Stop message or If value (as JSON), in characters. +pub const MAX_MACRO_TEXT: usize = 4_096; +/// The longest reference or token in a macro (an output anchor, an input's default range, a +/// Repeat's range, an If's cell), in characters. +pub const MAX_MACRO_REF: usize = 1_024; +/// The longest shortcut text (`Ctrl+Shift+M`), in characters. +pub const MAX_MACRO_SHORTCUT: usize = 64; +/// The most commands a macro's permissions list. +pub const MAX_MACRO_PERMISSIONS: usize = 256; +/// The most column types an input lists: one per column of a sheet. +pub const MAX_MACRO_COLUMNS: usize = 16_384; + +/// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run +/// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step +/// parameters can be tokens (`"Input.col(2)"`) that are resolved against the declared inputs when +/// the macro runs, so a macro recorded on one block of data works on another. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Macro { + /// Format version ([`MACRO_VERSION`]). + pub v: u32, + pub name: String, + pub description: String, + /// Keyboard shortcut (`Ctrl+Shift+M`) for a user interface to offer; the engine only stores it. + #[serde(skip_serializing_if = "Option::is_none")] + pub shortcut: Option, + /// The ranges the macro works on, checked before it runs. + pub inputs: Vec, + pub steps: Vec, + /// Where results go and what to do when they would land on existing data. + pub output: OutputRule, + /// Commands that need permission (reading or writing files) which the macro may run. + pub permissions: Vec, +} + +impl Default for Macro { + fn default() -> Self { + Macro { + v: MACRO_VERSION, + name: String::new(), + description: String::new(), + shortcut: None, + inputs: vec![], + steps: vec![], + output: OutputRule::default(), + permissions: vec![], + } + } +} + +impl Macro { + /// Steps in the macro, counting those inside Repeat and If. + pub fn step_count(&self) -> usize { + fn count(steps: &[Step]) -> usize { + steps.iter().map(|s| 1usize.saturating_add(s.kind.children().map(|b| count(b)).sum::())).sum() + } + count(&self.steps) + } + + /// Checks every field a file can set: the format version ([`check_version`]); the lengths of + /// the name, description, shortcut, notes, messages, references and command ids; the counts of + /// steps ([`MAX_MACRO_STEPS`]), inputs, permissions and column types; the nesting depth + /// ([`MAX_MACRO_DEPTH`]); and each input's declaration. Command parameters are bounded only by + /// the size of the whole macro, which [`MAX_MACRO_STEPS`] and the workbook's item limit cap. + /// Every struct is taken apart field by field, so a field added later doesn't compile here + /// until someone decides its limit. + pub fn check_limits(&self) -> Result<(), String> { + let Macro { v, name, description, shortcut, inputs, steps, output, permissions } = self; + check_version(u64::from(*v))?; + let name = name.trim(); + if name.is_empty() { + return Err("A macro needs a name.".into()); + } + if name.chars().count() > MAX_MACRO_NAME { + return Err(format!("A macro name can be at most {MAX_MACRO_NAME} characters long.")); + } + if name.chars().any(char::is_control) { + return Err("A macro name can't hold line breaks or other control characters.".into()); + } + check_text("A macro description", description, MAX_MACRO_TEXT, false)?; + if let Some(shortcut) = shortcut { + check_text("A shortcut", shortcut, MAX_MACRO_SHORTCUT, true)?; + } + let OutputRule { anchor, on_conflict: _ } = output; + if let Some(anchor) = anchor { + check_text("The output anchor", anchor, MAX_MACRO_REF, true)?; + } + if permissions.len() > MAX_MACRO_PERMISSIONS { + return Err(format!("A macro can list at most {MAX_MACRO_PERMISSIONS} permissions.")); + } + for p in permissions { + check_id("A permission", p)?; + } + // Count without recursing past the depth limit (a hostile file can nest deeper). + // A Repeat or If at the top is nested 1 deep; one inside it, 2 deep. + fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> { + for s in steps { + *count = count.saturating_add(1); + if *count > MAX_MACRO_STEPS { + return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps.")); + } + let Step { kind, enabled: _, breakpoint: _, note } = s; + check_text("A step note", note, MAX_MACRO_TEXT, false)?; + match kind { + StepKind::Command { id, params: _ } => check_id("A step's command", id)?, + StepKind::Repeat { over, of: _, max: _, body: _ } => check_text("A Repeat step's range", over, MAX_MACRO_REF, true)?, + StepKind::If { cell, op: _, value, then: _, otherwise: _ } => { + check_text("An If step's cell", cell, MAX_MACRO_REF, true)?; + // Empty and NotEmpty ignore the value, which is then null. + if serde_json::to_string(value).map_or(usize::MAX, |t| t.chars().count()) > MAX_MACRO_TEXT { + return Err(format!("An If step's value can be at most {MAX_MACRO_TEXT} characters long.")); + } + } + StepKind::Stop { message, rollback: _ } => check_text("A Stop message", message, MAX_MACRO_TEXT, false)?, + } + if kind.children().next().is_some() && level > MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); + } + for body in kind.children() { + walk(body, level + 1, count)?; + } + } + Ok(()) + } + walk(steps, 1, &mut 0)?; + if inputs.len() > 64 { + return Err("A macro can declare at most 64 inputs.".into()); + } + for (k, i) in inputs.iter().enumerate() { + let NamedInput { name, kind, rows, cols, header, col_types, non_empty: _, default_ref } = i; + let n = name.as_str(); + let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_'); + if !ident || n.chars().count() > MAX_MACRO_NAME { + return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter.")); + } + if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) { + return Err(format!("'{n}' is a token of its own and can't name an input.")); + } + // A step written with plain A1 text (a recorded one) must keep meaning the cell. + if gridcraft_core::RangeRef::parse(n).is_some() { + return Err(format!("'{n}' reads as a cell reference and can't name an input.")); + } + if inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { + return Err(format!("Two inputs are called '{n}'.")); + } + for (what, [least, most]) in [("rows", rows), ("columns", cols)] { + if *most != 0 && least > most { + return Err(format!("Input '{n}' allows {least} to {most} {what}: the least can't be more than the most.")); + } + } + // A cell is one row and one column ([1, 1], or the default [1, 0]) with no header. + let one = |b: &[u32; 2]| *b == [1, 1] || *b == [1, 0]; + if *kind == InputKind::Cell && (!one(rows) || !one(cols) || *header || col_types.len() > 1) { + return Err(format!("Input '{n}' is a single cell: one row and one column, no header, at most one column type.")); + } + let most = if cols[1] == 0 { MAX_MACRO_COLUMNS } else { usize::try_from(cols[1]).unwrap_or(usize::MAX).min(MAX_MACRO_COLUMNS) }; + if col_types.len() > most { + return Err(format!("Input '{n}' lists the types of {} columns, but has at most {most}.", col_types.len())); + } + if let Some(r) = default_ref { + check_text(&format!("The default range of input '{n}'"), r, MAX_MACRO_REF, true)?; + } + } + Ok(()) + } +} + +/// Refuses `text` longer than `max` characters, and, for a one-line field, one that holds line +/// breaks or other control characters. +fn check_text(what: &str, text: &str, max: usize, one_line: bool) -> Result<(), String> { + if text.chars().count() > max { + return Err(format!("{what} can be at most {max} characters long.")); + } + if one_line && text.chars().any(char::is_control) { + return Err(format!("{what} can't hold line breaks or other control characters.")); + } + Ok(()) +} + +/// Refuses a command id that isn't one: empty, longer than a name, or holding anything other than +/// letters, digits, `.` and `_` (every command id is of that form). +fn check_id(what: &str, id: &str) -> Result<(), String> { + let ok = !id.is_empty() && id.len() <= MAX_MACRO_NAME && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_'); + if ok { Ok(()) } else { Err(format!("{what} must be a command id (letters, digits, . and _): '{}'", id.chars().take(64).collect::())) } +} + +/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so +/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every +/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list +/// when another snapshot shares it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Macros(std::sync::Arc>); + +impl Macros { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether both hold the one list, with no copy made between them. + pub fn shares(&self, other: &Macros) -> bool { + std::sync::Arc::ptr_eq(&self.0, &other.0) + } + + /// A rough size of the list in memory, for the undo budget: each macro's JSON, which holds + /// every name, text and step parameter, plus its structs. An undo step that keeps a copy of + /// the list (after a save, rename or delete) is charged for all of it. The JSON is counted as + /// it is written, without building it. + pub fn estimated_bytes(&self) -> usize { + /// Counts the bytes written to it. + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(buf.len()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + self.0.iter().fold(0usize, |bytes, m| { + let mut json = Count(0); + // Writing to a counter fails only for a value JSON can't hold, which a macro never is. + let _ = serde_json::to_writer(&mut json, m); + let steps = m.step_count().saturating_mul(std::mem::size_of::()); + bytes.saturating_add(std::mem::size_of::()).saturating_add(json.0).saturating_add(steps) + }) + } +} + +impl From> for Macros { + fn from(list: Vec) -> Macros { + Macros(std::sync::Arc::new(list)) + } +} + +impl std::ops::Deref for Macros { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.0 + } +} + +impl std::ops::DerefMut for Macros { + fn deref_mut(&mut self) -> &mut Vec { + std::sync::Arc::make_mut(&mut self.0) + } +} + +impl IntoIterator for Macros { + type Item = Macro; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + std::sync::Arc::unwrap_or_clone(self.0).into_iter() + } +} + +impl<'a> IntoIterator for &'a Macros { + type Item = &'a Macro; + type IntoIter = std::slice::Iter<'a, Macro>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl FromIterator for Macros { + fn from_iter>(iter: I) -> Macros { + Macros(std::sync::Arc::new(iter.into_iter().collect())) + } +} + +/// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it +/// doesn't know, and a save would then write the rest back as if it were the whole macro. +pub fn check_version(v: u64) -> Result<(), String> { + if v > u64::from(MACRO_VERSION) { + return Err(format!("This macro was made by a newer version of GridCraft (macro format {v}); this version reads format {MACRO_VERSION}.")); + } + Ok(()) +} + +/// Reads the `macros` field of a JSON workbook as raw entries, whatever they hold, so they can be +/// checked one at a time. +pub(crate) fn entries<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(match serde_json::Value::deserialize(d)? { + serde_json::Value::Array(list) => list, + serde_json::Value::Null => vec![], + other => vec![other], + }) +} + +/// A range the macro works on, with the shape and content it expects. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct NamedInput { + /// The token that stands for it in step parameters (`Input`, `Rates`). + pub name: String, + pub kind: InputKind, + /// Allowed data rows (header excluded), `[min, max]`; a max of 0 means no limit. + pub rows: [u32; 2], + /// Allowed columns, `[min, max]`; a max of 0 means no limit. + pub cols: [u32; 2], + /// The first row holds column names. + pub header: bool, + /// What each column must hold, first column first; columns past the list may hold anything. + pub col_types: Vec, + /// Every data cell must have something in it. + pub non_empty: bool, + /// The range used when a run doesn't name one (else the current selection, for the first input). + #[serde(skip_serializing_if = "Option::is_none")] + pub default_ref: Option, +} + +impl Default for NamedInput { + fn default() -> Self { + NamedInput { + name: "Input".into(), + kind: InputKind::Range, + rows: [1, 0], + cols: [1, 0], + header: false, + col_types: vec![], + non_empty: false, + default_ref: None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InputKind { + /// A single cell. + Cell, + #[default] + Range, +} + +/// What a column of an input must hold. Empty cells pass unless the input is `non_empty`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColType { + Number, + Text, + /// A number shown with a date or time format. + Date, + Bool, + #[default] + Any, +} + +impl ColType { + /// The plural noun used in messages ("must hold numbers"). + pub fn noun(self) -> &'static str { + match self { + ColType::Number => "numbers", + ColType::Text => "text", + ColType::Date => "dates", + ColType::Bool => "TRUE or FALSE", + ColType::Any => "anything", + } + } +} + +/// One macro step and its editor flags. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Step { + #[serde(flatten)] + pub kind: StepKind, + /// A disabled step is skipped. + #[serde(default = "yes", skip_serializing_if = "is_true")] + pub enabled: bool, + /// The debugger stops before this step. + #[serde(default, skip_serializing_if = "is_false")] + pub breakpoint: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, +} + +fn yes() -> bool { + true +} +fn is_true(b: &bool) -> bool { + *b +} +fn is_false(b: &bool) -> bool { + !*b +} + +impl Step { + pub fn new(kind: StepKind) -> Step { + Step { kind, enabled: true, breakpoint: false, note: String::new() } + } + + /// A step that runs command `id` with `params`. + pub fn command(id: impl Into, params: serde_json::Value) -> Step { + Step::new(StepKind::Command { id: id.into(), params }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StepKind { + /// Runs a GridCraft command. String parameters may hold tokens, resolved when the step runs. + Command { + id: String, + #[serde(default)] + params: serde_json::Value, + }, + /// Runs `body` once for each row (or column) of the range `over`, a token or reference; inside + /// it, `Row` (or `Column`) stands for the current one. At most `max` times. + Repeat { + over: String, + #[serde(default)] + of: RepeatOf, + #[serde(default = "repeat_max")] + max: u32, + #[serde(default)] + body: Vec, + }, + /// Compares the value of `cell` (a token or reference to one cell) with `value` and runs + /// `then` or `else`. + If { + cell: String, + op: CompareOp, + #[serde(default)] + value: serde_json::Value, + #[serde(default)] + then: Vec, + #[serde(default, rename = "else")] + otherwise: Vec, + }, + /// Ends the run with a message. The changes made so far are kept, or undone with `rollback`. + Stop { + #[serde(default)] + message: String, + #[serde(default, skip_serializing_if = "is_false")] + rollback: bool, + }, +} + +fn repeat_max() -> u32 { + 10_000 +} + +impl StepKind { + /// The step lists inside this step (Repeat's body, If's branches). + pub fn children(&self) -> impl Iterator> { + let (a, b) = match self { + StepKind::Repeat { body, .. } => (Some(body), None), + StepKind::If { then, otherwise, .. } => (Some(then), Some(otherwise)), + _ => (None, None), + }; + a.into_iter().chain(b) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RepeatOf { + #[default] + Rows, + Columns, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CompareOp { + Eq, + Ne, + Gt, + Ge, + Lt, + Le, + /// The cell is empty (`value` is ignored). + Empty, + NotEmpty, + /// The cell's text contains `value`'s text, ignoring case. + Contains, +} + +/// Where a macro's results go. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OutputRule { + /// What the `Output` token stands for: a token (`Input.offset(0,4)`), a reference or `Name:X`. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, + /// What to do when the cells the macro writes, outside its inputs, already hold data. + pub on_conflict: OnConflict, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OnConflict { + /// Ask first: overwrite, shift the data down or right, or cancel. Programmatic runs fail. + #[default] + Ask, + Fail, + Overwrite, + ShiftDown, + ShiftRight, +} + +impl OnConflict { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() { + "ask" => OnConflict::Ask, + "fail" | "cancel" => OnConflict::Fail, + "overwrite" => OnConflict::Overwrite, + "shiftdown" => OnConflict::ShiftDown, + "shiftright" => OnConflict::ShiftRight, + _ => return None, + }) + } + + pub fn name(self) -> &'static str { + match self { + OnConflict::Ask => "ask", + OnConflict::Fail => "fail", + OnConflict::Overwrite => "overwrite", + OnConflict::ShiftDown => "shiftDown", + OnConflict::ShiftRight => "shiftRight", + } + } +} diff --git a/crates/model/src/password.rs b/crates/model/src/password.rs index 59b9aafba..f46de4b44 100644 --- a/crates/model/src/password.rs +++ b/crates/model/src/password.rs @@ -106,28 +106,30 @@ mod tests { use super::*; #[test] - fn legacy_hash_matches_the_published_example() { + fn legacy_hash_matches_the_published_example() -> Result<(), Box> { // The hash Excel writes for the password "password" (`password="83AF"`). assert_eq!(legacy_hash("password"), 0x83AF); - assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap()); - assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap()); + assert!(PasswordHash::Legacy(0x83AF).verify("password")?); + assert!(!PasswordHash::Legacy(0x83AF).verify("Password")?); assert_eq!(legacy_hash(""), 0xCE4B); + Ok(()) } #[test] - fn iterated_hash_verifies_and_refuses_what_it_cannot_check() { + fn iterated_hash_verifies_and_refuses_what_it_cannot_check() -> Result<(), Box> { let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef"); - assert!(h.verify("s3cret").unwrap()); - assert!(!h.verify("s3cret ").unwrap()); + assert!(h.verify("s3cret")?); + assert!(!h.verify("s3cret ")?); assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512")); - let PasswordHash::Iterated { hash, salt, .. } = h else { return }; + let PasswordHash::Iterated { hash, salt, .. } = h else { return Err("not an iterated hash".into()) }; let other = |algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count }; assert!(other("SHA-1", 10).verify("s3cret").is_err()); assert!(other("MD5", 10).verify("s3cret").is_err()); assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err()); - assert!(!other("SHA-256", 2).verify("s3cret").unwrap()); + assert!(!other("SHA-256", 2).verify("s3cret")?); let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 }; assert!(broken.verify("s3cret").is_err()); + Ok(()) } } diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 001b7cf60..be52bbe35 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -330,30 +330,32 @@ mod tests { } #[test] - fn set_get_remove() { + fn set_get_remove() -> Result<(), Box> { let mut s = CellStore::new(); s.set(c(0, 0), Cell::value(1.0.into())); s.set(c(100, 5), Cell::value("x".into())); assert_eq!(s.len(), 2); assert_eq!(s.get(c(100, 5)).map(|x| x.value.clone()), Some(Value::from("x"))); - assert_eq!(s.used_range().unwrap().a1(), "A1:F101"); + assert_eq!(s.used_range().ok_or("none")?.a1(), "A1:F101"); s.set(c(0, 0), Cell::default()); assert_eq!(s.len(), 1); assert!(s.remove(c(100, 5)).is_some()); assert!(s.is_empty()); assert!(s.used_range().is_none()); + Ok(()) } #[test] - fn cow_snapshots() { + fn cow_snapshots() -> Result<(), Box> { let mut s = CellStore::new(); for r in 0..1000 { s.set(c(r, 0), Cell::value((r as f64).into())); } let snap = s.clone(); s.set(c(5, 0), Cell::value(99.0.into())); - assert_eq!(snap.get(c(5, 0)).unwrap().value, Value::Number(5.0)); - assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0)); + assert_eq!(snap.get(c(5, 0)).ok_or("none")?.value, Value::Number(5.0)); + assert_eq!(s.get(c(5, 0)).ok_or("none")?.value, Value::Number(99.0)); + Ok(()) } #[test] @@ -371,14 +373,16 @@ mod tests { } #[test] - fn range_iteration_order() { + fn range_iteration_order() -> Result<(), Box> { let mut s = CellStore::new(); s.set(c(70, 2), Cell::value(3.0.into())); s.set(c(1, 1), Cell::value(1.0.into())); s.set(c(1, 3), Cell::value(2.0.into())); s.set(c(500, 0), Cell::value(4.0.into())); - let v: Vec = s.iter_range(RangeRef::parse("A1:D100").unwrap()).map(|(_, x)| x.value.as_f64().unwrap()).collect(); + let v: Vec = + s.iter_range(RangeRef::parse("A1:D100").ok_or("none")?).map(|(_, x)| x.value.as_f64()).collect::>().ok_or("none")?; assert_eq!(v, [1.0, 2.0, 3.0]); + Ok(()) } #[test] diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 1ded99288..d2f10780e 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -500,32 +500,34 @@ mod tests { } #[test] - fn a_theme_saved_before_per_script_fonts_still_opens() { - let mut json = serde_json::to_value(Theme::default()).unwrap(); - let fields = json.as_object_mut().unwrap(); + fn a_theme_saved_before_per_script_fonts_still_opens() -> Result<(), Box> { + let mut json = serde_json::to_value(Theme::default())?; + let fields = json.as_object_mut().ok_or("none")?; assert!(fields.remove("major_font_scripts").is_some() && fields.remove("minor_font_scripts").is_some()); - assert_eq!(serde_json::from_value::(json).unwrap(), Theme::default()); + assert_eq!(serde_json::from_value::(json)?, Theme::default()); + Ok(()) } #[test] - fn colors() { + fn colors() -> Result<(), Box> { let th = Theme::default(); - assert_eq!(Color::from_hex("#FF0000").unwrap().resolve(&th), Some([255, 0, 0])); - assert_eq!(Color::from_hex("FF00FF00").unwrap().resolve(&th), Some([0, 255, 0])); + assert_eq!(Color::from_hex("#FF0000").ok_or("none")?.resolve(&th), Some([255, 0, 0])); + assert_eq!(Color::from_hex("FF00FF00").ok_or("none")?.resolve(&th), Some([0, 255, 0])); assert_eq!(Color::Theme(0, -500).resolve(&th), Some([128, 128, 128])); assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128])); assert_eq!(Color::Auto.resolve(&th), None); + Ok(()) } #[test] - fn theme_json_keeps_ea_and_cs_and_reads_old_json() { + fn theme_json_keeps_ea_and_cs_and_reads_old_json() -> Result<(), Box> { // JSON written before the ea/cs fields existed still deserializes (`serde(default)`). - let mut old = serde_json::to_value(Theme::default()).unwrap(); - let obj = old.as_object_mut().unwrap(); + let mut old = serde_json::to_value(Theme::default())?; + let obj = old.as_object_mut().ok_or("none")?; for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] { assert!(obj.remove(key).is_some(), "the default theme serializes {key}"); } - assert_eq!(serde_json::from_value::(old).unwrap(), Theme::default()); + assert_eq!(serde_json::from_value::(old)?, Theme::default()); // Set fields serialize and come back. let t = Theme { @@ -535,8 +537,9 @@ mod tests { minor_font_cs: "Times New Roman".into(), ..Theme::default() }; - let json = serde_json::to_string(&t).unwrap(); + let json = serde_json::to_string(&t)?; assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), t); + assert_eq!(serde_json::from_str::(&json)?, t); + Ok(()) } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index ac6b3131b..0d82ca6ff 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -181,6 +181,20 @@ pub struct Workbook { /// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`. #[serde(default, skip_serializing_if = "Option::is_none")] pub csv_delimiter: Option, + /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them. + /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names. + #[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)] + pub macros: crate::macros::Macros, + /// The `macros` entries of a JSON file as read, before the engine checks each one on its own + /// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening. + /// Never written. + #[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")] + pub macros_read: Vec, + /// Macros in a newer format than this build reads, as the file held them. They are never read, + /// listed or run; a save writes them back unchanged, so the newer build gets them back whole. + /// Shared between snapshots, as nothing changes them. + #[serde(skip)] + pub macros_unread: Arc>, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -278,6 +292,9 @@ impl Workbook { phonetics: vec![], vba_project: None, csv_delimiter: None, + macros: crate::macros::Macros::default(), + macros_read: vec![], + macros_unread: Arc::default(), preserved: None, source_dir: None, source_path: None, @@ -439,7 +456,7 @@ mod tests { use super::*; #[test] - fn phonetics_are_additive_in_json() { + fn phonetics_are_additive_in_json() -> Result<(), Box> { let mut wb = Workbook::new(); assert!(wb.phonetics.is_empty(), "new workbooks have no phonetic data"); wb.phonetics.push(PhoneticString { @@ -447,12 +464,13 @@ mod tests { runs: vec![PhoneticRun { start: 0, end: 1, text: "ヒガシ".into() }], props: Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }), }); - let json = serde_json::to_string(&wb).expect("serialize"); - assert_eq!(serde_json::from_str::(&json).expect("deserialize").phonetics, wb.phonetics); + let json = serde_json::to_string(&wb)?; + assert_eq!(serde_json::from_str::(&json)?.phonetics, wb.phonetics); // JSON written before the field existed still loads. - let mut value: serde_json::Value = serde_json::from_str(&json).expect("value"); - value.as_object_mut().expect("an object").remove("phonetics"); - assert!(serde_json::from_value::(value).expect("older JSON").phonetics.is_empty()); + let mut value: serde_json::Value = serde_json::from_str(&json)?; + value.as_object_mut().ok_or("none")?.remove("phonetics"); + assert!(serde_json::from_value::(value)?.phonetics.is_empty()); + Ok(()) } #[test] diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 06d0a8693..52cada2c7 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value}; pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; -use parse::{SecKind, Section, Tok}; +use parse::{DatePart, SecKind, Section, Tok}; use render::Out; /// Width (in characters) of General output in a cell. @@ -193,6 +193,18 @@ impl NumberFormat { self.num_sections().iter().any(|s| s.kind == SecKind::Date) } + /// For a date or time format, whether the section that formats `value` shows calendar parts (year, month, + /// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for + /// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment. + pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> { + let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?; + let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None }); + if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) { + return None; + } + Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar()))) + } + pub fn is_percent(&self) -> bool { self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0) } diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index 63e2d3008..ac905d36b 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -438,7 +438,7 @@ fn date_part(t: &Tok) -> Option { } fn analyze(raw: Raw) -> Section { - let Raw { mut toks, color, cond, currency } = raw; + let Raw { toks, color, cond, currency } = raw; let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_))); let has_general = toks.iter().any(|t| matches!(t, Tok::General)); let mut sec = Section { @@ -458,69 +458,85 @@ fn analyze(raw: Raw) -> Section { }; if is_date { sec.kind = SecKind::Date; - // m/mm after hours or before seconds means minutes. - for i in 0..toks.len() { - let Some(Tok::Date(DatePart::Month(n))) = toks.get(i) else { continue }; - let n = *n; - if n > 2 { - continue; - } - let prev = toks.get(..i).and_then(|s| s.iter().rev().find_map(date_part)); - let next = toks.get(i + 1..).and_then(|s| s.iter().find_map(date_part)); - let after_h = matches!(prev, Some(DatePart::Hour(_) | DatePart::ElapsedH(_))); - let before_s = matches!(next, Some(DatePart::Second(_) | DatePart::ElapsedS(_))); - if (after_h || before_s) - && let Some(t) = toks.get_mut(i) - { - *t = Tok::Date(DatePart::Minute(n)); - } - } - let mut out = Vec::with_capacity(toks.len()); - let mut i = 0; - while let Some(t) = toks.get(i) { - if matches!(t, Tok::Point) && toks.get(i + 1) == Some(&Tok::Digit('0')) { - let mut n = 0usize; - while toks.get(i + 1 + n) == Some(&Tok::Digit('0')) { - n += 1; - } - out.push(Tok::Date(DatePart::SubSec(n.min(255) as u8))); - i += 1 + n; - continue; - } - match t { - Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - i += 1; - } - sec.toks = out; - return sec; + return analyze_date(toks, sec); } if has_general { sec.kind = SecKind::General; - let mut out = Vec::with_capacity(toks.len()); - for t in &toks { - match t { - Tok::General | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - } - sec.toks = out; - return sec; + return analyze_general(toks, sec); } sec.has_digits = toks.iter().any(|t| matches!(t, Tok::Digit(_))); if sec.has_at && !sec.has_digits { sec.kind = SecKind::Text; - let mut out = Vec::with_capacity(toks.len()); - for t in &toks { - match t { - Tok::At | Tok::Fill(_) => out.push(t.clone()), - other => push_lit(&mut out, &other.literal_text()), - } - } - sec.toks = out; - return sec; + return analyze_text(toks, sec); } + analyze_number(toks, sec) +} + +fn analyze_date(mut toks: Vec, mut sec: Section) -> Section { + // m/mm after hours or before seconds means minutes. + for i in 0..toks.len() { + let Some(Tok::Date(DatePart::Month(n))) = toks.get(i) else { continue }; + let n = *n; + if n > 2 { + continue; + } + let prev = toks.get(..i).and_then(|s| s.iter().rev().find_map(date_part)); + let next = toks.get(i + 1..).and_then(|s| s.iter().find_map(date_part)); + let after_h = matches!(prev, Some(DatePart::Hour(_) | DatePart::ElapsedH(_))); + let before_s = matches!(next, Some(DatePart::Second(_) | DatePart::ElapsedS(_))); + if (after_h || before_s) + && let Some(t) = toks.get_mut(i) + { + *t = Tok::Date(DatePart::Minute(n)); + } + } + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while let Some(t) = toks.get(i) { + if matches!(t, Tok::Point) && toks.get(i + 1) == Some(&Tok::Digit('0')) { + let mut n = 0usize; + while toks.get(i + 1 + n) == Some(&Tok::Digit('0')) { + n += 1; + } + out.push(Tok::Date(DatePart::SubSec(n.min(255) as u8))); + i += 1 + n; + continue; + } + match t { + Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + i += 1; + } + sec.toks = out; + sec +} + +fn analyze_general(toks: Vec, mut sec: Section) -> Section { + let mut out = Vec::with_capacity(toks.len()); + for t in &toks { + match t { + Tok::General | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + } + sec.toks = out; + sec +} + +fn analyze_text(toks: Vec, mut sec: Section) -> Section { + let mut out = Vec::with_capacity(toks.len()); + for t in &toks { + match t { + Tok::At | Tok::Fill(_) => out.push(t.clone()), + other => push_lit(&mut out, &other.literal_text()), + } + } + sec.toks = out; + sec +} + +fn analyze_number(mut toks: Vec, mut sec: Section) -> Section { // Number section. let mut seen_point = false; let mut seen_exp = false; diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 2c33d16b4..4b2ee089d 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -247,7 +247,7 @@ fn question_mark_pads_thousands_separator() { } #[test] -fn literals_and_fill() { +fn literals_and_fill() -> Result<(), Box> { assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00"); assert_eq!(f("$#,##0.00", -3.0), "-$3.00"); assert_eq!(f("[$€-407]#,##0.00", 1234.5), "€1,234.50"); @@ -256,7 +256,7 @@ fn literals_and_fill() { assert_eq!(f("(0)", 5.0), "(5)"); let r = full("*-0", Value::Number(5.0)); assert_eq!((r.text.as_str(), r.fill), ("5", Some(('-', 0)))); - let acct = builtin_format(44).unwrap(); + let acct = builtin_format(44).ok_or("none")?; let r = full(acct, Value::Number(0.0)); assert_eq!((r.text.as_str(), r.fill), (" $- ", Some((' ', 2)))); let r = full(acct, Value::Number(1234.5)); @@ -267,6 +267,7 @@ fn literals_and_fill() { assert_eq!((r.text.as_str(), r.numeric), (" abc ", false)); let r = full("-* 0", Value::Number(-5.0)); assert_eq!((r.text.as_str(), r.fill), ("--5", Some((' ', 2)))); + Ok(()) } #[test] @@ -279,6 +280,10 @@ fn dates_and_times() { assert_eq!(f("d-mmm-yy", 46302.0), "7-Oct-26"); assert_eq!(f("e", 46302.0), "2026"); assert_eq!(f("YYYY-MM-DD", 46302.0), "2026-10-07"); +} + +#[test] +fn time_formats() { assert_eq!(f("yyyy-mm-dd hh:mm:ss", 46302.5), "2026-10-07 12:00:00"); assert_eq!(f("h:mm AM/PM", 0.75), "6:00 PM"); assert_eq!(f("h AM/PM", 0.0), "12 AM"); @@ -290,6 +295,10 @@ fn dates_and_times() { assert_eq!(f("[s]", 1.0 / 1440.0), "60"); assert_eq!(f("mm:ss.0", 61.2 / 86400.0), "01:01.2"); assert_eq!(f("mm:ss.00", 61.25 / 86400.0), "01:01.25"); +} + +#[test] +fn date_serial_boundaries() { assert_eq!(f("h:mm:ss", 0.5 + 59.6 / 86400.0), "12:01:00"); assert_eq!(f("h:mm", 0.5 + 40.0 / 86400.0), "12:00"); assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900"); @@ -342,12 +351,12 @@ fn general() { } #[test] -fn kinds() { +fn kinds() -> Result<(), Box> { assert_eq!(NumberFormat::general().kind(), FormatKind::General); assert_eq!(NumberFormat::parse("0.00").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("#,##0.00;[Red](#,##0.00)").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("\"$\"#,##0.00").kind(), FormatKind::Currency); - assert_eq!(NumberFormat::parse(builtin_format(44).unwrap()).kind(), FormatKind::Accounting); + assert_eq!(NumberFormat::parse(builtin_format(44).ok_or("none")?).kind(), FormatKind::Accounting); assert_eq!(NumberFormat::parse("m/d/yyyy").kind(), FormatKind::Date); assert_eq!(NumberFormat::parse("h:mm AM/PM").kind(), FormatKind::Time); assert_eq!(NumberFormat::parse("0%").kind(), FormatKind::Percentage); @@ -356,10 +365,11 @@ fn kinds() { assert_eq!(NumberFormat::parse("@").kind(), FormatKind::Text); assert_eq!(NumberFormat::parse("00000").kind(), FormatKind::Special); assert_eq!(NumberFormat::parse("\"hello\"").kind(), FormatKind::Custom); + Ok(()) } #[test] -fn builtins() { +fn builtins() -> Result<(), Box> { assert_eq!(builtin_format(0), Some("General")); assert_eq!(builtin_format(14), Some("m/d/yyyy")); assert_eq!(builtin_format(49), Some("@")); @@ -375,9 +385,10 @@ fn builtins() { assert_eq!(builtin_id(code), Some(id)); } } - assert_eq!(f(builtin_format(5).unwrap(), -1234.0), "($1,234)"); - assert_eq!(f(builtin_format(12).unwrap(), 0.5), " 1/2"); - assert_eq!(f(builtin_format(47).unwrap(), 61.2 / 86400.0), "0101.2"); + assert_eq!(f(builtin_format(5).ok_or("none")?, -1234.0), "($1,234)"); + assert_eq!(f(builtin_format(12).ok_or("none")?, 0.5), " 1/2"); + assert_eq!(f(builtin_format(47).ok_or("none")?, 61.2 / 86400.0), "0101.2"); + Ok(()) } #[test] @@ -844,28 +855,29 @@ fn format_codes_are_capped_at_255_characters() { assert_eq!(f(&huge, 0.5), "0.5"); } -/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic +/// Parses and renders `code` on a worker thread; errors if that takes more than 20 s, so a quadratic /// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes /// minutes and an O(n) pass milliseconds, so the margin is decades wide. -fn finishes_in_time(code: String) -> String { +fn finishes_in_time(code: String) -> Result { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { // Uncapped: `parse` turns a code past 255 characters into General before the parser sees it. let _ = tx.send(format_value(&Value::Number(1234.5), &NumberFormat::parse_uncapped(&code), S).text); }); - rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time") + rx.recv_timeout(std::time::Duration::from_secs(20)) } #[test] -fn hostile_codes_parse_in_linear_time() { +fn hostile_codes_parse_in_linear_time() -> Result<(), std::sync::mpsc::RecvTimeoutError> { let n = 1_000_000; // Unclosed brackets, a run of commas after a digit, a run of fraction bars. - assert!(finishes_in_time("[".repeat(n)).starts_with('[')); - finishes_in_time(format!("0{}", ",".repeat(n))); - finishes_in_time(format!("0{}", "/".repeat(n))); - finishes_in_time(format!("0{}0", ",".repeat(n))); + assert!(finishes_in_time("[".repeat(n))?.starts_with('[')); + finishes_in_time(format!("0{}", ",".repeat(n)))?; + finishes_in_time(format!("0{}", "/".repeat(n)))?; + finishes_in_time(format!("0{}0", ",".repeat(n)))?; // Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one. - finishes_in_time("[;".repeat(n / 2)); + finishes_in_time("[;".repeat(n / 2))?; + Ok(()) } #[test] diff --git a/crates/numfmt/tests/poi_number_format_approx.rs b/crates/numfmt/tests/poi_number_format_approx.rs index 0f9932101..f98267705 100644 --- a/crates/numfmt/tests/poi_number_format_approx.rs +++ b/crates/numfmt/tests/poi_number_format_approx.rs @@ -7,7 +7,6 @@ use gridcraft_numfmt::text_function; /// (format, value, Excel's result). // The last row's 3.14159 is the workbook's value, not an approximation of pi. -#[allow(clippy::approx_constant)] const CASES: &[(&str, f64, &str)] = &[ (r"|#,e-#|", 123456.789, r"|1e5|"), (r"|#e-#,|", 123456.789, r"|1e5|"), @@ -256,7 +255,7 @@ const CASES: &[(&str, f64, &str)] = &[ (r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"), (r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"), (r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"), - (r"#,##0.00e+00", 3.14159, r"3.14e+00"), + (r"#,##0.00e+00", 314_159.0 / 100_000.0, r"3.14e+00"), ]; #[test] diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6cb0d2e3a..cd63456c2 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true description = "GridCraft's tiny self-contained PDF 1.7 writer (standard 14 fonts, vector drawing, RGBA images)" +readme = "README.md" [lints] workspace = true diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 000000000..cdb6308eb --- /dev/null +++ b/crates/pdf/README.md @@ -0,0 +1,33 @@ +# gridcraft-pdf + +A small, dependency-free PDF 1.7 writer for vector drawing and RGBA images. + +## API migration + +The geometry-taking methods now use `Point` and `Rect` values to name their coordinates, and +`image_rgba` groups its pixel dimensions into an array. Update calls to the four changed methods as +follows: + +```rust +use gridcraft_pdf::{Font, PdfDoc, Point, Rect}; + +let mut doc = PdfDoc::new(); +let page = doc.add_page(612.0, 792.0); + +// Before: page.link_page(20.0, 30.0, 80.0, 16.0, 1, 40.0, 50.0); +page.link_page(Rect::new(20.0, 30.0, 80.0, 16.0), 1, Point::new(40.0, 50.0)); + +// Before: page.stroke_line_dashed(0.0, 0.0, 100.0, 40.0, 1.0, [0, 0, 0], &[4.0, 2.0]); +page.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(100.0, 40.0), 1.0, [0, 0, 0], &[4.0, 2.0]); + +// Before: page.text_rotated(20.0, 40.0, 12.0, Font::Helvetica, [0, 0, 0], "Label", 0.2); +page.text_rotated(Point::new(20.0, 40.0), 12.0, Font::Helvetica, [0, 0, 0], "Label", 0.2); + +let rgba = [255, 0, 0, 255]; +// Before: page.image_rgba(10.0, 10.0, 24.0, 24.0, &rgba, 1, 1); +page.image_rgba(Rect::new(10.0, 10.0, 24.0, 24.0), &rgba, [1, 1]); +``` + +`Point::new(x, y)` uses the page's top-left origin, where y increases downward. `Rect::new(x, y, +width, height)` describes the destination or link rectangle in the same coordinate system. The +`image_rgba` pixel-size array is `[width, height]`. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e00b4fcc3..5270d4311 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -20,6 +20,34 @@ use std::fmt::Write as _; /// RGB colour, 0–255 per channel. pub type Rgb = [u8; 3]; +/// A point in page coordinates, measured from the top-left corner. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Point { + pub x: f32, + pub y: f32, +} + +impl Point { + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +/// A page-space rectangle described by its top-left corner and extent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Rect { + pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { x, y, width, height } + } +} + /// The standard 14 fonts minus Symbol and ZapfDingbats. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Font { @@ -353,11 +381,12 @@ impl Page { } } - fn link_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<[f32; 4]> { - if !finite(&[x, y, w, h]) || !extent(w) || !extent(h) || self.links.len() >= MAX_PAGE_LINKS { + fn link_rect(&self, rect: Rect) -> Option<[f32; 4]> { + let Rect { x, y, width, height } = rect; + if !finite(&[x, y, width, height]) || !extent(width) || !extent(height) || self.links.len() >= MAX_PAGE_LINKS { return None; } - let (right, bottom) = (x + w, y + h); + let (right, bottom) = (x + width, y + height); if !finite(&[right, bottom]) { return None; } @@ -374,18 +403,18 @@ impl Page { if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) { return; } - if let Some(rect) = self.link_rect(x, y, w, h) { + if let Some(rect) = self.link_rect(Rect::new(x, y, w, h)) { self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) }); } } /// Adds a link to a location on another page of this document (zero-based page index). - pub fn link_page(&mut self, x: f32, y: f32, w: f32, h: f32, page: usize, target_x: f32, target_y: f32) { - if !finite(&[target_x, target_y]) { + pub fn link_page(&mut self, rect: Rect, page: usize, target: Point) { + if !finite(&[target.x, target.y]) { return; } - if let Some(rect) = self.link_rect(x, y, w, h) { - self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target_x, y: target_y } }); + if let Some(rect) = self.link_rect(rect) { + self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target.x, y: target.y } }); } } @@ -424,12 +453,12 @@ impl Page { } pub fn stroke_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb) { - self.stroke_line_dashed(x0, y0, x1, y1, width, color, &[]); + self.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), width, color, &[]); } /// A line with a dash pattern (`[on, off, …]` in points; empty = solid). - pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) { - if !finite(&[x0, y0, x1, y1]) { + pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) { + if !finite(&[start.x, start.y, end.x, end.y]) { return; } let s = format!( @@ -437,10 +466,10 @@ impl Page { rgb_op(color, true), n(stroke_width(width)), dash_values(dash), - n(x0), - n(self.y(y0)), - n(x1), - n(self.y(y1)) + n(start.x), + n(self.y(start.y)), + n(end.x), + n(self.y(end.y)) ); self.op(&s); } @@ -543,20 +572,20 @@ impl Page { /// Text with its baseline starting at `(x, y)`. pub fn text(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str) { - self.text_rotated(x, y, size, font, color, text, 0.0); + self.text_rotated(Point::new(x, y), size, font, color, text, 0.0); } /// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate /// system) about its baseline origin. - pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { - if text.is_empty() || !extent(size) || !finite(&[x, y]) { + pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) { + if text.is_empty() || !extent(size) || !finite(&[origin.x, origin.y]) { return; } let fi = font.index(); let size = n(size.min(10_000.0)); let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; // Clockwise in y-down = counter-clockwise negated in PDF's y-up space. - let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(x), n(self.y(y))); + let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y))); // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj` // advances the text position, so the runs follow each other on the baseline. let mut rest = text; @@ -577,7 +606,9 @@ impl Page { /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box /// `(x, y, w, h)`. Malformed or oversized buffers are ignored. - pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { + pub fn image_rgba(&mut self, destination: Rect, rgba: &[u8], pixel_size: [u32; 2]) { + let Rect { x, y, width: w, height: h } = destination; + let [pw, ph] = pixel_size; let px = pw as u64 * ph as u64; if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e540685d0..bd1ad73a8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1,4 +1,6 @@ use super::*; +use std::error::Error; +use std::io; fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) @@ -8,22 +10,23 @@ fn rfind(hay: &[u8], needle: &[u8]) -> Option { } /// Parses the xref table back and checks each in-use entry points at `N 0 obj`. -fn check_xref(pdf: &[u8]) -> usize { - let sx = rfind(pdf, b"startxref\n").unwrap(); - let rest = std::str::from_utf8(&pdf[sx + 10..]).unwrap(); - let off: usize = rest.lines().next().unwrap().trim().parse().unwrap(); +fn check_xref(pdf: &[u8]) -> Result> { + let sx = rfind(pdf, b"startxref\n").ok_or_else(|| io::Error::other("missing startxref"))?; + let rest = std::str::from_utf8(&pdf[sx + 10..])?; + let off: usize = rest.lines().next().ok_or_else(|| io::Error::other("missing xref offset"))?.trim().parse()?; assert!(pdf[off..].starts_with(b"xref\n"), "startxref points at xref"); - let table = std::str::from_utf8(&pdf[off..sx]).unwrap(); + let table = std::str::from_utf8(&pdf[off..sx])?; let mut lines = table.lines(); assert_eq!(lines.next(), Some("xref")); - let hdr: Vec = lines.next().unwrap().split(' ').map(|x| x.parse().unwrap()).collect(); - assert_eq!(hdr[0], 0); - let count = hdr[1]; + let hdr: Vec = + lines.next().ok_or_else(|| io::Error::other("missing xref header"))?.split(' ').map(str::parse).collect::>()?; + assert_eq!(hdr.first(), Some(&0)); + let count = *hdr.get(1).ok_or_else(|| io::Error::other("missing xref count"))?; let mut checked = 0; for i in 0..count { - let e = lines.next().unwrap(); + let e = lines.next().ok_or_else(|| io::Error::other("missing xref entry"))?; assert_eq!(e.len(), 19, "entry `{e}` (20 bytes with newline)"); - let o: usize = e[..10].parse().unwrap(); + let o: usize = e.get(..10).ok_or_else(|| io::Error::other("short xref offset"))?.parse()?; if e.trim_end().ends_with('n') { let want = format!("{i} 0 obj"); assert!(pdf[o..].starts_with(want.as_bytes()), "object {i} at {o}"); @@ -32,38 +35,39 @@ fn check_xref(pdf: &[u8]) -> usize { } assert!(table.contains("trailer")); assert!(table.contains(&format!("/Size {count}"))); - checked + Ok(checked) } #[test] -fn minimal_document() { +fn minimal_document() -> Result<(), Box> { let pdf = PdfDoc::new().finish(); assert!(pdf.starts_with(b"%PDF-1.7")); assert!(pdf.ends_with(b"%%EOF\n")); - assert!(check_xref(&pdf) >= 5); + assert!(check_xref(&pdf)? >= 5); + Ok(()) } #[test] -fn drawing_and_text() { +fn drawing_and_text() -> Result<(), Box> { let mut d = PdfDoc::new(); d.info.title = "Report (Q1)".into(); let p = d.add_page(612.0, 792.0); p.fill_rect(10.0, 10.0, 100.0, 20.0, [255, 255, 0]); p.rect_stroke(10.0, 10.0, 100.0, 20.0, 0.5, [0, 0, 0]); p.stroke_line(0.0, 0.0, 612.0, 792.0, 1.0, [255, 0, 0]); - p.stroke_line_dashed(0.0, 5.0, 100.0, 5.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + p.stroke_line_dashed(Point::new(0.0, 5.0), Point::new(100.0, 5.0), 1.0, [0, 0, 0], &[3.0, 2.0]); p.polygon(&[[0.0, 0.0], [10.0, 0.0], [5.0, 8.0]], Some([0, 0, 255]), Some(([0, 0, 0], 1.0))); p.circle(300.0, 300.0, 20.0, Some([0, 128, 0]), None); p.save(); p.clip_rect(0.0, 0.0, 50.0, 50.0); p.text(72.0, 72.0, 12.0, Font::Helvetica, [0, 0, 0], "Hello (world) \\ café €5"); p.restore(); - p.text_rotated(100.0, 100.0, 9.0, Font::TimesBold, [0, 0, 0], "Up", -std::f32::consts::FRAC_PI_2); + p.text_rotated(Point::new(100.0, 100.0), 9.0, Font::TimesBold, [0, 0, 0], "Up", -std::f32::consts::FRAC_PI_2); let p2 = d.add_page(842.0, 595.0); p2.text(10.0, 20.0, 10.0, Font::CourierBold, [0, 0, 0], "Second"); assert_eq!(d.page_count(), 2); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"(Hello \\(world\\) \\\\ caf\\351 \\2005) Tj").is_some()); assert!(find(&pdf, b"(Second) Tj").is_some()); assert!(find(&pdf, b"/BaseFont /Helvetica /Encoding /WinAnsiEncoding").is_some()); @@ -74,21 +78,22 @@ fn drawing_and_text() { assert!(find(&pdf, b"/Title (Report \\(Q1\\))").is_some()); // Top-left origin: a line from (0,0) starts at the top of the page. assert!(find(&pdf, b"0 792 m 612 0 l S").is_some()); + Ok(()) } #[test] -fn uri_and_page_links_are_real_pdf_annotations() { +fn uri_and_page_links_are_real_pdf_annotations() -> Result<(), Box> { let mut d = PdfDoc::new(); let first = d.add_page(200.0, 300.0); first.link_uri(10.0, 20.0, 80.0, 30.0, "https://example.org/a(b)?q=£"); - first.link_page(100.0, 40.0, 50.0, 20.0, 1, 35.0, 60.0); + first.link_page(Rect::new(100.0, 40.0, 50.0, 20.0), 1, Point::new(35.0, 60.0)); first.link_uri(-20.0, -20.0, 40.0, 40.0, "mailto:a@example.org"); first.link_uri(0.0, 0.0, f32::NAN, 10.0, "https://bad.example"); first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)"); let second = d.add_page(100.0, 200.0); - second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0); + second.link_page(Rect::new(0.0, 0.0, 10.0, 10.0), 99, Point::new(0.0, 0.0)); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; let s = String::from_utf8_lossy(&pdf); assert_eq!(s.matches("/Subtype /Link").count(), 3); assert!(s.contains("/Annots [")); @@ -97,25 +102,27 @@ fn uri_and_page_links_are_real_pdf_annotations() { assert!(s.contains("/URI (https://example.org/a\\(b\\)?q=%C2%A3)")); assert!(s.contains("/XYZ 35 140 null")); assert!(!s.contains("https://bad.example")); + Ok(()) } #[test] -fn images_with_alpha() { +fn images_with_alpha() -> Result<(), Box> { let mut d = PdfDoc::new(); let p = d.add_page(200.0, 200.0); let mut px = Vec::new(); for i in 0..16u8 { px.extend_from_slice(&[i * 10, 0, 0, if i < 8 { 255 } else { 128 }]); } - p.image_rgba(10.0, 10.0, 40.0, 40.0, &px, 4, 4); - p.image_rgba(10.0, 10.0, 40.0, 40.0, &[255; 16], 2, 2); // opaque: no mask - p.image_rgba(0.0, 0.0, 1.0, 1.0, &[1, 2, 3], 4, 4); // too short: ignored + p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &px, [4, 4]); + p.image_rgba(Rect::new(10.0, 10.0, 40.0, 40.0), &[255; 16], [2, 2]); // opaque: no mask + p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[1, 2, 3], [4, 4]); // too short: ignored let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"/SMask").is_some()); assert!(find(&pdf, b"/Im1 Do").is_some()); assert!(find(&pdf, b"/Im2 Do").is_some()); assert!(find(&pdf, b"/Im3").is_none()); + Ok(()) } #[test] @@ -166,7 +173,7 @@ fn widths() { } #[test] -fn hostile_inputs_never_panic() { +fn hostile_inputs_never_panic() -> Result<(), Box> { let mut d = PdfDoc::new(); let p = d.add_page(f32::NAN, -5.0); p.text(f32::INFINITY, f32::NAN, 1e30, Font::Helvetica, [0, 0, 0], "x"); @@ -176,9 +183,10 @@ fn hostile_inputs_never_panic() { p.restore(); p.restore(); p.save(); - p.image_rgba(0.0, 0.0, 10.0, 10.0, &[], u32::MAX, u32::MAX); + p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[], [u32::MAX, u32::MAX]); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; + Ok(()) } fn page_content(d: &PdfDoc) -> &str { @@ -198,9 +206,9 @@ fn text_width_does_not_overflow() { fn all_zero_dash_is_drawn_solid() { let mut d = PdfDoc::new(); let p = d.add_page(100.0, 100.0); - p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[0.0, 0.0]); p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]); - p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[3.0, 2.0]); let c = page_content(&d); assert_eq!(c.matches("[] 0 d").count(), 2); assert!(!c.contains("[0 0]")); @@ -226,15 +234,15 @@ fn non_finite_image_size_stores_nothing() { let p = d.add_page(200.0, 200.0); let px = [255u8; 16]; for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] { - p.image_rgba(0.0, 0.0, w, h, &px, 2, 2); + p.image_rgba(Rect::new(0.0, 0.0, w, h), &px, [2, 2]); } - p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2); + p.image_rgba(Rect::new(f32::NAN, 0.0, 10.0, 10.0), &px, [2, 2]); assert!(p.images.is_empty()); assert!(p.content.is_empty()); } #[test] -fn every_draw_method_survives_hostile_numbers() { +fn every_draw_method_survives_hostile_numbers() -> Result<(), Box> { let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30]; let px = [200u8; 16]; let mut d = PdfDoc::new(); @@ -245,41 +253,45 @@ fn every_draw_method_survives_hostile_numbers() { p.fill_rect(a, b, b, a, [1, 2, 3]); p.rect_stroke(a, b, a, b, a, [1, 2, 3]); p.stroke_line(a, b, b, a, a, [1, 2, 3]); - p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]); + p.stroke_line_dashed(Point::new(a, b), Point::new(b, a), b, [1, 2, 3], &[a, b, 0.0]); p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]); p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a))); p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a))); p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x"); - p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b); - p.image_rgba(a, b, b, a, &px, 2, 2); + p.text_rotated(Point::new(a, b), 10.0, Font::TimesRoman, [0, 0, 0], "y", b); + p.image_rgba(Rect::new(a, b, b, a), &px, [2, 2]); } } - p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]); + p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(1.0, 1.0), 1.0, [0, 0, 0], &[0.0; 100]); let c = page_content(&d); assert!(!c.contains("NaN") && !c.contains("inf")); let pdf = d.finish(); - check_xref(&pdf); + check_xref(&pdf)?; + Ok(()) } /// Decodes the `` hex string following `key` in the file bytes. -fn utf16_entry(pdf: &[u8], key: &str) -> String { - let at = find(pdf, key.as_bytes()).unwrap() + key.len(); - let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); - let hex = rest.trim_start().strip_prefix("').unwrap()]; - let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); - String::from_utf16(&units).unwrap() +fn utf16_entry(pdf: &[u8], key: &str) -> Result> { + let at = find(pdf, key.as_bytes()).ok_or_else(|| io::Error::other("missing PDF info key"))? + key.len(); + let end = at + 9000.min(pdf.len() - at); + let rest = std::str::from_utf8(pdf.get(at..end).ok_or_else(|| io::Error::other("short PDF info value"))?)?; + let hex = rest.trim_start().strip_prefix("').ok_or_else(|| io::Error::other("missing UTF-16 terminator"))?; + let hex = hex.get(..end).ok_or_else(|| io::Error::other("invalid UTF-16 range"))?; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(hex.get(i..i + 4).unwrap_or(""), 16)).collect::>()?; + Ok(String::from_utf16(&units)?) } #[test] -fn info_strings_with_non_ascii_are_utf16be() { +fn info_strings_with_non_ascii_are_utf16be() -> Result<(), Box> { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); doc.info.author = "Müller & Søn".into(); let pdf = doc.finish(); - assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); - assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); + assert_eq!(utf16_entry(&pdf, "/Title")?, "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author")?, "Müller & Søn"); + Ok(()) } #[test] @@ -293,22 +305,23 @@ fn ascii_info_strings_stay_literal_and_escaped() { } #[test] -fn info_strings_are_capped() { +fn info_strings_are_capped() -> Result<(), Box> { let mut doc = PdfDoc::new(); doc.add_page(200.0, 200.0); doc.info.title = "日".repeat(100_000); let pdf = doc.finish(); - assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); + assert_eq!(utf16_entry(&pdf, "/Title")?.chars().count(), MAX_INFO_CHARS); + Ok(()) } #[test] -fn cjk_text_is_not_replaced_by_question_marks() { +fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box> { let mut doc = PdfDoc::new(); let p = doc.add_page(612.0, 792.0); p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], "签字清单"); p.text(10.0, 40.0, 10.0, Font::Helvetica, [0, 0, 0], "No. 编号 2"); let pdf = doc.finish(); - check_xref(&pdf); + check_xref(&pdf)?; assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks"); assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some()); assert!(find(&pdf, b"(No. ) Tj").is_some()); @@ -317,4 +330,5 @@ fn cjk_text_is_not_replaced_by_question_marks() { assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some()); // Measured as full-width glyphs, matching the CID font's default width. assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); + Ok(()) } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ac1a67c33..297f7bab9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every # craft-libs dependency uses (see the root Cargo.toml). -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind @@ -23,6 +23,7 @@ serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } skrifa = { workspace = true } +web-time = { workspace = true } # Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see # `desktop_theme.rs`). Already in the tree, with these features, through egui's own diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 07ab18a23..0c5dbf3e8 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose +//! people.toml entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 000000000..6fa5def4f --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,531 @@ +#[cfg(test)] +use super::clock::monotonic_ms; +use super::clock::now_ms; +use super::services::{Chrome, Services}; +use super::state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{ + CellRef, HashMap, INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, Notice, Perf, RangeRef, Session, SheetApp, UiRequest, UiState, backstage, + clamp_interface_scale, clean_error, control, desktop_theme, dialogs, editor, floating_pictures, formula_bar, formula_locale, grid, i18n, json, + keytips, macro_ui, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets, +}; + +mod clipboard; +mod commands; +mod editing; +mod files; +mod fonts; +mod input; +mod keys; +mod prefs; +mod print; +mod quit; +mod view; + +impl SheetApp { + /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. + pub fn new(session: Session, services: Services) -> SheetApp { + SheetApp { + session, + ui: UiState::default(), + chrome: Chrome::default(), + views: HashMap::new(), + editor: None, + text_box_editor: None, + dialog: None, + backstage: None, + message: None, + toast: None, + notice: None, + services, + printing: Default::default(), + print_feedback_seen: None, + synthetic: Default::default(), + synthetic_pending: false, + synthetic_injected_at_pass: None, + control_rx: None, + grid: grid::GridState { + last_border: "bottom".into(), + last_fill: "#FFFF00".into(), + last_font_color: "#C00000".into(), + ..Default::default() + }, + perf: Perf::default(), + fonts_ready: false, + fonts_set: false, + prepared_fonts: false, + fonts_bound: Vec::new(), + fonts_seen: (0, 0), + fonts_rebind: false, + fonts_han: None, + effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::default(), + desktop_dark: None, + name_box: None, + keytips: keytips::KeyTips::default(), + shots: control::Shots::default(), + capture_epoch: 0, + capture_geometry: None, + capture_geometry_from_raw: false, + selected_chart: None, + clipboard_text_at_copy: None, + cut_text: None, + tab_run: None, + macros: macro_ui::MacroUi::default(), + started: now_ms(), + allow_close: false, + open_switch_windows: false, + ui_window: None, + parked: HashMap::new(), + native_windows: false, + arrange_windows: None, + #[cfg(not(target_arch = "wasm32"))] + updates_rt: Default::default(), + } + } + + /// One-time context setup: start cataloging the machine's fonts, then install the fonts and + /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). + /// + /// [`logic`]: SheetApp::logic + pub fn setup_context(ctx: &egui::Context, dark: bool) { + crate::fonts::start_scan(); + Self::setup_context_for_language(ctx, dark, i18n::Language::En); + } + + /// Sets up fonts and visuals for a specific persisted interface language. + pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { + ctx.set_fonts(theme::font_definitions_for_language(language)); + theme::apply(ctx, dark); + } + + /// Per-frame logic (control channel, screenshots). Call before `ui`. + pub fn logic(&mut self, ctx: &egui::Context) { + self.ensure_text_width(ctx); + // A keytip control request lives one frame: the ribbon consumes it during `ui`, and + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. + self.ui.keytip_invoke = None; + // Floating pictures' textures go with their workbook, even when no sheet is painted. + let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); + floating_pictures::forget_closed(ctx, &open); + self.apply_font_state(ctx); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + self.sync_theme_preference(ctx); + self.sync_input_options(ctx); + // The geometry captures are bound to. Native frames recorded it from their fresh + // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is + // monotonic, so a resize back to the original values is still a new epoch and old pixels + // are never saved with new geometry. + let capture_geometry = control::current_capture_geometry(self, ctx); + control::note_capture_geometry(self, capture_geometry); + // Drain before admission: completed or timed-out captures release their slots and + // reservation first, so a new request is never refused for budget that is about to free. + // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only + // issues the capture command (after every widget, so a command cannot be missed). + control::drain_screenshots(self, ctx); + control::poll(self, ctx); + // Printing feedback is polled once per frame, here, so hidden windows and logic-only + // frames still see worker completion. `ui` and the desktop app no longer poll it. + self.poll_printing(ctx); + self.guard_window_close(ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); + // Files read asynchronously (web file picker, dropped files). + self.poll_async_opens(); + self.autosave(ctx); + self.update_title(ctx); + self.request_hidden_paint(ctx); + // One window always shows a workbook, unless each workbook has a native window of its own + // (`doc_windows` decides what having none left means). + if self.session.active().is_none() && !self.native_windows { + self.session.new_workbook(); + } + self.follow_active_window(); + } + + /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a + /// pass and a control-channel rename while hidden updates it on return. + fn update_title(&mut self, ctx: &egui::Context) { + // Native windows get theirs from `doc_windows`. + if self.native_windows { + return; + } + let Some(d) = self.session.active() else { return }; + let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); + if self.grid.last_title.as_deref() != Some(title.as_str()) { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.grid.last_title = Some(title); + } + } + + /// A queued/settling capture with a capturable size and an unapplied synthetic step both need + /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` + /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A + /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues + /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden + /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. + /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control + /// protocol documents the limitation. + fn request_hidden_paint(&self, ctx: &egui::Context) { + if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { + return; + } + // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an + // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the + // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } + + /// Lays out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready || self.fonts_rebind { + ctx.request_repaint(); + return; + } + let t0 = now_ms(); + // A script may have switched windows since the last frame. + self.follow_active_window(); + text_box::before_ui(self, &ctx); + // Another window came forward without going through a command (which settles drafts + // first): the draft goes into its own window instead of floating over this one. If its + // workbook refuses it, its window comes back to the front with the draft open. + if self.draft_elsewhere() { + self.commit_edit(0, 0, false, false); + } + // The language the widgets translate with this frame (see `i18n::current`). + i18n::set_current(&ctx, self.ui.language); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, &ctx); + let t = theme::Tokens::get(&ctx); + // A host can turn the File tab off while Backstage is already open: close it. + if !self.chrome.file_tab { + self.backstage = None; + } + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { + self.ribbon_keys(&ctx); + } + if self.chrome.title_bar { + ribbon::title_bar(self, ui); + } + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + widgets::notice_bar(self, ui); + if self.session.prefs.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); + } + dialogs::show(self, &ctx); + macro_ui::forget_closed_editor(self); + macro_ui::question_box(self, &ctx); + widgets::message_box(self, &ctx); + widgets::toast(self, &ctx); + // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. + if ctx.viewport_id() == egui::ViewportId::ROOT { + control::issue_screenshots(self, &ctx); + } + if !ctx.input(|i| i.focused) { + // Nothing else to do. + } + let now = now_ms(); + self.perf.frame_ms = now - t0; + if self.perf.last > 0.0 { + let dt = now - self.perf.last; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; + } + } + self.perf.last = now; + // The step is applied only after a completed UI pass. A discarded or repeated pass of the + // same frame must not offer it again (grid sizing calls `ui` more than once per frame); + // provenance is the injected pass number, so only a pass that missed input handling is + // re-offered by `raw_input_hook`. + // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. + if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { + self.synthetic.pop_front(); + self.synthetic_pending = false; + self.synthetic_injected_at_pass = None; + } + } + + /// Current selection's top-left cell text as A1 (for the Name Box). + pub fn name_box_text(&self) -> String { + let Some(d) = self.session.active() else { return String::new() }; + let sel = &d.selection; + let cur = sel.current(); + // A named range that exactly matches shows its name. + let sh = d.wb.active(); + if let Some(sh) = sh { + for n in &d.wb.names { + let target = n.formula.replace('$', ""); + let (sheet, body) = match target.rsplit_once('!') { + Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), + None => (None, target.clone()), + }; + if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { + return n.name.clone(); + } + } + } + if self.grid.drag_select && !cur.is_single() { + return format!("{}R x {}C", cur.height(), cur.width()); + } + sel.active.a1() + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + /// Builds a key-A press or release event without modifiers. + fn key_event(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// Returns a test app with a new workbook and default services. + fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Services::default()) + } + + /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it + /// is dropped unapplied). + fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out + } + + #[test] + /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. + fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic.push_back(vec![key_event(false)]); + + let mut first = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1, "only one step is offered"); + assert!(app.synthetic_pending); + assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); + + // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step + // only once; a hidden logic-only tick does not duplicate it or offer the release. + let mut second = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); + assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); + } + + /// Returns raw input with a screen rect at the given size. + fn raw_with_size(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } + } + + #[test] + /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. + fn a_font_gated_pass_offers_the_synthetic_step_again() { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut app = app(); + app.fonts_ready = true; + app.fonts_set = true; + // Binds `session.text_width`, as eframe's first frame does. + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + app.synthetic.push_back(vec![key_event(true)]); + + // The font gate returns before input handling; `begin_pass` has still consumed the events. + app.fonts_rebind = true; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); + assert_eq!(app.synthetic.len(), 1, "the step is still queued"); + + // A completed pass that did not apply it made the hook offer it again for a real pass. + app.fonts_rebind = false; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); + } + + #[test] + /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. + fn a_pending_step_delays_a_font_change() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.fonts_han = Some(theme::HanOrder::Chinese); + app.ui.language = i18n::Language::Ja; + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); + app.synthetic.clear(); + app.synthetic_pending = false; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); + } + + #[test] + /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. + fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { + /// Runs one logic tick with the requested width and native scale. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); + viewport.native_pixels_per_point = Some(native_ppp); + } + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0, 2.0); + let first = app.capture_geometry.expect("the first logic tick records the geometry"); + assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); + assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); + tick(&ctx, &mut app, 900.0, 2.0); + let resized = app.capture_geometry.expect("geometry"); + assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); + let epoch = app.capture_epoch; + tick(&ctx, &mut app, 800.0, 2.0); + assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); + assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); + } + + #[test] + /// A resize and a resize back both advance the capture epoch. + fn the_capture_epoch_changes_even_when_the_geometry_returns() { + /// Runs one UI pass at the requested width. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0); + let base = app.capture_epoch; + tick(&ctx, &mut app, 900.0); + let changed = app.capture_epoch; + assert!(changed > base, "a resize increments the capture epoch"); + tick(&ctx, &mut app, 800.0); + assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); + } + + /// Extracts title viewport commands from a logic output. + fn titles(out: &egui::LogicOutput) -> Vec { + out.viewport_commands + .values() + .flatten() + .filter_map(|c| match c { + egui::ViewportCommand::Title(title) => Some(title.clone()), + _ => None, + }) + .collect() + } + + #[test] + /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. + fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("autosave.xlsx"); + app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + app.ui.autosave = true; + app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); + app.grid.last_autosave = monotonic_ms() - 3000.0; + + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); + assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); + let saved_at = app.grid.last_autosave; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + /// The title is sent on the first tick and after a rename, but not on every unchanged frame. + fn a_logic_only_tick_updates_the_title_once_per_change() { + let ctx = egui::Context::default(); + let mut app = app(); + let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let title = app.session.active().unwrap().display_title().to_string(); + let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); + let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); + + app.session.active_mut().unwrap().title = "Renamed".into(); + let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); + } + + #[test] + /// A hidden tick surfaces a failed print job in the app message. + fn a_logic_only_tick_polls_printing_feedback() { + let ctx = egui::Context::default(); + let mut app = app(); + app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); + app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run("ui.printSubmit", serde_json::json!({})).unwrap(); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); + } + + #[test] + /// Pending synthetic input makes logic request a real hidden paint. + fn a_pending_synthetic_step_requests_a_hidden_paint() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let painted = out + .viewport_output + .get(&ctx.viewport_id()) + .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); + } +} diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 000000000..da75d8b73 --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,77 @@ +use super::{Json, SheetApp, json}; + +impl SheetApp { + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + // Only ask the engine for HTML when the host can publish it. + let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { + Ok(result) => result, + Err(e) => return self.alert(e), + }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + // The system clipboard gets this copy, so its text decides what Paste pastes. + self.clipboard_text_at_copy = None; + self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + + /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), + /// with the clipboard's HTML flavour when the host can read it. + /// + /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text + /// that came with it is not pasted again over the moved block; GridCraft's own (empty) + /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the + /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. + pub fn paste_external(&mut self, text: String) { + if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { + if self.session.clipboard.is_none() { + return self.run_or_alert("edit.paste", json!({})); + } + } else { + self.cut_text = None; + } + let html = self.services.paste_html.as_mut().and_then(|read| read()); + let params = match html { + Some(html) => json!({"text": text, "html": html}), + None => json!({"text": text}), + }; + self.run_or_alert("edit.paste", params); + } + + /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no + /// text there, no host clipboard (web), or the text that was there when GridCraft last copied + /// without publishing, GridCraft's own clipboard is pasted. + pub fn paste_from_clipboard(&mut self) { + match self.system_clipboard_text() { + Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), + _ => self.run_or_alert("edit.paste", json!({})), + } + } + + /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. + /// `None` when it holds no text or the host can't read it. + pub(super) fn system_clipboard_text(&mut self) -> Option { + self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) + } + + /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the + /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). + fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { + fn chars(t: &str) -> impl Iterator + '_ { + t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') + } + match (a, b) { + (Some(a), Some(b)) => chars(a).eq(chars(b)), + _ => false, + } + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 000000000..c7dcde0ff --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,368 @@ +use super::{ + INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, NoticeKind, SheetApp, UiRequest, backstage, clamp_interface_scale, clean_error, dialogs, + formula_locale, i18n, json, now_ms, printing, +}; + +impl SheetApp { + /// Runs an engine command (or a UI command) and handles UI requests it makes. + pub fn run(&mut self, id: &str, params: Json) -> Result { + let r = self.run_command(id, params); + // This copy leaves the system clipboard alone: note what it holds, so Paste can tell + // that GridCraft's copy is newer while that text is still there. + if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { + self.clipboard_text_at_copy = self.system_clipboard_text(); + self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; + } + r + } + + pub(super) fn run_command(&mut self, id: &str, params: Json) -> Result { + let switches_window = id.starts_with("window.") + || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") + || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); + if id.starts_with("file.") || switches_window { + self.settle_drafts()?; + } + if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { + self.store_view_state(); + } + if matches!( + id, + "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) { + self.commit_text_box_edit()?; + } + if let Some(r) = self.run_ui_command(id, ¶ms) { + return r; + } + let before = self.session.active_window(); + let r = self.session.run(id, params); + if id.starts_with("file.") && self.session.active_window() != before { + // File › New or Open from the Backstage view: the window it was in goes back to its sheet. + self.backstage = None; + } + // Requests the command made (a dialog, a draft) belong to the window it left active. + self.follow_active_window(); + self.after_engine(); + if id == "formulas.autoSum" + && let Some(ed) = self.editor.as_mut() + && ed.text.starts_with('=') + && ed.text.ends_with("()") + { + ed.caret = ed.text.chars().count().saturating_sub(1); + ed.anchor = ed.caret; + ed.sync_caret = true; + } + if id == "view.arrangeAll" + && self.native_windows + && let Ok(r) = &r + { + let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); + let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + self.arrange_windows = Some((arrange, windows)); + } + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } + if id == "file.exportPdf" + && let Ok(r) = &r + { + self.deliver_pdf(r); + } + r + } + + pub fn run_or_alert(&mut self, id: &str, params: Json) { + // A menu/shortcut opens the settings first; the Print page's button submits explicitly. + if id == "file.print" { + if self.services.print_native.is_some() { + self.open_backstage(backstage::Page::Print); + return; + } + return self.print_now(params); + } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } + // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks + // which chart to insert; programmatic callers of `run` still get the first recommendation. + if id == "insert.recommendedCharts" && params.get("type").is_none() { + return self.open_dialog("recommendedCharts", json!({})); + } + // Switch Windows with no window named (key tip, command search) opens the View tab's + // list to choose from; programmatic callers of `run` get the list back. + if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { + self.ui.ribbon_tab = "View".into(); + self.ui.ribbon_collapsed = false; + self.open_switch_windows = true; + return; + } + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} + } + } + + /// Shows a command error: a toast for an unavailable command, else a message box. + pub(super) fn alert(&mut self, e: String) { + // Shown in the window the user ends up in, also after a command that switched windows. + self.follow_active_window(); + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); + } + } + + /// UI-only commands (dialogs, view toggles that aren't saved in the file). + fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { + let r = match id { + "app.checkForUpdates" | "help.checkForUpdates" => { + self.check_for_updates(); + Ok(Json::Null) + } + "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), + "ui.printSubmit" => self.submit_print(p.clone()), + "ui.ribbonTab" => { + self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); + Ok(Json::Null) + } + "view.collapseRibbon" => { + self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; + Ok(Json::Null) + } + "view.expandFormulaBar" => { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } + "view.darkMode" => { + let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; + self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); + self.ui.system_theme = false; + Ok(json!({"on": self.ui.dark})) + } + "view.theme" => { + match p.get("mode").and_then(Json::as_str) { + Some("system") => self.ui.system_theme = true, + Some(mode @ ("light" | "dark")) => { + self.ui.dark = mode == "dark"; + self.ui.system_theme = false; + } + _ => return Some(Err("theme mode must be system, light, or dark".into())), + } + Ok(json!({"mode": self.ui.theme_mode()})) + } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } + "options.autoComplete" => { + self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); + Ok(json!({"on": self.ui.autocomplete})) + } + "view.interfaceScale" => { + // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. + let percent = match p.get("percent") { + Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), + Some(v) => v.as_f64().and_then(clamp_interface_scale), + None => None, + }; + match percent { + Some(v) => { + self.ui.interface_scale = v; + Ok(json!({"percent": v})) + } + None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), + } + } + "app.language.set" => { + // `{"language": "ja"}`; `code` is accepted as an alias. + let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); + match i18n::Language::parse(code) { + Some(l) => { + self.ui.language = l; + Ok(json!({"language": l})) + } + None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), + } + } + "app.language.english" => { + self.ui.language = i18n::Language::En; + Ok(json!({"language": i18n::Language::En})) + } + "app.language.japanese" => { + self.ui.language = i18n::Language::Ja; + Ok(json!({"language": i18n::Language::Ja})) + } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } + "ui.dialog" => { + let name = p.get("name").and_then(Json::as_str).unwrap_or(""); + self.open_dialog(name, p.clone()); + Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) + } + "ui.message.dismiss" => { + self.message = None; + Ok(Json::Null) + } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } + _ => return None, + }; + Some(r) + } + + /// Picks up requests commands made (dialogs, edit mode…). + pub fn after_engine(&mut self) { + // The requests belong to the window the command left active. + self.follow_active_window(); + for req in self.session.take_ui_requests() { + match req { + UiRequest::Dialog(name, params) => self.open_dialog(&name, params), + UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), + UiRequest::EditCell(text) => { + let text = text.map(|text| { + self.session + .active() + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) + .unwrap_or(text) + }); + self.begin_edit(text, false); + } + UiRequest::InsertName(name) => dialogs::insert_name(self, &name), + UiRequest::OpenUrl(u) => { + if let Some(f) = &self.services.open_url { + f(&u); + } + } + } + } + } + + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + + pub fn open_dialog(&mut self, name: &str, params: Json) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } + match name { + "open" => { + if let Some(start) = &self.services.open_async { + start(); + } else if let Some(pick) = &self.services.pick_open + && let Some(path) = pick() + { + self.open_path(&path); + } + } + "saveAs" => { + let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + if let Some(pick) = &self.services.pick_save { + if let Some(path) = pick(&suggested) { + let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; + self.run_or_alert("file.save", json!({"path": path})); + } + } else if self.services.download.is_some() + && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) + && let Some(dl) = &self.services.download + && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); + dl(&name, &b); + } + } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } + // The macro step editor stays until it closes itself (its draft, overlays and debug + // run go with it), so another dialog can't replace it. + _ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => { + self.message = Some(( + "Macro".into(), + "Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(), + )); + } + other => self.dialog = dialogs::Dialog::open(self, other, params), + } + } + + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.chrome.file_tab { + return; + } + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } +} diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs new file mode 100644 index 000000000..24889ec82 --- /dev/null +++ b/crates/ui-egui/src/app/editing.rs @@ -0,0 +1,243 @@ +use super::{Json, SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms}; + +impl SheetApp { + /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. + pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + // The source formula is display-only on spill children. Grid replacement keeps its + // existing behavior, but no formula-bar entry path may edit that displayed formula. + if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { + return; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + 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 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. + if text.is_none() && sh.cell_pictures.contains_key(&at) { + return; + } + let locale = self.ui.language.formula_locale(); + let current = if sh.cell_pictures.contains_key(&at) { + String::new() + } else { + match sh.cell(at) { + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), + _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + } + }; + let (text, replace) = match text { + Some(t) => (t, true), + None => (current.clone(), false), + }; + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); + ed.code_edit = !replace && sh.cell(at).is_some_and(|c| c.py.is_some()); + ed.window = self.session.active_window(); + ed.locale = locale; + if replace && self.ui.autocomplete && !ed.is_formula() { + ed.completion = editor::column_completion(sh, at, &ed.text); + } + self.editor = Some(ed); + self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; + } + + /// Commits the edit; `then` moves the selection (dr, dc) afterwards. + /// + /// The draft goes into the window and workbook it was typed in. If another window is + /// showing, that window stays active afterwards; a draft its own workbook refuses + /// (validation, a bad formula) brings its window back to the front with the draft still open. + pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; + if !self.draft_elsewhere() { + return self.commit_edit_here(dr, dc, array, fill_selection); + } + // Its window, or (when that window closed) its workbook's most recent one. + let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { + self.cancel_edit(); + return true; + }; + let back = self.session.active_window(); + self.session.activate_window(owner); + if !self.commit_edit_here(dr, dc, array, fill_selection) { + // Refused: its window stays in front with the draft open and the reason shown. + return false; + } + if let Some(back) = back { + self.session.activate_window(back); + } + true + } + + /// The cell draft belongs to a window or workbook other than the one showing. + pub(super) fn draft_elsewhere(&self) -> bool { + self.editor.as_ref().is_some_and(|ed| { + self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) + }) + } + + /// Commits the cell and text-box drafts, as Enter would, before a command that can show + /// another workbook or write this one to a file, so a draft never lands in another workbook. + /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. + pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { + if !self.commit_edit(0, 0, false, false) { + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); + } + self.commit_text_box_edit() + } + + fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some(ed) = self.editor.take() else { return true }; + self.session.mode = gridcraft_engine::Mode::Ready; + let text = match &ed.completion { + Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + _ => ed.text.clone(), + }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if (ed.code_edit || (!fill_selection && !array)) && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } + // All the code deleted: the cell is cleared through cell.set, as an empty entry clears any + // other cell. Code of spaces is still code. + if ed.code_edit && !text.is_empty() { + return self.commit_code_edit(ed, text, dr, dc); + } + let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { + Some(Ok(text)) => text, + Some(Err(error)) => { + self.message = Some(("GridCraft".into(), error.to_string())); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + None => text, + }; + // Data validation. + if let Some(d) = self.session.active() + && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) + { + if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { + let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; + self.message = Some((title, msg)); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + self.toast = Some((msg, now_ms())); + } + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) + && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has + // already cleared the cell as its own step). + let unchanged = !fill_selection + && !array + && text.is_empty() + && self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| { + sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.py.is_none() && c.value == gridcraft_engine::core::Value::Empty) + }); + let r = if unchanged { + Ok(json!({})) + } else if fill_selection { + self.session.run("range.fill", json!({"input": text})) + } else { + self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) + }; + match r { + Ok(_) => { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + + /// Commits a changed edit of a Python cell's code through `python.set`, which keeps the cell's + /// return type; `cell.set` would replace the Python cell with an ordinary one holding the code + /// as text. When the command refuses the code, its message shows and the edit stays open. + fn commit_code_edit(&mut self, ed: editor::EditState, code: String, dr: i64, dc: i64) -> bool { + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + let r = if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) { + self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + } else { + Ok(Json::Null) + }; + let r = r.and_then(|_| self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": code}))); + match r { + Ok(_) => { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + + pub fn cancel_edit(&mut self) { + self.editor = None; + self.session.mode = gridcraft_engine::Mode::Ready; + } + + /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. + pub fn move_after_enter(&mut self, dr: i64, dc: i64) { + let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); + if multi { + let fwd = dr > 0 || dc > 0; + let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); + } else { + let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet()); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } + } + self.grid.ensure_visible = true; + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 000000000..0cbde92a8 --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,127 @@ +use super::{Json, Notice, NoticeKind, OpenWindow, SheetApp, clean_error, json}; + +impl SheetApp { + pub fn open_path(&mut self, path: &str) { + match self.run("file.open", json!({"path": path})) { + Ok(r) => self.after_open(Some(path), &r), + // A password-protected workbook: ask for the password; the dialog opens it. + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + + /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent + /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the + /// password dialog and the control channel. + pub fn after_open(&mut self, path: Option<&str>, r: &Json) { + self.backstage = None; + if let Some(path) = path { + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(20); + } + self.show_open_warnings(r); + } + + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + // The opened workbook's window shows them, however it was opened (a script may have run + // `file.open` on the session directly). + self.follow_active_window(); + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. + let dropped = vba.iter().any(|m| m.contains("dropped")); + let text = if dropped { + "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." + } else { + "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." + }; + self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + + /// The open windows in order, for the Switch Windows lists. + pub fn open_windows(&self) -> Vec { + let active = self.session.active_window(); + self.session + .windows() + .iter() + .filter_map(|w| { + let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; + let title = self.session.window_title(w.id)?; + Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) + }) + .collect() + } + + /// Opens files that arrived asynchronously (web file picker, dropped files). + pub(super) fn poll_async_opens(&mut self) { + let arrived: Vec<(String, Vec)> = self + .services + .inbox + .as_ref() + .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) + .unwrap_or_default(); + for (name, bytes) in arrived { + let b64 = gridcraft_engine::io::base64_encode(&bytes); + match self.run("file.open", json!({"name": &name, "base64": &b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(r) => { + self.backstage = None; + self.show_open_warnings(&r); + } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.backstage = None; + self.open_dialog("openPassword", json!({"name": name, "base64": b64})); + } + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + } + + /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in + /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a + /// forced hidden UI pass from saving the same change twice. + pub(super) fn autosave(&mut self, ctx: &egui::Context) { + let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + let now = crate::monotonic_ms(); + let autosave_due = + self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; + if autosave_due { + self.grid.last_autosave = now; + self.store_view_state(); + let _ = self.session.run("file.save", json!({})); + } else if self.ui.autosave && dirty_xlsx { + // Keep checking even when no input arrives while the window is hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } + } + + /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI + /// preferences, so it stays as the user left it across restarts (#1901). + pub fn toggle_autosave(&mut self) { + self.ui.autosave = !self.ui.autosave; + } +} diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs new file mode 100644 index 000000000..1f99a9902 --- /dev/null +++ b/crates/ui-egui/src/app/fonts.rs @@ -0,0 +1,112 @@ +use super::{SheetApp, theme}; +use crate::fonts; + +impl SheetApp { + /// Test-harness setup: mark a deterministic font set (see + /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither + /// starts the host font scan nor replaces the harness's pinned set. Production uses + /// [`Self::setup_context`] instead. + #[doc(hidden)] + pub fn use_prepared_fonts(&mut self) { + self.prepared_fonts = true; + self.fonts_ready = true; + self.fonts_set = true; + self.fonts_rebind = false; + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + self.fonts_bound = self.used_fonts(); + self.fonts_seen = self.fonts_key(); + } + + /// The font names the open workbooks use: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as + /// each may be showing in a window of its own. + pub fn used_fonts(&self) -> Vec { + let mut names: Vec = Vec::new(); + for d in self.session.documents() { + names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); + names.push(d.wb.theme.major_font.clone()); + names.push(d.wb.theme.minor_font.clone()); + // Text boxes and shapes name their own typefaces. + names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); + } + names + } + + /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. + fn fonts_key(&self) -> (u64, u64) { + self.session + .documents() + .iter() + .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) + } + + /// Installs the text-width closure once, on the first frame. + pub(super) fn ensure_text_width(&mut self, ctx: &egui::Context) { + if self.session.text_width.is_some() { + return; + } + let ctx = ctx.clone(); + self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); + } + + /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook + /// font binding. Every branch keeps its one-frame paint gate. + pub(super) fn apply_font_state(&mut self, ctx: &egui::Context) { + // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate + // before it handles input, so a font change requested now would consume the step without + // applying it. Delay the change one frame; the hidden-paint request keeps it coming. + let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); + if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { + self.fonts_ready = false; + self.fonts_set = false; + } + if !self.fonts_ready { + self.start_font_setup(ctx); + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { + self.rebind_workbook_fonts(ctx); + } + } + + /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until + /// then. Startup always proceeds: without it a queued step would be re-offered forever. + fn start_font_setup(&mut self, ctx: &egui::Context) { + if self.fonts_set { + self.fonts_ready = true; + return; + } + if self.prepared_fonts { + ctx.set_fonts(theme::pinned_font_definitions()); + theme::apply(ctx, self.ui.dark); + } else { + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + } + self.fonts_set = true; + // The rebuilt set has no workbook fonts yet: bind them again once it is active. + self.fonts_bound.clear(); + self.fonts_seen = (0, 0); + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + ctx.request_repaint(); + } + + /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts + /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint + /// (same one-frame cost as startup). Only when an open workbook changed. + fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { + let seen = self.fonts_key(); + if seen == self.fonts_seen { + return; + } + let used = self.used_fonts(); + self.fonts_seen = seen; + if used == self.fonts_bound { + return; + } + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 000000000..b9fa30a18 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,33 @@ +use super::{SheetApp, control}; + +impl SheetApp { + /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Capture geometry from this frame's real input before any synthetic event is added. This + // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, + // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded + // even though egui's last UI pass still describes the old layout. + let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); + control::note_capture_geometry(self, geometry); + self.capture_geometry_from_raw = true; + // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the + // next pass and for a hidden logic-only tick, so the batch is offered once and survives + // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. + // + // A completed egui pass may have consumed the injected events without applying them: the + // font gate returns before input handling, but `begin_pass` has already taken the events. + // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch + // still pending means exactly that, and the step is offered again for the next real pass. + if self.synthetic_pending { + let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); + if !consumed_by_an_unapplied_pass { + return; + } + } + if let Some(batch) = self.synthetic.front() { + raw.events.extend(batch.iter().cloned()); + self.synthetic_pending = true; + self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); + } + } +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs new file mode 100644 index 000000000..3490bd01c --- /dev/null +++ b/crates/ui-egui/src/app/keys.rs @@ -0,0 +1,66 @@ +use super::{Json, SheetApp, backstage, json, keytips}; + +impl SheetApp { + pub(super) fn ribbon_keys(&mut self, ctx: &egui::Context) { + let before = self.keytips.prefix().map(str::to_string); + let enabled = self.editor.is_none() + && self.dialog.is_none() + && self.message.is_none() + && self.name_box.is_none() + && !ctx.text_edit_focused() + && self.grid.context_menu.is_none() + && self.grid.object_menu.is_none() + && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() + && self.grid.filter_menu.is_none() + && self.grid.list_picker.is_none() + && self.grid.renaming_tab.is_none() + && (before.is_some() || !egui::Popup::is_any_open(ctx)); + let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } + if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { + // Leave pointer cancellation to the popup so clicking a menu item still works. + egui::Popup::close_all(ctx); + } + for action in actions { + match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), + keytips::Action::Tab(tab) => { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + } + keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), + keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), + keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } + keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), + // Reserved sequence: fires silently while its command is missing. When the + // command lands in the catalog it goes live with no table edit. + keytips::Action::Pending(id) => { + if gridcraft_engine::find_command(id).is_some() { + self.run_or_alert(id, json!({})); + } + } + // Route to the ribbon control that owns this keytip: it opens its dropdown or + // runs its click handler with the right params (the engine command alone would + // be missing them). + keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + } + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs new file mode 100644 index 000000000..216e4a568 --- /dev/null +++ b/crates/ui-egui/src/app/prefs.rs @@ -0,0 +1,67 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{SheetApp, desktop_theme}; + +impl SheetApp { + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + + /// Follows the desktop/system theme preference and records the effective theme. + pub(super) fn sync_theme_preference(&mut self, ctx: &egui::Context) { + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); + } + self.effective_dark = ctx.theme() == egui::Theme::Dark; + } + + /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. + pub(super) fn sync_input_options(&mut self, ctx: &egui::Context) { + // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom + // keys stay off (they would also swallow GridCraft's shortcuts). + if ctx.options(|o| o.zoom_with_keyboard) { + ctx.options_mut(|o| o.zoom_with_keyboard = false); + } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native + // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu + // bar's Quit item has the key instead (it asks about unsaved workbooks first). + if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } + let zoom = self.ui.zoom_factor(); + if (ctx.zoom_factor() - zoom).abs() > 0.001 { + ctx.set_zoom_factor(zoom); + ctx.request_repaint(); + } + } + + /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the + /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. + pub fn start_update_check(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, false); + } + + /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. + pub fn check_for_updates(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, true); + #[cfg(target_arch = "wasm32")] + { + self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); + } + } +} diff --git a/crates/ui-egui/src/app/print.rs b/crates/ui-egui/src/app/print.rs new file mode 100644 index 000000000..0cf678505 --- /dev/null +++ b/crates/ui-egui/src/app/print.rs @@ -0,0 +1,89 @@ +use super::{Json, SheetApp, json, now_ms, printing}; + +impl SheetApp { + /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. + pub(super) fn deliver_pdf(&self, result: &Json) { + if result.get("path").is_some() { + return; + } + if let Some(dl) = &self.services.download + && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); + } + } + + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + pub(super) fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + + /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the + /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible + /// on the Print page. The engine command still returns PDF bytes without submitting. + pub fn print_now(&mut self, params: Json) { + if let Err(error) = self.submit_print(params) { + self.alert(error); + } + } + + pub(super) fn submit_print(&mut self, params: Json) -> Result { + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let pdf = self.run("file.print", params)?; + let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; + let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + } else if let Some(print) = &self.services.print { + print(&bytes); + } else { + return Err("Printing is unavailable here; export a PDF instead.".into()); + } + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) + } + + /// Terminal feedback also reaches the normal window when printing starts through control. + /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in + /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); + if matches!(message, printing::Feedback::Submitted(_)) { + self.toast = Some((text, now_ms())); + } else { + self.alert(text); + } + } +} diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs new file mode 100644 index 000000000..d4479d191 --- /dev/null +++ b/crates/ui-egui/src/app/quit.rs @@ -0,0 +1,103 @@ +use super::{SaveAnswer, SheetApp, json}; + +impl SheetApp { + /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a + /// workbook has unsaved changes, and asks about them first. + pub(super) fn guard_window_close(&mut self, ctx: &egui::Context) { + if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // A cell still being edited is part of the workbook, as for File › Close. + if !self.commit_edit(0, 0, false, false) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } + // The macro step editor asks about its own unsaved steps and debug run first; once it has + // closed, the window close goes on. + if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + crate::macro_editor::request_close(self, true); + return; + } + if !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + match self.dialog.as_mut() { + // Already asking about a workbook (File › Close): answering it now goes on to quit. + Some(d) if d.name() == "saveChanges" => { + d.values.insert("quit".into(), json!(true)); + } + _ => self.continue_quit(ctx), + } + } + + /// Asks about the next unsaved workbook before quitting, in its window, or closes the app + /// once none is left. + pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { + let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); + match dirty.and_then(|uid| self.session.doc_window(uid)) { + Some(window) => { + // Its window comes forward (the native window too: see `doc_windows`). + 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})); + } + None => { + self.allow_close = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); + } + } + } + + /// Answers the Save Changes question about the active workbook (its buttons). While quitting, + /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. + /// Returns whether the question is settled: Save keeps it open when the cell being edited + /// can't be entered. + pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { + match answer { + SaveAnswer::Cancel => {} + SaveAnswer::DontSave => { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + SaveAnswer::Save => { + // File operations include the cell still being edited, as File › Save does. + if !self.commit_edit(0, 0, false, false) { + return false; + } + self.run_or_alert("file.save", json!({})); + if self.session.active().is_some_and(|x| !x.is_dirty()) { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + } + } + true + } + + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks + /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// workbook refuses stops the quit too, with its window in front and the reason shown. + pub fn request_quit(&mut self, ctx: &egui::Context) { + while let Some(window) = self.parked_window_with_draft() { + self.enter_window(window); + if self.settle_drafts().is_err() { + return; + } + } + if self.settle_drafts().is_err() { + return; + } + if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { + // Already asking about a workbook (File › Close): answering it goes on to quit. + d.values.insert("quit".into(), json!(true)); + return; + } + self.continue_quit(ctx); + } +} diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs new file mode 100644 index 000000000..aea36abc2 --- /dev/null +++ b/crates/ui-egui/src/app/view.rs @@ -0,0 +1,56 @@ +use super::{CellRef, SheetApp, SheetView}; + +impl SheetApp { + /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. + pub fn view_key(&self) -> Option<(u64, usize)> { + self.session.active().map(|d| (d.uid, d.wb.active_sheet())) + } + + /// The active window and sheet: each window scrolls on its own. + fn scroll_key(&self) -> Option<(u64, usize)> { + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) + } + + pub fn view(&self) -> SheetView { + self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) + } + + pub fn view_mut(&mut self) -> Option<&mut SheetView> { + let k = self.scroll_key()?; + let saved = self.saved_view(); + Some(self.views.entry(k).or_insert(saved)) + } + + /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). + fn saved_view(&self) -> SheetView { + let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; + if sh.view != gridcraft_engine::model::SheetViewMode::Normal { + return SheetView::default(); + } + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let top = sh.view_top_left; + let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); + let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); + SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } + } + + /// Hands the active document's scroll positions (first visible cell per sheet) and active + /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: + /// Page Layout view scrolls over pages, not cells. + pub fn store_view_state(&mut self) { + let window = self.session.active_window(); + let Some(d) = self.session.active_mut() else { return }; + let mut tops = Vec::new(); + for (&(w, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + continue; + }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + // A hair past the edge so a row that starts exactly at the scroll offset is the one found. + let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); + let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); + tops.push((si, CellRef::new(row, col))); + } + d.store_view(&tops); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ec097c201..6310dd6a0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -20,6 +20,12 @@ use crate::i18n::{self, Language}; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +mod options; +mod print; + +use options::{options, platform}; +use print::print; + /// Width of the navigation column. const NAV_W: f32 = 196.0; /// Width of the content column (pages are left-aligned like a document, not stretched). @@ -671,7 +677,7 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label); ui.horizontal(|ui| { let label = ui.label(lang.tr("Save as type")); - egui::ComboBox::from_id_salt("backstage_save_type") + craft_ui::ComboBox::from_id_salt("backstage_save_type") .width(280.0) .selected_text(lang.tr(current)) .show_ui(ui, |ui| { @@ -704,172 +710,6 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file.")); } -/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action. -fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - let lang = app.ui.language; - let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { - dim(ui, lang.tr("No workbook is open.")); - return; - }; - let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); - if bs.fit_to_page { - params["fitToPage"] = json!(true); - } - let key = (uid, revision, bs.print_all, bs.fit_to_page); - if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { - let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); - bs.preview = Some((key, preview)); - } - let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - if let Some(discover) = &app.services.printers { - crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); - } - let can_print = app.services.print_native.is_none() || app.printing.ready(); - if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() - && app.commit_edit(0, 0, false, false) - { - app.print_now(params.clone()); - out.close = app.services.print_native.is_none(); - } - ui.add_space(16.0); - let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; - egui::ComboBox::from_id_salt("backstage_print_scope") - .width(230.0) - .selected_text(lang.tr(scope)) - .show_ui(ui, |ui| { - for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { - if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { - bs.print_all = all; - bs.preview_page = 0; - } - } - }) - .response - .accessible_name(lang.tr("Print What")); - ui.add_space(8.0); - if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { - bs.preview_page = 0; - } - ui.add_space(8.0); - if ui.link(lang.tr("Page Setup…")).clicked() { - app.open_dialog("pageSetup", json!({})); - } - }); - ui.add_space(24.0); - ui.vertical(|ui| { - if pages.is_empty() { - dim(ui, lang.tr("Nothing to print.")); - return; - } - let last = pages.len().saturating_sub(1); - bs.preview_page = bs.preview_page.min(last); - let Some(page) = pages.get(bs.preview_page) else { return }; - // Paper proportions from the preview (points); hostile sizes fall back to Letter. - let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; - let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; - let scale = (520.0 / h).min(440.0 / w); - let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); - let p = ui.painter(); - p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); - p.rect_filled(rect, 2.0, t.grid_bg); - p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); - if let Some(d) = app.session.active() { - paint_page(p, rect, w, page, &d.wb, &t); - } - let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); - let range = page.get("range").and_then(Json::as_str).unwrap_or(""); - ui.add_space(4.0); - dim(ui, &format!("{sheet} · {range}")); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { - bs.preview_page = bs.preview_page.saturating_sub(1); - } - let text = lang - .tr("Page {page} of {pages}") - .replace("{page}", &(bs.preview_page + 1).to_string()) - .replace("{pages}", &pages.len().to_string()); - ui.label(text); - if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { - bs.preview_page = (bs.preview_page + 1).min(last); - } - }); - }); - }); -} - -/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). -const PREVIEW_ROWS: u32 = 200; -const PREVIEW_COLS: u32 = 60; - -/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, -/// inside the sheet's margins. `paper_w` is the page width in points. -fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { - let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; - let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; - // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). - let per_pt = rect.width() / paper_w; - let print_scale = - page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); - let per_px = per_pt * 0.75 * print_scale; - let margin = - |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; - let body = Rect::from_min_max( - pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), - pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), - ); - if !body.is_positive() { - return; - } - let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); - let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); - let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); - let mut y = body.top(); - for row in range.start.row..=last_row { - let h = sh.row_height(row) * per_px; - if h <= 0.0 { - continue; - } - if y >= body.bottom() { - break; - } - let mut x = body.left(); - for col in range.start.col..=last_col { - let w = sh.col_width(col) * per_px; - if w <= 0.0 { - continue; - } - if x >= body.right() { - break; - } - let cell = gridcraft_engine::core::CellRef::new(row, col); - let text = gridcraft_engine::display::cell_text(wb, sh, cell); - if !text.is_empty() { - let numeric = sh.value(cell).as_f64().is_some(); - // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. - let mut span = w; - if !numeric { - let mut next = col.saturating_add(1); - while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { - span += sh.col_width(next) * per_px; - next = next.saturating_add(1); - } - } - let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); - let (anchor, at) = - if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; - p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); - } - x += w; - } - y += h; - } -} - fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { let lang = app.ui.language; if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked() @@ -949,168 +789,6 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { } } -/// Excel's File › Options page: General (display theme, interface language and Show formula bar), -/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas -/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula -/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation -/// settings are saved with the workbook. -fn options(app: &mut SheetApp, ui: &mut Ui) { - let lang = app.ui.language; - section(ui, lang, "Options|General"); - egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { - let label = ui.label(lang.tr("Display theme")); - let mode = app.ui.theme_mode(); - let current = match mode { - "system" => "System", - "dark" => "Dark", - _ => "Light", - }; - egui::ComboBox::from_id_salt("backstage_theme") - .width(160.0) - .selected_text(lang.tr(current)) - .show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - app.run_or_alert("view.theme", json!({"mode": value})); - } - } - }) - .response - .labelled_by(label.id); - ui.end_row(); - let label = ui.label(lang.tr("Interface language")); - egui::ComboBox::from_id_salt("backstage_language") - .width(160.0) - .selected_text(lang.name()) - .show_ui(ui, |ui| { - for l in i18n::Language::for_picker() { - if ui.selectable_label(l == lang, l.name()).clicked() { - app.run_or_alert("app.language.set", json!({"language": l.code()})); - } - } - }) - .response - .labelled_by(label.id); - ui.end_row(); - }); - ui.add_space(6.0); - let mut formula_bar = app.session.prefs.formula_bar; - if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { - app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Editing"); - let mut autocomplete = app.ui.autocomplete; - if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { - app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Save"); - let mut autosave = app.ui.autosave; - if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { - app.toggle_autosave(); - } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; - ui.add_space(18.0); - section(ui, lang, "Formulas"); - ui.label(lang.tr("Workbook calculation")); - use gridcraft_engine::model::CalcMode; - for (mode, id, label) in [ - (CalcMode::Automatic, "automatic", "Automatic"), - (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), - (CalcMode::Manual, "manual", "Manual"), - ] { - if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { - app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); - } - } - ui.add_space(4.0); - let mut iterative = calc.iterative; - if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { - app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); - } - // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. - ui.add_enabled_ui(iterative, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - let label = ui.label(lang.tr("Maximum Iterations:")); - let id = ui.make_persistent_id("backstage_max_iterations"); - // Text left in the field when iteration is switched off (it loses focus) is dropped. - let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)); - if let Some(response) = ui.ctx().read_response(id) { - response.labelled_by(label.id); - } - if let Some(text) = committed.filter(|_| iterative) { - match parse_max_iterations(&text) { - Some(n) if n != calc.max_iterations => { - app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); - } - Some(_) => {} - None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."), - } - } - ui.end_row(); - let label = ui.label(lang.tr("Maximum Change:")); - let id = ui.make_persistent_id("backstage_max_change"); - let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)); - if let Some(response) = ui.ctx().read_response(id) { - response.labelled_by(label.id); - } - if let Some(text) = committed.filter(|_| iterative) { - match parse_max_change(&text, lang.formula_locale()) { - Some(d) if d != calc.max_change => { - app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); - } - Some(_) => {} - None => refuse(app, ui, "Maximum Change must be a number of 0 or more."), - } - } - ui.end_row(); - }); - }); - }); - // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes - // `precisionAsDisplayed` (PR #1366); the model already stores it. - ui.add_space(6.0); - dim(ui, lang.tr("Calculation settings are saved with the workbook.")); -} - -/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that -/// committed the field is consumed so it doesn't also dismiss the message box in this frame. -fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) { - ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); - app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into())); -} - -/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others). -fn parse_max_iterations(text: &str) -> Option { - let n = text.trim().parse::().ok()?; - (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n) -} - -/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal -/// separator is accepted as well as a dot. -fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option { - let d = text.trim().replace(locale.decimal_separator(), ".").parse::().ok()?; - (d.is_finite() && d >= 0.0).then_some(d) -} - -/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". -fn platform() -> String { - let os = match std::env::consts::OS { - "macos" => "macOS", - "windows" => "Windows", - "linux" => "Linux", - "freebsd" => "FreeBSD", - "openbsd" => "OpenBSD", - "netbsd" => "NetBSD", - _ if cfg!(target_arch = "wasm32") => return "Web".into(), - other => other, - }; - format!("{os} ({})", std::env::consts::ARCH) -} - /// Excel's File › Account: product information and the About window. fn account(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs new file mode 100644 index 000000000..6b9f70eb7 --- /dev/null +++ b/crates/ui-egui/src/backstage/options.rs @@ -0,0 +1,178 @@ +use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, vec2}; + +/// Excel's File › Options page: General (display theme, interface language and Show formula bar), +/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas +/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation +/// settings are saved with the workbook. +pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { + let lang = app.ui.language; + section(ui, lang, "Options|General"); + general_options(app, ui, lang); + ui.add_space(6.0); + let mut formula_bar = app.session.prefs.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Editing"); + let mut autocomplete = app.ui.autocomplete; + if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { + app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.ui.autosave; + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + calc_options(app, ui, lang, &calc); + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +/// The display theme and interface language controls of the General section. +fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) { + egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { + let label = ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let current = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + craft_ui::ComboBox::from_id_salt("backstage_theme") + .width(160.0) + .selected_text(lang.tr(current)) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + let label = ui.label(lang.tr("Interface language")); + craft_ui::ComboBox::from_id_salt("backstage_language") + .width(160.0) + .selected_text(lang.name()) + .show_ui(ui, |ui| { + for l in i18n::Language::for_picker() { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + }); +} + +/// The calculation mode, and iterative calculation, of the active workbook. +fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) { + use gridcraft_engine::model::CalcMode; + for (mode, id, label) in [ + (CalcMode::Automatic, "automatic", "Automatic"), + (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), + (CalcMode::Manual, "manual", "Manual"), + ] { + if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { + app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); + } + } + ui.add_space(4.0); + let mut iterative = calc.iterative; + if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { + app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); + } + // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. + iteration_options(app, ui, lang, calc, iterative); + // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes + // `precisionAsDisplayed` (PR #1366); the model already stores it. +} + +/// Maximum Iterations and Maximum Change, live only while iterative calculation is on. +fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) { + ui.add_enabled_ui(iterative, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + let label = ui.label(lang.tr("Maximum Iterations:")); + let id = ui.make_persistent_id("backstage_max_iterations"); + // Text left in the field when iteration is switched off (it loses focus) is dropped. + let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { + match parse_max_iterations(&text) { + Some(n) if n != calc.max_iterations => { + app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."), + } + } + ui.end_row(); + let label = ui.label(lang.tr("Maximum Change:")); + let id = ui.make_persistent_id("backstage_max_change"); + let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { + match parse_max_change(&text, lang.formula_locale()) { + Some(d) if d != calc.max_change => { + app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Change must be a number of 0 or more."), + } + } + ui.end_row(); + }); + }); + }); +} + +/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that +/// committed the field is consumed so it doesn't also dismiss the message box in this frame. +fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) { + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); + app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into())); +} + +/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others). +fn parse_max_iterations(text: &str) -> Option { + let n = text.trim().parse::().ok()?; + (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n) +} + +/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal +/// separator is accepted as well as a dot. +fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option { + let d = text.trim().replace(locale.decimal_separator(), ".").parse::().ok()?; + (d.is_finite() && d >= 0.0).then_some(d) +} + +/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". +pub(super) fn platform() -> String { + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + _ if cfg!(target_arch = "wasm32") => return "Web".into(), + other => other, + }; + format!("{os} ({})", std::env::consts::ARCH) +} diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs new file mode 100644 index 000000000..bcfe0417d --- /dev/null +++ b/crates/ui-egui/src/backstage/print.rs @@ -0,0 +1,170 @@ +use super::{ + Align2, Backstage, Icon, Json, Outcome, Rect, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, dim, json, pos2, square_button, theme, vec2, +}; + +/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action. +pub(super) fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); + if bs.fit_to_page { + params["fitToPage"] = json!(true); + } + let key = (uid, revision, bs.print_all, bs.fit_to_page); + if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { + let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); + bs.preview = Some((key, preview)); + } + let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); + } + let can_print = app.services.print_native.is_none() || app.printing.ready(); + if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() + && app.commit_edit(0, 0, false, false) + { + app.print_now(params.clone()); + out.close = app.services.print_native.is_none(); + } + ui.add_space(16.0); + let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; + craft_ui::ComboBox::from_id_salt("backstage_print_scope") + .width(230.0) + .selected_text(lang.tr(scope)) + .show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } + } + }) + .response + .accessible_name(lang.tr("Print What")); + ui.add_space(8.0); + if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { + bs.preview_page = 0; + } + ui.add_space(8.0); + if ui.link(lang.tr("Page Setup…")).clicked() { + app.open_dialog("pageSetup", json!({})); + } + }); + ui.add_space(24.0); + ui.vertical(|ui| { + if pages.is_empty() { + dim(ui, lang.tr("Nothing to print.")); + return; + } + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(page) = pages.get(bs.preview_page) else { return }; + // Paper proportions from the preview (points); hostile sizes fall back to Letter. + let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; + let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; + let scale = (520.0 / h).min(440.0 / w); + let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); + p.rect_filled(rect, 2.0, t.grid_bg); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + if let Some(d) = app.session.active() { + paint_page(p, rect, w, page, &d.wb, &t); + } + let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); + let range = page.get("range").and_then(Json::as_str).unwrap_or(""); + ui.add_space(4.0); + dim(ui, &format!("{sheet} · {range}")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + let text = lang + .tr("Page {page} of {pages}") + .replace("{page}", &(bs.preview_page + 1).to_string()) + .replace("{pages}", &pages.len().to_string()); + ui.label(text); + if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); + }); + }); +} + +/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). +const PREVIEW_ROWS: u32 = 200; + +const PREVIEW_COLS: u32 = 60; + +/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, +/// inside the sheet's margins. `paper_w` is the page width in points. +fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { + let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; + let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; + // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). + let per_pt = rect.width() / paper_w; + let print_scale = + page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); + let per_px = per_pt * 0.75 * print_scale; + let margin = + |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; + let body = Rect::from_min_max( + pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), + pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), + ); + if !body.is_positive() { + return; + } + let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); + let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); + let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); + let mut y = body.top(); + for row in range.start.row..=last_row { + let h = sh.row_height(row) * per_px; + if h <= 0.0 { + continue; + } + if y >= body.bottom() { + break; + } + let mut x = body.left(); + for col in range.start.col..=last_col { + let w = sh.col_width(col) * per_px; + if w <= 0.0 { + continue; + } + if x >= body.right() { + break; + } + let cell = gridcraft_engine::core::CellRef::new(row, col); + let text = gridcraft_engine::display::cell_text(wb, sh, cell); + if !text.is_empty() { + let numeric = sh.value(cell).as_f64().is_some(); + // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. + let mut span = w; + if !numeric { + let mut next = col.saturating_add(1); + while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { + span += sh.col_width(next) * per_px; + next = next.saturating_add(1); + } + } + let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); + let (anchor, at) = + if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; + p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); + } + x += w; + } + y += h; + } +} diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index fb3dbd8dd..8adab4918 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -264,6 +264,23 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if objects_hidden(wb) { return painted; } + // Source outlines stay in cell space beneath every floating object. + if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id)) + && anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect()) + { + for s in &ch.series { + if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values) + && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) + { + let rr = geo.range_rect(sh, rf.range()); + let color = Tokens::get(p.ctx()).ref_color(0, true); + for pane in geo.range_panes(rf.range()) { + let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside); + } + } + } + } let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect }); // The grid paints the active workbook: its pictures are cached under it. let doc = app.session.active().map_or(0, |d| d.uid); @@ -365,23 +382,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if app.selected_chart == Some(ch.id) { selection_frame(p, r); } - // The source-data highlight below is in cell space: mirrored with the cells. done(start, r); - if app.selected_chart == Some(ch.id) { - // Highlight the source data like Excel does. - for s in &ch.series { - if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values) - && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) - { - let rr = geo.range_rect(sh, rf.range()); - let color = Tokens::get(p.ctx()).ref_color(0, true); - for pane in geo.range_panes(rf.range()) { - let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); - pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside); - } - } - } - } } paint_slicers(app, p, geo, wb, si, sh); painted diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 000000000..ca1b57783 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,25 @@ +/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last +/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive +/// deadlines or pacing; use [`monotonic_ms`] for those. +pub fn now_ms() -> f64 { + gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) +} + +/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. +/// +/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a +/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the +/// browser's `performance.now()`, which is monotonic too. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn monotonic_ms() -> f64 { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +#[cfg(target_arch = "wasm32")] +/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. +pub fn monotonic_ms() -> f64 { + web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) +} diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 6f02b4044..ee65b7ec8 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range, Token)> { } } +/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`. +fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize { + let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); + let mut j = if triple { i + 3 } else { i + 1 }; + loop { + match chars.get(j) { + None => break, + Some((_, '\\')) => j += 2, + Some((_, '\n')) if !triple => break, + Some((_, d)) if *d == c => { + if !triple { + j += 1; + break; + } + if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { + j += 3; + break; + } + j += 1; + } + Some(_) => j += 1, + } + } + j.min(chars.len()) +} + fn python(text: &str) -> Vec<(Range, Token)> { let chars: Vec<(usize, char)> = text.char_indices().collect(); let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); @@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range, Token)> { out.push((start..end_of(j), Token::Comment)); i = j; } else if c == '"' || c == '\'' { - let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); - let mut j = if triple { i + 3 } else { i + 1 }; - loop { - match chars.get(j) { - None => break, - Some((_, '\\')) => j += 2, - Some((_, '\n')) if !triple => break, - Some((_, d)) if *d == c => { - if !triple { - j += 1; - break; - } - if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { - j += 3; - break; - } - j += 1; - } - Some(_) => j += 1, - } - } - let j = j.min(chars.len()); + let j = python_string_end(&chars, i, c); out.push((start..end_of(j), Token::String)); i = j; } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) { @@ -565,377 +570,4 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max } #[cfg(test)] -mod tests { - use super::*; - use egui::Modifiers; - - fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { - highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() - } - - #[test] - fn python_tokens() { - let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); - assert_eq!( - s, - vec![ - ("def", Token::Keyword), - ("return", Token::Keyword), - ("len", Token::Builtin), - ("1.5", Token::Number), - ("# done", Token::Comment), - ("xl", Token::Builtin), - ("\"A1:B2\"", Token::String), - ] - ); - assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); - assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); - assert_eq!(spans("hello", Lang::Plain), vec![]); - } - - #[test] - fn latex_tokens() { - let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); - assert_eq!( - s, - vec![ - ("\\frac", Token::Keyword), - ("^", Token::Builtin), - ("2", Token::Number), - ("3", Token::Number), - ("% half", Token::Comment), - ("\\{", Token::Keyword), - ] - ); - } - - #[test] - fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { - let inputs = [ - "", - "'", - "\"", - "'''", - "\"\"\"x", - "#", - "\\", - "%", - "1", - ".", - "é'ü\\", - "θ = 'π' # ∑", - "\u{1F600}'\u{1F600}", - "'\\", - "\\\u{1F600}", - "\u{200B}\n\t'''\n", - "0x_é", - "a.b.c", - "____", - ]; - for text in inputs { - for lang in [Lang::Python, Lang::Latex, Lang::Plain] { - let mut last = 0; - for (r, _) in highlight(text, lang) { - assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); - assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); - last = r.end; - } - let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); - assert_eq!(job.text, text, "every character is laid out once"); - } - } - } - - #[test] - fn enter_keeps_indentation_and_indents_after_a_colon() { - let mut s = " x = 1".to_string(); - let e = newline(&mut s, (9, 9), Lang::Python); - assert_eq!(s, " x = 1\n "); - assert_eq!(e.caret, 14); - let mut s = "if x:".to_string(); - let e = newline(&mut s, (5, 5), Lang::Python); - assert_eq!(s, "if x:\n "); - assert_eq!(e.caret, 10); - // Plain text keeps the indentation but adds none after a colon. - let mut s = " a:".to_string(); - newline(&mut s, (4, 4), Lang::Plain); - assert_eq!(s, " a:\n "); - // A selection is replaced; out-of-range carets are clamped. - let mut s = "abcdef".to_string(); - let e = newline(&mut s, (4, 2), Lang::Plain); - assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); - let mut s = "é".to_string(); - let e = newline(&mut s, (99, 99), Lang::Plain); - assert_eq!((s.as_str(), e.caret), ("é\n", 2)); - } - - #[test] - /// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. - fn shift_tab_in_the_editor_dedents_rather_than_indents() { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, text: &mut String| { - if ui.ctx().cumulative_pass_nr() == 0 { - ui.ctx().set_fonts(theme::pinned_font_definitions()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, text, Lang::Python, 5); - }, - " x".to_string(), - ); - h.run_steps(2); - let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; - h.input_mut().events.push(tab(Modifiers::SHIFT)); - h.run_steps(1); - assert_eq!(h.state(), " x"); - // A plain Tab still inserts spaces at the caret (the end). - h.input_mut().events.push(tab(Modifiers::NONE)); - h.run_steps(1); - assert_eq!(h.state(), " x "); - } - - /// Gives the context the deterministic font set, including the editor's monospace family. - fn install_mono(ctx: &egui::Context) { - ctx.set_fonts(theme::pinned_font_definitions()); - } - - /// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. - fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, text: &mut String| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, text, Lang::Python, 5); - }, - text.to_string(), - ); - h.run_steps(2); - h.check_accessibility(); - h - } - - fn key(key: Key, modifiers: Modifiers) -> egui::Event { - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } - } - - #[test] - fn batched_input_applies_in_order_and_keeps_every_key() { - // Events gather between repaints (typing, key repeat). The text typed before an Enter - // comes first, and two Enters make two lines. - let mut h = editor_harness(""); - h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n "); - // Backspace and Tab in the same frame as the typing around them. - h.input_mut().events.extend([ - egui::Event::Text("y = 1".into()), - key(Key::Backspace, Modifiers::NONE), - egui::Event::Text("2".into()), - key(Key::Tab, Modifiers::NONE), - ]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 "); - // An event the field acts on (an arrow) keeps the order too: the text typed before it - // is applied first, the field moves the caret, and the Enter after it waits for the - // next pass, where the editor applies it at the moved caret, with the indentation. - h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); - } - - #[test] - fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { - // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. - let mut s = "a\nb".to_string(); - indent(&mut s, (0, 2), false); - assert_eq!(s, " a\nb"); - let mut s = " a\n b".to_string(); - indent(&mut s, (0, 6), true); - assert_eq!(s, "a\n b"); - // One character into the second line, both lines are touched. - let mut s = "a\nb".to_string(); - indent(&mut s, (0, 3), false); - assert_eq!(s, " a\n b"); - } - - #[test] - fn typed_text_and_deletions_replace_the_selection() { - let mut s = "abcd".to_string(); - assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); - assert_eq!(s, "aXd"); - assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "ad"); - assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "a"); - assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); - assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "a"); - let mut s = "éa".to_string(); - delete(&mut s, (2, 0), true); - assert_eq!(s, ""); - // The queue keeps what the editor does not handle, and everything after it. - let mut events = vec![ - egui::Event::Text("x".into()), - egui::Event::Text("\n".into()), - key(Key::ArrowLeft, Modifiers::NONE), - key(Key::Enter, Modifiers::NONE), - ]; - let (mut s, mut sel) = (String::new(), (0, 0)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert!(out.edited); - assert_eq!((s.as_str(), sel), ("x", (1, 1))); - assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); - assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); - let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); - assert_eq!(events.len(), 1); - } - - #[test] - fn select_all_then_tab_in_one_pass_indents_every_line() { - // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the - // selection and indents the selected lines, rather than reaching the field, which would - // replace the selection with a tab character. - let mut h = editor_harness("a\nb"); - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); - h.run_steps(1); - assert_eq!(h.state(), " a\n b"); - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); - h.run_steps(2); - assert_eq!(h.state(), "a\nb"); - } - - #[test] - fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { - // egui-winit sends a Key event before each Text event. Those presses are nobody's to - // read, so they do not hand the Enter to the field, which knows no indentation. - let mut h = editor_harness(""); - let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; - let mut events = Vec::new(); - events.extend(typed(Key::I, "i", Modifiers::NONE)); - events.extend(typed(Key::F, "f", Modifiers::NONE)); - events.extend(typed(Key::Space, " ", Modifiers::NONE)); - events.extend(typed(Key::X, "x", Modifiers::NONE)); - events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); - events.push(key(Key::Enter, Modifiers::NONE)); - h.input_mut().events.extend(events); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n "); - // A character typed with Option (macOS) comes as a key under that modifier and its text; - // the field reads neither the key nor Option, so the Enter after it is still the editor's. - h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \u{e9}\n "); - } - - #[test] - fn tab_and_enter_under_modifiers_never_reach_the_field() { - // The field would make a tab character of Ctrl+Tab (in place of the selection) and a - // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. - let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; - let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert_eq!(s, "if x:\n "); - assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); - assert!(out.held.is_empty()); - } - - #[test] - fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { - let mut events = vec![ - key(Key::X, Modifiers::NONE), - egui::Event::Text("x".into()), - key(Key::A, Modifiers::COMMAND), - key(Key::Tab, Modifiers::NONE), - egui::Event::PointerMoved(egui::Pos2::ZERO), - key(Key::ArrowLeft, Modifiers::NONE), - egui::Event::Text("y".into()), - ]; - let (mut s, mut sel) = (String::new(), (0, 0)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); - assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); - assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); - } - - #[test] - /// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. - fn held_back_events_land_in_the_next_pass_only() { - let mut h = editor_harness(""); - let id = egui::Id::unique("code"); - let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; - // What the pass just completed held back comes in with the next one. - let last = h.ctx.cumulative_pass_nr() - 1; - h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); - h.run_steps(1); - assert_eq!(h.state(), "a"); - // Anything older is dropped rather than typed out of context, and cleared. - h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); - h.run_steps(1); - assert_eq!(h.state(), "a"); - assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); - } - - #[test] - /// Held keyboard input does not land in the next focused text field once the code editor has lost focus. - fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { - // Typed into the editor, they must not land in whatever has the focus next. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, s: &mut (String, String, bool)| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); - ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); - code_editor(ui, code, &mut s.0, Lang::Python, 5); - ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); - }, - (String::new(), String::new(), true), - ); - h.run_steps(2); - h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); - h.run_steps(1); - assert_eq!(h.state().0, "", "the x waits for the arrow"); - h.state_mut().2 = false; - h.run_steps(2); - assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); - } - - #[test] - /// A discard-triggered repeat pass receives held keys with the selection made by the first pass. - fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { - // When a widget asks egui to discard and repeat the pass, the repeat pass gets the - // held-back keys, with the selection the field made in the first pass. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, s: &mut (String, bool)| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, &mut s.0, Lang::Python, 5); - if std::mem::take(&mut s.1) { - ui.ctx().request_discard("the test repeats this pass"); - } - }, - ("a\nb".to_string(), false), - ); - h.run_steps(2); - h.state_mut().1 = true; - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state().0, " a\n b"); - } -} +mod tests; diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs new file mode 100644 index 000000000..ba3c08efa --- /dev/null +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -0,0 +1,368 @@ +use super::*; +use egui::Modifiers; + +fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { + highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() +} + +#[test] +fn python_tokens() { + let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); + assert_eq!( + s, + vec![ + ("def", Token::Keyword), + ("return", Token::Keyword), + ("len", Token::Builtin), + ("1.5", Token::Number), + ("# done", Token::Comment), + ("xl", Token::Builtin), + ("\"A1:B2\"", Token::String), + ] + ); + assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); + assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); + assert_eq!(spans("hello", Lang::Plain), vec![]); +} + +#[test] +fn latex_tokens() { + let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); + assert_eq!( + s, + vec![ + ("\\frac", Token::Keyword), + ("^", Token::Builtin), + ("2", Token::Number), + ("3", Token::Number), + ("% half", Token::Comment), + ("\\{", Token::Keyword), + ] + ); +} + +#[test] +fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { + let inputs = [ + "", + "'", + "\"", + "'''", + "\"\"\"x", + "#", + "\\", + "%", + "1", + ".", + "é'ü\\", + "θ = 'π' # ∑", + "\u{1F600}'\u{1F600}", + "'\\", + "\\\u{1F600}", + "\u{200B}\n\t'''\n", + "0x_é", + "a.b.c", + "____", + ]; + for text in inputs { + for lang in [Lang::Python, Lang::Latex, Lang::Plain] { + let mut last = 0; + for (r, _) in highlight(text, lang) { + assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); + last = r.end; + } + let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); + assert_eq!(job.text, text, "every character is laid out once"); + } + } +} + +#[test] +fn enter_keeps_indentation_and_indents_after_a_colon() { + let mut s = " x = 1".to_string(); + let e = newline(&mut s, (9, 9), Lang::Python); + assert_eq!(s, " x = 1\n "); + assert_eq!(e.caret, 14); + let mut s = "if x:".to_string(); + let e = newline(&mut s, (5, 5), Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(e.caret, 10); + // Plain text keeps the indentation but adds none after a colon. + let mut s = " a:".to_string(); + newline(&mut s, (4, 4), Lang::Plain); + assert_eq!(s, " a:\n "); + // A selection is replaced; out-of-range carets are clamped. + let mut s = "abcdef".to_string(); + let e = newline(&mut s, (4, 2), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); + let mut s = "é".to_string(); + let e = newline(&mut s, (99, 99), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("é\n", 2)); +} + +#[test] +/// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. +fn shift_tab_in_the_editor_dedents_rather_than_indents() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + ui.ctx().set_fonts(theme::pinned_font_definitions()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + " x".to_string(), + ); + h.run_steps(2); + let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + h.input_mut().events.push(tab(Modifiers::SHIFT)); + h.run_steps(1); + assert_eq!(h.state(), " x"); + // A plain Tab still inserts spaces at the caret (the end). + h.input_mut().events.push(tab(Modifiers::NONE)); + h.run_steps(1); + assert_eq!(h.state(), " x "); +} + +/// Gives the context the deterministic font set, including the editor's monospace family. +fn install_mono(ctx: &egui::Context) { + ctx.set_fonts(theme::pinned_font_definitions()); +} + +/// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. +fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + text.to_string(), + ); + h.run_steps(2); + h.check_accessibility(); + h +} + +fn key(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +#[test] +fn batched_input_applies_in_order_and_keeps_every_key() { + // Events gather between repaints (typing, key repeat). The text typed before an Enter + // comes first, and two Enters make two lines. + let mut h = editor_harness(""); + h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n "); + // Backspace and Tab in the same frame as the typing around them. + h.input_mut().events.extend([ + egui::Event::Text("y = 1".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("2".into()), + key(Key::Tab, Modifiers::NONE), + ]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 "); + // An event the field acts on (an arrow) keeps the order too: the text typed before it + // is applied first, the field moves the caret, and the Enter after it waits for the + // next pass, where the editor applies it at the moved caret, with the indentation. + h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); +} + +#[test] +fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { + // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 2), false); + assert_eq!(s, " a\nb"); + let mut s = " a\n b".to_string(); + indent(&mut s, (0, 6), true); + assert_eq!(s, "a\n b"); + // One character into the second line, both lines are touched. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 3), false); + assert_eq!(s, " a\n b"); +} + +#[test] +fn typed_text_and_deletions_replace_the_selection() { + let mut s = "abcd".to_string(); + assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); + assert_eq!(s, "aXd"); + assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "ad"); + assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); + assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + let mut s = "éa".to_string(); + delete(&mut s, (2, 0), true); + assert_eq!(s, ""); + // The queue keeps what the editor does not handle, and everything after it. + let mut events = + vec![egui::Event::Text("x".into()), egui::Event::Text("\n".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(out.edited); + assert_eq!((s.as_str(), sel), ("x", (1, 1))); + assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); + assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); + let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); + assert_eq!(events.len(), 1); +} + +#[test] +fn select_all_then_tab_in_one_pass_indents_every_line() { + // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the + // selection and indents the selected lines, rather than reaching the field, which would + // replace the selection with a tab character. + let mut h = editor_harness("a\nb"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); + h.run_steps(1); + assert_eq!(h.state(), " a\n b"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); + h.run_steps(2); + assert_eq!(h.state(), "a\nb"); +} + +#[test] +fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { + // egui-winit sends a Key event before each Text event. Those presses are nobody's to + // read, so they do not hand the Enter to the field, which knows no indentation. + let mut h = editor_harness(""); + let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; + let mut events = Vec::new(); + events.extend(typed(Key::I, "i", Modifiers::NONE)); + events.extend(typed(Key::F, "f", Modifiers::NONE)); + events.extend(typed(Key::Space, " ", Modifiers::NONE)); + events.extend(typed(Key::X, "x", Modifiers::NONE)); + events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); + events.push(key(Key::Enter, Modifiers::NONE)); + h.input_mut().events.extend(events); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n "); + // A character typed with Option (macOS) comes as a key under that modifier and its text; + // the field reads neither the key nor Option, so the Enter after it is still the editor's. + h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \u{e9}\n "); +} + +#[test] +fn tab_and_enter_under_modifiers_never_reach_the_field() { + // The field would make a tab character of Ctrl+Tab (in place of the selection) and a + // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. + let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; + let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); + assert!(out.held.is_empty()); +} + +#[test] +fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { + let mut events = vec![ + key(Key::X, Modifiers::NONE), + egui::Event::Text("x".into()), + key(Key::A, Modifiers::COMMAND), + key(Key::Tab, Modifiers::NONE), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::ArrowLeft, Modifiers::NONE), + egui::Event::Text("y".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); + assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); +} + +#[test] +/// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. +fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::unique("code"); + let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; + // What the pass just completed held back comes in with the next one. + let last = h.ctx.cumulative_pass_nr() - 1; + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + // Anything older is dropped rather than typed out of context, and cleared. + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); +} + +#[test] +/// Held keyboard input does not land in the next focused text field once the code editor has lost focus. +fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { + // Typed into the editor, they must not land in whatever has the focus next. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); + ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); + code_editor(ui, code, &mut s.0, Lang::Python, 5); + ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); + }, + (String::new(), String::new(), true), + ); + h.run_steps(2); + h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); + h.run_steps(1); + assert_eq!(h.state().0, "", "the x waits for the arrow"); + h.state_mut().2 = false; + h.run_steps(2); + assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); +} + +#[test] +/// A discard-triggered repeat pass receives held keys with the selection made by the first pass. +fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { + // When a widget asks egui to discard and repeat the pass, the repeat pass gets the + // held-back keys, with the selection the field made in the first pass. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, &mut s.0, Lang::Python, 5); + if std::mem::take(&mut s.1) { + ui.ctx().request_discard("the test repeats this pass"); + } + }, + ("a\nb".to_string(), false), + ); + h.run_steps(2); + h.state_mut().1 = true; + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state().0, " a\n b"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4c226b81..af5eda156 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -24,6 +24,21 @@ use serde_json::{Value as Json, json}; use crate::SheetApp; +mod screenshots; +mod synthetic; + +pub use screenshots::{ + CaptureGeometry, SCREENSHOT_DOCUMENT_CHANGED, SCREENSHOT_MAX_AGGREGATE_BYTES, SCREENSHOT_MAX_BASE64_BYTES, SCREENSHOT_MAX_ENCODING_BYTES, + SCREENSHOT_MAX_IMAGE_BYTES, SCREENSHOT_MAX_PEAK_BYTES, SCREENSHOT_MAX_REQUESTS, SCREENSHOT_MAX_RETRIES, SCREENSHOT_VIEWPORT_CHANGED, Shots, + UNKNOWN_CAPTURE_BYTES, drain_screenshots, encoding_working_bytes, issue_screenshots, save_image, +}; +use screenshots::{Shot, check_encoding_budget, check_peak_budget, reject_shot}; +#[cfg(test)] +use screenshots::{base64_len, png_worst_bytes, validate_image}; +pub(crate) use screenshots::{current_capture_geometry, note_capture_geometry}; +pub use synthetic::{MAX_QUEUED_SYNTHETIC_BYTES, MAX_QUEUED_SYNTHETIC_EVENTS, MAX_SYNTHETIC_TEXT_BYTES}; +use synthetic::{cell_center, key_from, modifiers, push_click, push_drag, push_key, push_move, push_text}; + pub type ControlResponse = Json; pub struct ControlRequest { @@ -67,323 +82,6 @@ pub enum Outcome { Screenshot { path: Option }, } -/// Most screenshots one client may have in flight or waiting for its reply at once. -pub const SCREENSHOT_MAX_REQUESTS: usize = 4; -/// Retained bytes for one screenshot image. The renderer's readback buffer is a separate -/// transient allocation (recorded in peak RSS), not part of this reservation. -pub const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; -/// Aggregate bytes reserved by every outstanding screenshot request. -pub const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; -/// Largest transient working set allowed while one capture is encoded. It covers the RGBA copy -/// made from the retained `ColorImage`, the PNG stream and (for base64 replies) the encoded text. -/// Admission checks this before a request is issued, so a large capture cannot make the process -/// peak several times above its retained-image budget. -pub const SCREENSHOT_MAX_ENCODING_BYTES: usize = 256 * 1024 * 1024; -/// Largest base64 reply this client produces. The encoded length is checked before the string is -/// allocated; at most [`SCREENSHOT_MAX_REQUESTS`] such replies can therefore wait in the channel, -/// keeping queued base64 text bounded too. -pub const SCREENSHOT_MAX_BASE64_BYTES: usize = 64 * 1024 * 1024; -/// Hard bound on retained request images plus one capture's encoding working set. Admission, -/// issuance and every retry reject a request that would push the process over this, so the -/// aggregate reservation and the transient encoder peak together stay bounded. -pub const SCREENSHOT_MAX_PEAK_BYTES: usize = 512 * 1024 * 1024; -/// Capture retries allowed inside one request's deadline after a resize/DPI/zoom/surface change. -pub const SCREENSHOT_MAX_RETRIES: u32 = 2; -/// Reported once when the active document changes before a queued capture is issued or completed. -pub const SCREENSHOT_DOCUMENT_CHANGED: &str = "screenshot failed: the document changed while the capture was queued"; -/// Reported once when a capture request or completion is not for the active viewport. -pub const SCREENSHOT_VIEWPORT_CHANGED: &str = "screenshot failed: the capture belongs to another viewport"; - -/// Reserved while the viewport has no capturable size yet (minimized, or not laid out at all): -/// the largest single image the per-image limit allows. The real size replaces it at issuance. -pub const UNKNOWN_CAPTURE_BYTES: usize = SCREENSHOT_MAX_IMAGE_BYTES; - -/// The viewport geometry a capture is admitted, issued and completed against. -/// -/// `SheetApp` keeps a monotonic epoch (see [`crate::SheetApp::logic`]) that increments on any -/// change of these values, so a resize back to the original size is still a mismatch: an image -/// taken in another layout must never be combined with the current geometry. The viewport id, -/// minimized/occluded state and visibility are part of the identity too: a minimize/restore or -/// an occlusion transition at the same pixel size is a new surface generation, not a no-op. -/// -/// The identity deliberately keeps `zoom`, `visible` and `occluded` even though interface zoom -/// does not change the expected physical surface size. A capture is bound to one layout generation, -/// so a zoom or occlusion transition during it is retried within [`SCREENSHOT_MAX_RETRIES`] and -/// then reported as a bounded failure instead of risking pixels from another generation. The common -/// wheel-zoom path is frozen while a capture is in flight; a non-glide zoom or a compositor -/// occlusion flap can still exhaust the retries, which is accepted as a rare, explicit error. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct CaptureGeometry { - /// The viewport this capture belongs to. - pub viewport_id: egui::ViewportId, - /// Logical size of the content that is captured, or `None` when the window currently has no - /// capturable surface (minimized, or not laid out yet). A hidden-but-occluded window still - /// reports a size. - pub viewport: Option, - pub pixels_per_point: f32, - pub zoom: f32, - /// Maximum physical width/height the surface/renderer can capture. - pub max_texture_side: usize, - pub minimized: Option, - pub occluded: Option, - pub visible: Option, -} - -impl CaptureGeometry { - /// The geometry described by a native frame's fresh [`egui::RawInput`]. - /// - /// `raw.screen_rect` is the authoritative content size when `ViewportInfo::inner_rect` is - /// absent (Wayland always omits it, and a minimized window can omit both). The fallback to the - /// last UI pass's rect is deliberately *not* used here: a logic-only tick after a resize or - /// minimize must record the new state (or the explicit unknown state), never the old layout. - pub fn from_raw_input(raw: &egui::RawInput, ctx: &egui::Context) -> Self { - let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); - Self::build(raw.viewport_id, &info, raw.screen_rect, raw.max_texture_side, ctx.zoom_factor(), ctx.pixels_per_point()) - } - - /// Builds a `CaptureGeometry` from fresh viewport information. A minimized window has no size; `inner_rect` falls back to the raw screen rect; native scale is multiplied by interface zoom, and a zero surface limit means uncapped. - fn build( - viewport_id: egui::ViewportId, - info: &egui::ViewportInfo, - screen_rect: Option, - max_texture_side: Option, - zoom: f32, - fallback_pixels_per_point: f32, - ) -> Self { - let minimized = info.minimized; - let visible = info.visible(); - // A minimized window has no surface to capture even if the platform still reports the - // last inner rectangle. Both rectangles missing likewise means "unknown size", not a - // reason to re-use an earlier layout. - let viewport = if minimized == Some(true) { - None - } else { - info.inner_rect.map(|rect| rect.size()).filter(positive_size).or_else(|| screen_rect.map(|rect| rect.size()).filter(positive_size)) - }; - let pixels_per_point = - info.native_pixels_per_point.filter(|p| p.is_finite() && *p > 0.0).map_or(fallback_pixels_per_point, |native| native * zoom); - Self { - viewport_id, - viewport, - pixels_per_point, - zoom, - // Zero means "unreported" (a bare test context); `expected_size` treats it as no cap. - max_texture_side: max_texture_side.unwrap_or(0), - minimized, - occluded: info.occluded, - visible, - } - } - - /// The geometry of egui's current input. This is a fallback for callers that do not run - /// [`crate::SheetApp::raw_input_hook`] (tests and embedders); native eframe always records the - /// fresh [`Self::from_raw_input`] value first, so issuance and drain never fall back here. - pub fn current(ctx: &egui::Context) -> Self { - let (viewport_id, info, screen_rect, max_texture_side, fallback_ppp) = ctx.input(|i| { - let viewport_id = i.raw.viewport_id; - let info = i.raw.viewports.get(&viewport_id).cloned().unwrap_or_default(); - // `screen_rect` from the pass if one supplied it; otherwise egui's retained content - // rect (which is only fresh when this is called from a UI pass, never a logic tick). - let screen_rect = i.raw.screen_rect.or_else(|| Some(i.viewport_rect())); - (viewport_id, info, screen_rect, i.max_texture_side, i.pixels_per_point()) - }); - Self::build(viewport_id, &info, screen_rect, Some(max_texture_side), ctx.zoom_factor(), fallback_ppp) - } - - /// Physical pixel size the screenshot should have, rounded and capped by the surface limit. - /// `None` when the viewport has no positive, finite size (minimized or not laid out yet). - pub fn expected_size(&self) -> Option<[usize; 2]> { - let viewport = self.viewport?; - let ppp = self.pixels_per_point; - if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { - return None; - } - if !ppp.is_finite() || ppp <= 0.0 { - return None; - } - // An unreported surface limit (0) must not cap every capture to one pixel. - let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; - let axis = |points: f32| -> Option { - let px = (f64::from(points) * f64::from(ppp)).round(); - if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { - return None; - } - Some((px as usize).min(max)) - }; - Some([axis(viewport.x)?, axis(viewport.y)?]) - } - - /// Checked `width * height * 4` bytes for the expected image, capped by the surface limit. - pub fn expected_bytes(&self) -> Option { - let [w, h] = self.expected_size()?; - w.checked_mul(h)?.checked_mul(4) - } - - /// Checked bytes of the largest image [`validate_image`] accepts for this geometry: one - /// physical pixel of rounding per axis. Reservations and the slot's per-image bound both use - /// this, so the aggregate budget is strict for the bytes actually retained. - pub fn retained_bytes(&self) -> Option { - let [w, h] = self.expected_size()?; - let w = w.checked_add(1)?; - let h = h.checked_add(1)?; - w.checked_mul(h)?.checked_mul(4) - } -} - -/// True only when both width and height are finite and positive. -fn positive_size(size: &egui::Vec2) -> bool { - size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 -} - -/// Conservative worst-case size of the PNG stream for a `bytes`-sized RGBA image with `height` -/// rows. PNG adds a filter byte per row and zlib framing; uncompressed blocks add a few bytes per -/// 64 KiB. 1% + 64 KiB plus the row bytes is safely above the real encoder output. -fn png_worst_bytes(bytes: usize, height: usize) -> Option { - bytes.checked_add(bytes / 100)?.checked_add(height.checked_mul(2)?)?.checked_add(64 * 1024) -} - -/// Base64 length (with padding) for `len` bytes. -fn base64_len(len: usize) -> Option { - len.checked_add(2)?.checked_div(3)?.checked_mul(4) -} - -/// Worst transient bytes for encoding one expected capture: the retained `ColorImage` plus the -/// RGBA copy made from it, then (for base64) the PNG stream and the encoded text. `None` on -/// overflow. Admission rejects a request whose working set exceeds -/// [`SCREENSHOT_MAX_ENCODING_BYTES`] before any slot or command exists. -pub fn encoding_working_bytes(expected: [usize; 2], base64: bool) -> Option { - let [w, h] = expected; - let bytes = w.checked_mul(h)?.checked_mul(4)?; - let retained_and_copy = bytes.checked_mul(2)?; - if !base64 { - return Some(retained_and_copy); - } - let png = png_worst_bytes(bytes, h)?; - let encoded = base64_len(png)?; - Some(retained_and_copy.max(bytes.checked_add(png)?.checked_add(encoded)?)) -} - -/// Reject an expected capture whose encoding working set or base64 reply would exceed the -/// explicit encoding limits. Checked before admission and again immediately before issuance. -fn check_encoding_budget(expected: [usize; 2], base64: bool) -> Result<(), String> { - let [w, h] = expected; - let Some(bytes) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { - return Err("screenshot failed: the window size overflows the screenshot budget".to_string()); - }; - let Some(working) = encoding_working_bytes(expected, base64) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - if working > SCREENSHOT_MAX_ENCODING_BYTES { - return Err(format!( - "screenshot encoding budget exceeded: {working} bytes of transient working memory for one {w}×{h} capture, over the {SCREENSHOT_MAX_ENCODING_BYTES}-byte encoding limit" - )); - } - if base64 { - let Some(png) = png_worst_bytes(bytes, h) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - let Some(encoded) = base64_len(png) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - if encoded > SCREENSHOT_MAX_BASE64_BYTES { - return Err(format!( - "screenshot base64 budget exceeded: the reply for one {w}×{h} capture could be {encoded} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" - )); - } - } - Ok(()) -} - -/// Reject a request whose reserved retained bytes plus its encoding working set exceed -/// [`SCREENSHOT_MAX_PEAK_BYTES`]. `reserved` already includes this request's own reservation. -fn check_peak_budget(reserved: usize, expected: [usize; 2], base64: bool) -> Result<(), String> { - let Some(working) = encoding_working_bytes(expected, base64) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - let peak = reserved.saturating_add(working); - if peak > SCREENSHOT_MAX_PEAK_BYTES { - return Err(format!( - "screenshot peak-memory budget exceeded: {reserved} bytes retained plus {working} bytes encoding for one capture is over the {SCREENSHOT_MAX_PEAK_BYTES}-byte peak limit" - )); - } - Ok(()) -} - -/// One admitted `ui.screenshot` request. The slot owns the image and its viewport waker; the -/// callback holds only a weak reference to it and never writes a file or sends a reply. -struct Shot { - token: u64, - path: Option, - reply: Sender, - /// Monotonic deadline for the whole request, including retries. - deadline: f64, - /// Settle time before the first issuance (the UI must be still). - ready_at: f64, - /// Real (non-discarded) UI passes counted towards settling. - frames: u32, - /// `cumulative_pass_nr` of the last pass counted in `frames`, so repeated `ui` calls in one - /// pass count once and a settled frame is never counted twice. - settle_pass: Option, - issued: bool, - retries: u32, - /// Geometry at admission, updated immediately before issuance. - geometry: CaptureGeometry, - /// The viewport this request was admitted for. A callback or slot from another viewport must - /// never fill this request. - viewport_id: egui::ViewportId, - /// The active document (`DocState::uid`) at admission. A queued capture is never rebound to a - /// document that became active later. - document: Option, - /// Monotonic geometry epoch at issuance; any later change rejects the image. - epoch: u64, - /// Physical size expected at issuance; `None` while the window has no capturable size. - expected: Option<[usize; 2]>, - /// Bytes reserved from the aggregate budget until the request completes or is dropped. This - /// is the largest image the request may retain (one pixel of rounding per axis included) and - /// is also the slot's per-image bound. - bytes: usize, - /// Created at issuance (and anew for every retry), never at admission: the bound must match - /// the geometry and reservation the command is issued against. - slot: Option, -} - -#[derive(Default)] -pub struct Shots { - token: u64, - requests: Vec, -} - -impl Shots { - /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the - /// picture shows the settled state. - pub(crate) fn in_flight(&self) -> bool { - !self.requests.is_empty() - } - - /// Whether a hidden window still needs `RequestPaintWhileHidden` to reach issuance. Only a - /// request that is admitted or settling with a capturable size needs a first `ui` pass; once - /// `issue_screenshots` has sent the `Screenshot` command, that command's own `wants_paint` - /// paints the hidden window. A minimized request has no capturable size, so requesting hidden - /// full frames for its whole wait cannot make progress (the restore pass issues it). - pub(crate) fn needs_hidden_paint(&self) -> bool { - self.requests.iter().any(|r| !r.issued && r.expected.is_some()) - } - - /// Bytes currently reserved by admitted requests. - fn reserved(&self) -> usize { - self.requests.iter().fold(0usize, |sum, r| sum.saturating_add(r.bytes)) - } - - /// Drop every outstanding request without a reply (app shutdown): callbacks become inert. - pub fn cancel_all(&mut self) { - for request in self.requests.drain(..) { - if let Some(slot) = request.slot { - slot.cancel(); - } - } - } -} - fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -461,148 +159,6 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { }) } -fn key_from(name: &str) -> Option { - egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { - "enter" | "return" => Some(egui::Key::Enter), - "esc" | "escape" => Some(egui::Key::Escape), - "delete" | "del" => Some(egui::Key::Delete), - "backspace" => Some(egui::Key::Backspace), - "left" => Some(egui::Key::ArrowLeft), - "right" => Some(egui::Key::ArrowRight), - "up" => Some(egui::Key::ArrowUp), - "down" => Some(egui::Key::ArrowDown), - "space" => Some(egui::Key::Space), - "tab" => Some(egui::Key::Tab), - "pageup" => Some(egui::Key::PageUp), - "pagedown" => Some(egui::Key::PageDown), - "home" => Some(egui::Key::Home), - "end" => Some(egui::Key::End), - "f2" => Some(egui::Key::F2), - "f4" => Some(egui::Key::F4), - "f9" => Some(egui::Key::F9), - _ => None, - }) -} - -fn modifiers(p: &Json) -> egui::Modifiers { - let b = |n: &str| p.get(n).and_then(Json::as_bool).unwrap_or(false); - egui::Modifiers { - alt: b("alt"), - ctrl: b("ctrl"), - shift: b("shift"), - mac_cmd: b("cmd") && cfg!(target_os = "macos"), - command: b("cmd") || (b("ctrl") && !cfg!(target_os = "macos")), - } -} - -fn cell_center(app: &SheetApp, c: CellRef) -> Option { - let d = app.session.active()?; - let sh = d.wb.active()?; - let rect = app.grid.rect?; - let geo = crate::grid::Geo::new(sh, rect, app.view().scroll); - Some(geo.screen_cell_rect(sh, c).center()) -} - -/// Most synthetic events (pointer, key, text and move alike) a control client may keep queued at -/// once. The queue offers one batch per UI pass, and a hidden logic-only frame keeps the same -/// batch unapplied, so every producer must honour this budget. -pub const MAX_QUEUED_SYNTHETIC_EVENTS: usize = 10_000; -/// Most bytes of synthetic input queued at once: the `Event` storage plus every text payload. -pub const MAX_QUEUED_SYNTHETIC_BYTES: usize = 8 * 1024 * 1024; -/// Largest single `ui.text` string accepted (well below the control line limit; one paste-sized -/// string is plenty for a spreadsheet cell and keeps a burst from exhausting memory). -pub const MAX_SYNTHETIC_TEXT_BYTES: usize = 1024 * 1024; - -/// Bytes charged to one synthetic event: `size_of::()` plus the payload of a `Text` event. -fn event_bytes(event: &egui::Event) -> usize { - std::mem::size_of::() - + match event { - egui::Event::Text(text) => text.len(), - _ => 0, - } -} - -/// Saturating totals of queued synthetic events and their charged bytes across every pending batch. -fn synthetic_usage(app: &SheetApp) -> (usize, usize) { - app.synthetic - .iter() - .flat_map(|batch| batch.iter()) - .fold((0usize, 0usize), |(events, bytes), event| (events.saturating_add(1), bytes.saturating_add(event_bytes(event)))) -} - -/// Refuses a request that would push the queued synthetic input past either limit; the queue is -/// left unchanged. -fn synthetic_budget(app: &SheetApp, extra_events: usize, extra_bytes: usize) -> Result<(), String> { - let (events, bytes) = synthetic_usage(app); - if events.saturating_add(extra_events) > MAX_QUEUED_SYNTHETIC_EVENTS { - return Err(format!("too many synthetic input events (limit {MAX_QUEUED_SYNTHETIC_EVENTS} queued at once)")); - } - if bytes.saturating_add(extra_bytes) > MAX_QUEUED_SYNTHETIC_BYTES { - return Err(format!("synthetic input exceeds the {MAX_QUEUED_SYNTHETIC_BYTES}-byte queue limit")); - } - Ok(()) -} - -/// Checks the synthetic-event budget, then queues one pointer move followed by `count` press/release pairs (`count` is at least one). -fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { - let count = count.max(1) as usize; - let events = count.saturating_mul(2).saturating_add(1); - synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); - let mut batch = Vec::with_capacity(count * 2); - for _ in 0..count { - batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); - batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); - } - app.synthetic.push_back(batch); - Ok(()) -} - -/// Checks the synthetic-event budget, then queues a move, a press, `steps` interpolated moves and a release. -fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { - let steps = steps.max(1); - let events = steps.saturating_add(3); - synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; - app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); - app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); - for i in 1..=steps { - let t = i as f32 / steps as f32; - app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); - } - app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); - Ok(()) -} - -/// Checks the synthetic-event budget and queues one key press/release pair. -fn push_key(app: &mut SheetApp, key: egui::Key, m: egui::Modifiers) -> Result<(), String> { - let events = 2; - synthetic_budget(app, events, events * std::mem::size_of::())?; - app.synthetic.push_back(vec![ - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, - egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, - ]); - Ok(()) -} - -/// Rejects text longer than `MAX_SYNTHETIC_TEXT_BYTES`, then queues one `Text` event under the shared queue budget. -fn push_text(app: &mut SheetApp, text: String) -> Result<(), String> { - if text.len() > MAX_SYNTHETIC_TEXT_BYTES { - return Err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); - } - let event = egui::Event::Text(text); - synthetic_budget(app, 1, event_bytes(&event))?; - app.synthetic.push_back(vec![event]); - Ok(()) -} - -/// Queues one pointer-move event under the shared synthetic queue budget. -fn push_move(app: &mut SheetApp, pos: egui::Pos2) -> Result<(), String> { - let event = egui::Event::PointerMoved(pos); - synthetic_budget(app, 1, event_bytes(&event))?; - app.synthetic.push_back(vec![event]); - Ok(()) -} - /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -726,6 +282,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - None => err("no dialog is open"), }, "ui.dialog.cancel" => { + // The macro step editor's draft, overlays and debug run go with its own close. + if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + return match crate::macro_editor::close_now(app) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + }; + } app.dialog = None; ok(Json::Null) } @@ -883,11 +446,6 @@ pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { } } -/// Sends one screenshot rejection carrying the request token. -fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { - let _ = reply.send(json!({"ok": false, "error": message.to_string(), "request": token})); -} - /// Put the request token on every screenshot reply (success, rejection, timeout), so a client can /// correlate an asynchronous capture even when the reply order differs from the request order. fn with_token(mut reply: Json, token: u64) -> Json { @@ -897,29 +455,6 @@ fn with_token(mut reply: Json, token: u64) -> Json { reply } -/// The geometry to use for admission, issuance and drain. -/// -/// Native eframe calls [`crate::SheetApp::raw_input_hook`] before every frame (including a -/// logic-only tick), so the recorded value carries fresh `RawInput` geometry even when egui's -/// last UI pass is stale. Tests and embedders that never call the hook fall back to egui's current -/// input; that fallback is only used when no raw frame was seen. -pub(crate) fn current_capture_geometry(app: &SheetApp, ctx: &egui::Context) -> CaptureGeometry { - if app.capture_geometry_from_raw { app.capture_geometry.unwrap_or_else(|| CaptureGeometry::current(ctx)) } else { CaptureGeometry::current(ctx) } -} - -/// Record `geometry` as the layout captures are bound to and bump the epoch when it differs. -/// -/// Issuance and drain use this instead of writing the fields separately: during a hidden -/// logic-only tick the viewport info can be newer than the last UI pass, and the epoch must -/// change together with the geometry or a valid image would be retried against its own epoch. -pub(crate) fn note_capture_geometry(app: &mut SheetApp, geometry: CaptureGeometry) -> u64 { - if app.capture_geometry != Some(geometry) { - app.capture_geometry = Some(geometry); - app.capture_epoch = app.capture_epoch.wrapping_add(1); - } - app.capture_epoch -} - /// Returns the next screenshot token, wrapping and skipping zero. fn next_token(app: &mut SheetApp) -> u64 { let mut token = app.shots.token.wrapping_add(1); @@ -1023,1380 +558,5 @@ fn timeout_reply(message: &str, token: u64) -> Json { json!({"ok": false, "error": message, "request": token}) } -/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the -/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a -/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the -/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks -/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is -/// known. -fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { - let bytes = match expected { - Some(expected) => { - let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; - if bytes > SCREENSHOT_MAX_IMAGE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", - expected[0], expected[1] - )); - } - check_encoding_budget(expected, base64)?; - bytes - } - None => UNKNOWN_CAPTURE_BYTES, - }; - let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); - if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" - )); - } - if let Some(expected) = expected { - check_peak_budget(others.saturating_add(bytes), expected, base64)?; - } - Ok(bytes) -} - -/// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the -/// geometry and reserves the exact retained bytes, then sends one -/// `ViewportCommand::Screenshot(callback)` per request. -/// -/// The slot is created here, not at admission. Its per-image bound is the same reservation the -/// aggregate budget holds, so an image that would exceed the budget cannot be retained; a retry -/// after a resize creates a fresh slot for the new geometry. -pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - let now = crate::monotonic_ms(); - let pass = ctx.cumulative_pass_nr(); - // A pass whose output is already going to be discarded is not a settling frame and its - // layout is not the one to capture. GridCraft requests no discard after this call. - let discard = ctx.will_discard(); - let active_document = app.session.active().map(|d| d.uid); - let current_viewport = ctx.viewport_id(); - let len = app.shots.requests.len(); - let mut failures: Vec<(usize, String)> = Vec::new(); - for i in 0..len { - let (issued, ready_at, settle_pass) = { - let r = &app.shots.requests[i]; - (r.issued, r.ready_at, r.settle_pass) - }; - if issued || discard || settle_pass == Some(pass) { - continue; - } - // One count per egui pass, however many times this `ui` runs in it. - let frames = { - let r = &mut app.shots.requests[i]; - r.settle_pass = Some(pass); - r.frames = r.frames.saturating_add(1); - r.frames - }; - if now < ready_at || frames < 3 { - continue; - } - // A queued capture belongs to the document and viewport it was admitted for. A switch - // before issuance is reported once and never rebound to the new document. - let (viewport_id, document, base64) = { - let r = &app.shots.requests[i]; - (r.viewport_id, r.document, r.path.is_none()) - }; - if viewport_id != current_viewport { - failures.push((i, SCREENSHOT_VIEWPORT_CHANGED.into())); - continue; - } - if document != active_document { - failures.push((i, SCREENSHOT_DOCUMENT_CHANGED.into())); - continue; - } - let current = current_capture_geometry(app, ctx); - let Some(expected) = current.expected_size() else { - // Still minimized or not laid out: keep the request queued and try again on a later - // real pass; `drain_screenshots` reports the size-specific timeout if none arrives. - app.shots.requests[i].expected = None; - continue; - }; - let bytes = match capture_bytes(app, i, current, Some(expected), base64) { - Ok(bytes) => bytes, - Err(message) => { - failures.push((i, message)); - continue; - } - }; - // Update the geometry and its epoch together, so the drain accepts exactly the layout the - // capture is issued against even when a hidden logic tick already saw the new viewport. - let epoch = note_capture_geometry(app, current); - let r = &mut app.shots.requests[i]; - r.geometry = current; - r.expected = Some(expected); - r.bytes = bytes; - r.epoch = epoch; - if let Some(old) = r.slot.take() { - old.cancel(); - } - let (slot, callback) = CaptureSlot::new(ctx, viewport_id, bytes); - r.slot = Some(slot); - ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Screenshot(callback)); - r.issued = true; - } - for (i, message) in failures.into_iter().rev() { - reject_shot_at(app, i, message); - } - if app.shots.in_flight() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } -} - -/// Checks that the image has the pixel count its dimensions imply, is within one pixel of the expected size on each axis, and keeps the expected aspect ratio within that rounding. -fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), String> { - let [w, h] = image.size; - let [ew, eh] = expected; - let pixels = w.checked_mul(h).ok_or_else(|| "screenshot dimensions overflow".to_string())?; - if image.pixels.len() != pixels { - return Err(format!("screenshot has {} pixels for its {w}×{h} dimensions", image.pixels.len())); - } - // Allow one physical pixel of rounding per axis, but not independent distortion: an image - // that is one pixel wider and one pixel shorter has a different aspect and cannot be the - // recorded viewport. Rejecting it forces the retry/timeout path instead of saving skewed pixels. - if w.abs_diff(ew) > 1 || h.abs_diff(eh) > 1 { - return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the window changed size while it was captured")); - } - // Keep the returned scale's aspect within the rounding error of the recorded logical viewport. - let cross = w.checked_mul(eh).and_then(|a| h.checked_mul(ew).map(|b| a.abs_diff(b))); - let Some(cross) = cross else { - return Err("screenshot dimensions overflow the aspect check".to_string()); - }; - if cross > ew.max(eh) { - return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the aspect ratio changed while it was captured")); - } - Ok(()) -} - -/// What [`drain_screenshots`] must do with one request after inspecting it. -enum DrainAction { - /// Still waiting; leave the request at the same index. - Keep, - /// Answer the request with this reply and remove it. - Remove(Json), - /// Re-arm the request for a fresh capture within its retry budget. - Restart, -} - -/// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch -/// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized -/// or late image is dropped without writing a file. -pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - let now = crate::monotonic_ms(); - let current_geometry = current_capture_geometry(app, ctx); - let epoch = note_capture_geometry(app, current_geometry); - let active_document = app.session.active().map(|d| d.uid); - let current_viewport = ctx.viewport_id(); - let mut i = 0; - while i < app.shots.requests.len() { - match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { - DrainAction::Keep => i += 1, - DrainAction::Remove(reply) => finish_shot(app, i, reply), - DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { - Ok(()) => i += 1, - Err(message) => reject_shot_at(app, i, message), - }, - } - } - if app.shots.in_flight() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } -} - -/// Validates one request and decides whether it stays queued, is answered or is re-armed. -fn drain_action( - app: &mut SheetApp, - i: usize, - now: f64, - current_geometry: CaptureGeometry, - epoch: u64, - active_document: Option, - current_viewport: egui::ViewportId, -) -> DrainAction { - let r = &mut app.shots.requests[i]; - // A document or viewport change is a definitive mismatch: cancel once, never retry and - // never write the pixels against the new identity. - if r.viewport_id != current_viewport { - cancel_shot(r); - return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); - } - if r.document != active_document { - cancel_shot(r); - return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); - } - if !r.issued { - return drain_unissued(r, now); - } - drain_completion(r, now, current_geometry, epoch) -} - -/// A request that never issued a capture: keep waiting, or report the timeout that fits whether -/// the window ever had a capturable size. -fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { - if now < r.deadline { - return DrainAction::Keep; - } - cancel_shot(r); - let message = if r.expected.is_none() { - "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" - } else { - "no frame was presented before the screenshot timeout" - }; - DrainAction::Remove(timeout_reply(message, r.token)) -} - -/// A request that issued a capture: inspect its slot once, then apply the deadline, validation -/// and geometry checks in the same order as issuance. -fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { - match r.slot.as_ref().and_then(|slot| slot.take()) { - // A completion that arrives past the deadline is late even if the pixels are valid: the - // client was promised one answer within the deadline, so nothing is written for it. - Some(result) if now >= r.deadline => { - drop(result); - DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) - } - Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), - // The slot refused the image before `validate_image` saw it: an image larger than the - // reservation is as much evidence of a resize as a smaller one. Retry within the same - // bounded budget instead of failing only when the window grew. A non-resize fault still - // exhausts the retries and reports the slot's error, so this stays fail-closed. - Some(Err(_)) if retry_allowed(r, now) => DrainAction::Restart, - Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), - None if now >= r.deadline => { - cancel_shot(r); - DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) - } - None => DrainAction::Keep, - } -} - -/// Validates a completed image against the size recorded at issuance and the current -/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the -/// request's deadline and retry budget gets a fresh attempt. -fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { - let validated = match r.expected { - Some(expected) => validate_image(&image, expected), - None => Err("screenshot has no expected size".to_string()), - }; - match validated { - Err(_) if retry_allowed(r, now) => { - drop(image); - DrainAction::Restart - } - Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), - Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { - if !retry_allowed(r, now) { - return DrainAction::Remove(json!({ - "ok": false, - "error": "screenshot geometry changed while it was captured; no image was written" - })); - } - drop(image); - DrainAction::Restart - } - Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), - } -} - -/// Whether this request may take another capture: both limits are fixed for the whole request. -fn retry_allowed(r: &Shot, now: f64) -> bool { - r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline -} - -/// Takes and cancels the request's capture slot, if it has one. -fn cancel_shot(r: &mut Shot) { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } -} - -/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. -fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - let _ = request.reply.send(with_token(reply, request.token)); -} - -/// Remove request `i`, cancel its slot and reject it with `message`. -fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - reject_shot(request.reply, request.token, message); -} - -/// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum -/// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. -/// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by -/// the caller. -fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { - let expected = geometry.expected_size(); - let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); - let bytes = capture_bytes(app, i, geometry, expected, base64)?; - let r = &mut app.shots.requests[i]; - r.retries = r.retries.saturating_add(1); - r.issued = false; - r.frames = 3; - r.settle_pass = None; - r.ready_at = now; - r.expected = expected; - r.bytes = bytes; - r.geometry = geometry; - r.epoch = epoch; - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - Ok(()) -} - -/// Encodes a screenshot as PNG to `path`, or returns it as base64. -/// -/// The retained `ColorImage` is released before encoding, and a path capture streams the PNG -/// straight to the file. A base64 reply is length-checked against -/// [`SCREENSHOT_MAX_BASE64_BYTES`] *before* the string is allocated, so one admitted capture cannot -/// retain an unbounded reply alongside its image. -pub fn save_image(image: std::sync::Arc, path: Option<&str>) -> Json { - use image::ImageEncoder as _; - let [w, h] = image.size; - let Some(capacity) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { return json!({"ok": false, "error": "bad image"}) }; - if image.pixels.len().checked_mul(4) != Some(capacity) { - return json!({"ok": false, "error": "bad image"}); - } - let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return json!({"ok": false, "error": "bad image"}) }; - // One RGBA copy is unavoidable with the `image` encoder API. Drop the retained image before - // encoding so the peak is the copy plus encoder buffers, not both images plus the PNG stream. - let mut rgba = Vec::with_capacity(capacity); - for p in &image.pixels { - rgba.extend_from_slice(&p.to_array()); - } - drop(image); - match path { - Some(p) => match std::fs::File::create(p) { - Ok(file) => { - let encoder = image::codecs::png::PngEncoder::new(file); - match encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { - Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), - Err(e) => { - // Do not leave a truncated PNG behind. - let _ = std::fs::remove_file(p); - json!({"ok": false, "error": e.to_string()}) - } - } - } - Err(e) => json!({"ok": false, "error": e.to_string()}), - }, - None => { - let mut png = Vec::new(); - let encoder = image::codecs::png::PngEncoder::new(&mut png); - if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { - return json!({"ok": false, "error": e.to_string()}); - } - let Some(encoded_len) = base64_len(png.len()) else { - return json!({"ok": false, "error": "screenshot base64 length overflow"}); - }; - if encoded_len > SCREENSHOT_MAX_BASE64_BYTES { - return json!({ - "ok": false, - "error": format!( - "screenshot base64 budget exceeded: the {w}×{h} reply would be {encoded_len} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" - ) - }); - } - json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), - json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), - json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), - ] - } - - fn ids(v: &[Json]) -> Vec<&str> { - v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() - } - - #[test] - fn search_filters_case_insensitively() { - assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); - assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); - assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); - assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); - assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); - assert_eq!(filter_commands(sample(), None).len(), 3); - } - - fn test_app() -> SheetApp { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - SheetApp::new(session, crate::Services::default()) - } - - #[test] - /// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. - fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { - let mut app = test_app(); - let p = egui::pos2(20.0, 20.0); - let m = egui::Modifiers::NONE; - let btn = egui::PointerButton::Primary; - assert!(push_click(&mut app, p, btn, 2, m).is_ok()); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - // Already queued input counts toward the budget. - assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - // Normal requests still queue in full. - assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); - assert_eq!(app.synthetic.len(), 8); - } - - #[test] - fn print_command_discovery_documents_settings_and_submission() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { - panic!("command discovery must return immediately"); - }; - assert_eq!(reply["ok"], true); - let commands = reply["result"].as_array().unwrap(); - for (id, expected_docs) in [ - ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), - ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), - ] { - let command = commands.iter().find(|command| command["id"] == id).unwrap(); - assert_eq!(command["ui"], true); - let docs = command["params"].as_str().unwrap(); - for expected in expected_docs { - assert!(docs.contains(expected), "{id}: missing {expected}"); - } - } - assert!(!app.printing.busy(), "discovery must not submit a job"); - assert!(app.backstage.is_none(), "discovery must not open settings"); - } - - #[test] - fn language_command_docs_list_every_language() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { - panic!("command discovery must return immediately"); - }; - let commands = reply["result"].as_array().unwrap(); - let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); - for l in crate::i18n::Language::ALL { - assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); - } - } - /// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping - /// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - /// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. - fn sized_context(size: egui::Vec2) -> egui::Context { - let ctx = egui::Context::default(); - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), - max_texture_side: Some(8192), - ..Default::default() - }; - let _ = ui_pass(&ctx, raw, |_| {}); - ctx - } - - /// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. - fn raw_of(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } - } - - /// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. - fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { - out.viewport_output - .get(&ctx.viewport_id()) - .map(|output| { - output - .commands - .iter() - .filter_map(|command| match command { - egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), - _ => None, - }) - .collect::>() - }) - .unwrap_or_default() - } - - /// Run three settled passes with `raw` and return the callbacks issued in them. - fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); - callbacks.extend(screenshot_callbacks(&out, ctx)); - } - callbacks - } - - /// Returns a per-process temporary PNG path after removing any stale file at that path. - fn temp_png(name: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); - let _ = std::fs::remove_file(&path); - path - } - - #[test] - /// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. - fn capture_geometry_reports_checked_capped_pixels() { - let geometry = CaptureGeometry { - viewport_id: egui::ViewportId::ROOT, - viewport: Some(egui::vec2(800.0, 600.0)), - pixels_per_point: 2.0, - zoom: 1.0, - max_texture_side: 4096, - minimized: None, - occluded: None, - visible: None, - }; - assert_eq!(geometry.expected_size(), Some([1600, 1200])); - assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); - assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); - let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; - assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); - let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; - assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); - assert_eq!(empty.retained_bytes(), None); - let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; - assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); - } - - #[test] - /// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. - fn screenshot_admission_stops_on_the_count_budget_without_evicting() { - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut replies = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - replies.push(rx); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("the over-count request is rejected"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); - assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); - } - - #[test] - /// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. - fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { - let ctx = sized_context(egui::vec2(5000.0, 5000.0)); - let mut app = test_app(); - let path = temp_png("aggregate").to_string_lossy().into_owned(); - let mut rejected = None; - for _ in 0..3 { - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture (not base64) isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.clone()), tx); - if let Ok(reply) = rx.try_recv() { - rejected = Some(reply); - } - } - let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); - assert_eq!(app.shots.requests.len(), 2); - } - - #[test] - /// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. - fn requests_without_a_size_stay_queued_and_time_out() { - let ctx = sized_context(egui::Vec2::ZERO); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); - // Settling passes without a size neither issue nor fail the request. - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); - assert!(callbacks.is_empty()); - assert!(!app.shots.requests[0].issued); - assert!(rx.try_recv().is_err()); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the size-less request times out once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); - assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); - } - - /// Counts `RequestPaintWhileHidden` commands in a logic output. - fn hidden_paints(out: &egui::LogicOutput) -> usize { - out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() - } - - #[test] - /// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. - fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); - - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); - } - - #[test] - /// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. - fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut raw); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err()); - let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); - app.shots.cancel_all(); - } - - #[test] - /// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. - fn one_admitted_capture_issues_one_callback_and_replies_once() { - use egui_kittest::kittest::Queryable as _; - - let mut app = test_app(); - app.use_prepared_fonts(); - let mut h = egui_kittest::Harness::builder() - .with_size(egui::vec2(900.0, 700.0)) - .with_context_setup(|ctx| { - ctx.set_fonts(crate::theme::pinned_font_definitions()); - crate::theme::apply(ctx, false); - }) - // This test completes the callback itself. Keep the real app/AccessKit tree, but - // do not let kittest's automatic screenshot handling initialize a GPU renderer. - .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) - .build_ui_state( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(4); - assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); - h.check_accessibility(); - - let ctx = h.ctx.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(h.state_mut(), &ctx, None, tx); - let token = h.state().shots.requests[0].token; - h.state_mut().shots.requests[0].ready_at = 0.0; - let mut callbacks = Vec::new(); - for _ in 0..3 { - h.step(); - callbacks.extend(screenshot_callbacks(h.output(), &ctx)); - } - assert_eq!(callbacks.len(), 1, "the settled request is issued once"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); - h.step(); // the real logic drains the callback and the UI repaints - h.check_accessibility(); - assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); - let reply = rx.try_recv().expect("the drain replies once"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 900); - assert_eq!(reply["result"]["height"], 700); - assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); - assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); - assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); - } - - #[test] - /// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. - fn out_of_order_completions_reply_on_their_own_request() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("order-1"); - let p2 = temp_png("order-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let token1 = app.shots.requests[0].token; - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - let token2 = app.shots.requests[1].token; - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2, "one callback per admitted request"); - // Complete the second request first: slots, not callback order, carry the result. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let second = rx2.try_recv().expect("the second request gets its own reply"); - assert_eq!(second["ok"], true, "{second}"); - assert_eq!(second["request"].as_u64(), Some(token2)); - assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); - assert!(!p1.exists(), "the unfinished request has not written anything"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let first = rx1.try_recv().expect("the first request gets its own reply"); - assert_eq!(first["request"].as_u64(), Some(token1)); - assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); - assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); - let _ = std::fs::remove_file(&p1); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// An expired request replies once with a timeout, writes no file and releases its reservation. - fn a_timed_out_capture_replies_once_and_never_writes() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("timeout"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the timeout replies"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert!(!path.exists(), "a timed-out capture never writes a file"); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a timeout is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A valid image that completes after its deadline gets one timeout reply and is not written. - fn a_capture_completed_after_its_deadline_writes_nothing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("late"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - let token = app.shots.requests[0].token; - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // The deadline passes while the callback is outstanding; the image arrives afterwards. - app.shots.requests[0].deadline = 0.0; - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the late completion gets one timeout reply"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert_eq!(reply["request"].as_u64(), Some(token)); - assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); - assert!(rx.try_recv().is_err(), "the late image is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. - fn a_callback_after_the_request_is_removed_is_inert() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("removed"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - app.shots.cancel_all(); - assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. - fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let admitted_epoch = app.shots.requests[0].epoch; - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); - assert_eq!(request.expected, Some([32, 24])); - assert_eq!(request.bytes, 33 * 25 * 4); - assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the once-issued capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. - fn the_retained_reservation_is_the_slot_bound() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. - assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the rounded image is accepted"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 11); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. - fn an_image_beyond_the_reservation_is_released_without_writing() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("over-1"); - let p2 = temp_png("over-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2); - // The slot refuses an image over the reservation instead of retaining bytes beyond it. - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - // The other request completes with valid pixels. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - // A larger image can be a genuine resize, so it receives the same bounded retries as - // a smaller one. Each oversized callback is released and never written while retrying. - for attempt in 1..=SCREENSHOT_MAX_RETRIES { - assert_eq!(app.shots.requests[0].retries, attempt); - assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); - assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); - assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - } - let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); - assert_eq!(refused["ok"], false, "{refused}"); - assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); - assert!(!p1.exists()); - let accepted = rx2.try_recv().expect("the valid image is written"); - assert_eq!(accepted["ok"], true, "{accepted}"); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0, "both reservations are released"); - } - - #[test] - /// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. - fn settling_counts_one_real_pass_and_skips_a_discarded_one() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // Two `ui` calls in one pass are one settling frame. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 1); - // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. - let mut asked = false; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - if !asked { - asked = true; - ui.ctx().request_discard("settling test"); - } - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); - } - - #[test] - /// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. - fn a_discard_requested_after_issuance_still_issues_and_writes_once() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("discard-after-issue"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let mut asked = false; - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { - asked = true; - ui.ctx().request_discard("discard after screenshot issuance"); - } - }); - callbacks.extend(screenshot_callbacks(&out, &ctx)); - } - assert!(asked, "the test requested a discard after issuance"); - assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - callbacks[0].clone().complete(image.clone()); - callbacks[0].clone().complete(image); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); - assert_eq!(reply["ok"], true, "{reply}"); - assert!(path.exists(), "the image is written exactly once"); - assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); - let _ = std::fs::remove_file(&path); - } - - #[test] - /// A grow-resize spends one retry, reissues at the new size and then accepts the new image. - fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - - // The surface grew by two pixels while the image was outstanding. The slot's reservation - // refuses the larger image before `validate_image`; drain must retry at the new geometry - // instead of failing because the resize went in this direction. - let mut grown = raw_of(egui::vec2(20.0, 16.0)); - app.raw_input_hook(&ctx, &mut grown); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); - assert!(!request.issued); - assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. - fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let mut paths = Vec::new(); - let mut receivers = Vec::new(); - for n in 0..SCREENSHOT_MAX_REQUESTS { - let path = temp_png(&format!("minimize-retry-{n}")); - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - paths.push(path); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); - assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); - - // The window is minimized with every capture outstanding. The drain retries each one - // against the new surface generation, which has no capturable size, so every retry - // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate - // limit; the rest must be reported instead of inflating `reserved()` over the budget. - let mut minimized = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = None; - viewport.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); - - assert!( - app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, - "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", - app.shots.reserved() - ); - let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); - assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); - assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); - assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); - assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); - - // Restoring re-issues only the surviving requests at the real size; both fit because the - // over-reserved retries were rejected rather than kept. - let mut restored = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); - viewport.minimized = Some(false); - viewport.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - let callbacks = issue_with(&ctx, &mut app, restored.clone()); - assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); - - let mut delivered = 0; - for rx in &mut receivers { - if let Ok(reply) = rx.try_recv() { - assert_eq!(reply["ok"], true, "{reply}"); - delivered += 1; - } - } - assert_eq!(delivered, 2, "the two surviving captures reply once each"); - let written = paths.iter().filter(|path| path.exists()).count(); - assert_eq!(written, 2, "only the surviving captures write their image"); - for path in &paths { - let _ = std::fs::remove_file(path); - } - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. - fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - - // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says - // 64x48, so only the fresh viewport info lets the drain see the change. - let hidden = || { - let mut raw = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); - viewport.native_pixels_per_point = Some(1.0); - } - raw - }; - let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert!(!request.issued, "the image captured before the hidden resize is not accepted"); - assert_eq!(request.retries, 1); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - // The next real pass issues against the new size and its image is accepted. - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Draining four completed captures replies to all of them and permits a new admission. - fn a_drained_capture_frees_its_slot_for_the_next_admission() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let mut receivers = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - for callback in &callbacks { - callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - // Draining before admitting is what `logic` does: completed requests release first. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert_eq!(app.shots.requests.len(), 0); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); - assert_eq!(app.shots.requests.len(), 1); - } - - #[test] - /// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. - fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { - // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(1280.0, 720.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut raw); - let geo = app.capture_geometry.expect("the raw frame records its geometry"); - assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); - assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); - assert_eq!(geo.expected_size(), Some([2560, 1440])); - - let epoch = app.capture_epoch; - // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. - let mut hidden = raw_of(egui::vec2(640.0, 480.0)); - if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(true); - } - app.raw_input_hook(&ctx, &mut hidden); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); - } - - #[test] - /// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. - fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut minimized = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let geo = app.capture_geometry.expect("recorded"); - assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); - assert_eq!(geo.visible, Some(false)); - - // Admission while minimized reserves the unknown-size maximum and waits. - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); - assert!(rx.try_recv().is_err()); - let epoch = app.capture_epoch; - - // Restoring at the same logical size is still a new surface generation. - let mut restored = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); - app.shots.cancel_all(); - } - - #[test] - /// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. - fn a_document_switch_before_issuance_cancels_without_writing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-queued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let admitted = app.shots.requests[0].document; - assert!(app.run("file.new", json!({})).is_ok()); - assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); - - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert!(callbacks.is_empty(), "a capture for another document is never issued"); - let reply = rx.try_recv().expect("the queued request is cancelled once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "no pixels for the old document are written to its path"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Switching documents after issuance rejects the completed image once and writes nothing. - fn a_document_switch_after_issuance_rejects_the_completed_image() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-issued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - assert!(app.run("file.new", json!({})).is_ok()); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the switched document is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "a completion after a document switch never writes"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A request bound to another viewport is rejected once with a viewport error and writes nothing. - fn a_completion_for_another_viewport_is_rejected() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("viewport"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // A slot/request bound to a different viewport must not fill this one. - app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the viewport mismatch is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. - fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { - let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); - assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); - assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); - assert!(validate_image(&image(99, 101), [100, 100]).is_err()); - assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); - } - - #[test] - /// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. - fn encoding_working_set_is_bounded_for_admitted_captures() { - assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - // The base64 reply itself is capped too, before the string exists. - let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); - assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); - // Retained requests plus one encoding working set are held to a hard peak. - assert!(check_peak_budget(0, [64, 48], false).is_ok()); - assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); - } - - #[test] - /// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. - fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { - let ctx = sized_context(egui::vec2(5000.0, 4000.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!( - reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), - "the error names the encoding budget: {reply}" - ); - assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); - - // The same capture as a file is admitted within the per-image and encoding budgets. - let path = temp_png("encoding-path"); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); - assert!(rx2.try_recv().is_err(), "the path form is admitted"); - assert_eq!(app.shots.requests.len(), 1); - app.shots.cancel_all(); - } - - #[test] - /// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. - fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { - let ctx = egui::Context::default(); - let mut app = test_app(); - // One oversized text event is refused before it is queued. - let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert!(app.synthetic.is_empty()); - - // The byte budget stops repeated maximum-size texts; the queue is left unchanged. - let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); - let mut refused_at = None; - for i in 0..64 { - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { - panic!("ui.text returns immediately") - }; - if reply["ok"] == false { - refused_at = Some((i, app.synthetic.len())); - break; - } - } - let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); - assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); - - // Event count: keys and moves use the same overall budget as pointer gestures. - app.synthetic.clear(); - let pos = egui::pos2(1.0, 1.0); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; - assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); - - app.synthetic.clear(); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); - } -} +mod tests; diff --git a/crates/ui-egui/src/control/screenshots.rs b/crates/ui-egui/src/control/screenshots.rs new file mode 100644 index 000000000..998a924b9 --- /dev/null +++ b/crates/ui-egui/src/control/screenshots.rs @@ -0,0 +1,741 @@ +use super::{CaptureSlot, ControlResponse, Json, Sender, SheetApp, json, timeout_reply, with_token}; + +/// Most screenshots one client may have in flight or waiting for its reply at once. +pub const SCREENSHOT_MAX_REQUESTS: usize = 4; + +/// Retained bytes for one screenshot image. The renderer's readback buffer is a separate +/// transient allocation (recorded in peak RSS), not part of this reservation. +pub const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; + +/// Aggregate bytes reserved by every outstanding screenshot request. +pub const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; + +/// Largest transient working set allowed while one capture is encoded. It covers the RGBA copy +/// made from the retained `ColorImage`, the PNG stream and (for base64 replies) the encoded text. +/// Admission checks this before a request is issued, so a large capture cannot make the process +/// peak several times above its retained-image budget. +pub const SCREENSHOT_MAX_ENCODING_BYTES: usize = 256 * 1024 * 1024; + +/// Largest base64 reply this client produces. The encoded length is checked before the string is +/// allocated; at most [`SCREENSHOT_MAX_REQUESTS`] such replies can therefore wait in the channel, +/// keeping queued base64 text bounded too. +pub const SCREENSHOT_MAX_BASE64_BYTES: usize = 64 * 1024 * 1024; + +/// Hard bound on retained request images plus one capture's encoding working set. Admission, +/// issuance and every retry reject a request that would push the process over this, so the +/// aggregate reservation and the transient encoder peak together stay bounded. +pub const SCREENSHOT_MAX_PEAK_BYTES: usize = 512 * 1024 * 1024; + +/// Capture retries allowed inside one request's deadline after a resize/DPI/zoom/surface change. +pub const SCREENSHOT_MAX_RETRIES: u32 = 2; + +/// Reported once when the active document changes before a queued capture is issued or completed. +pub const SCREENSHOT_DOCUMENT_CHANGED: &str = "screenshot failed: the document changed while the capture was queued"; + +/// Reported once when a capture request or completion is not for the active viewport. +pub const SCREENSHOT_VIEWPORT_CHANGED: &str = "screenshot failed: the capture belongs to another viewport"; + +/// Reserved while the viewport has no capturable size yet (minimized, or not laid out at all): +/// the largest single image the per-image limit allows. The real size replaces it at issuance. +pub const UNKNOWN_CAPTURE_BYTES: usize = SCREENSHOT_MAX_IMAGE_BYTES; + +/// The viewport geometry a capture is admitted, issued and completed against. +/// +/// `SheetApp` keeps a monotonic epoch (see [`crate::SheetApp::logic`]) that increments on any +/// change of these values, so a resize back to the original size is still a mismatch: an image +/// taken in another layout must never be combined with the current geometry. The viewport id, +/// minimized/occluded state and visibility are part of the identity too: a minimize/restore or +/// an occlusion transition at the same pixel size is a new surface generation, not a no-op. +/// +/// The identity deliberately keeps `zoom`, `visible` and `occluded` even though interface zoom +/// does not change the expected physical surface size. A capture is bound to one layout generation, +/// so a zoom or occlusion transition during it is retried within [`SCREENSHOT_MAX_RETRIES`] and +/// then reported as a bounded failure instead of risking pixels from another generation. The common +/// wheel-zoom path is frozen while a capture is in flight; a non-glide zoom or a compositor +/// occlusion flap can still exhaust the retries, which is accepted as a rare, explicit error. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureGeometry { + /// The viewport this capture belongs to. + pub viewport_id: egui::ViewportId, + /// Logical size of the content that is captured, or `None` when the window currently has no + /// capturable surface (minimized, or not laid out yet). A hidden-but-occluded window still + /// reports a size. + pub viewport: Option, + pub pixels_per_point: f32, + pub zoom: f32, + /// Maximum physical width/height the surface/renderer can capture. + pub max_texture_side: usize, + pub minimized: Option, + pub occluded: Option, + pub visible: Option, +} + +impl CaptureGeometry { + /// The geometry described by a native frame's fresh [`egui::RawInput`]. + /// + /// `raw.screen_rect` is the authoritative content size when `ViewportInfo::inner_rect` is + /// absent (Wayland always omits it, and a minimized window can omit both). The fallback to the + /// last UI pass's rect is deliberately *not* used here: a logic-only tick after a resize or + /// minimize must record the new state (or the explicit unknown state), never the old layout. + pub fn from_raw_input(raw: &egui::RawInput, ctx: &egui::Context) -> Self { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + Self::build(raw.viewport_id, &info, raw.screen_rect, raw.max_texture_side, ctx.zoom_factor(), ctx.pixels_per_point()) + } + + /// Builds a `CaptureGeometry` from fresh viewport information. A minimized window has no size; `inner_rect` falls back to the raw screen rect; native scale is multiplied by interface zoom, and a zero surface limit means uncapped. + fn build( + viewport_id: egui::ViewportId, + info: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + zoom: f32, + fallback_pixels_per_point: f32, + ) -> Self { + let minimized = info.minimized; + let visible = info.visible(); + // A minimized window has no surface to capture even if the platform still reports the + // last inner rectangle. Both rectangles missing likewise means "unknown size", not a + // reason to re-use an earlier layout. + let viewport = if minimized == Some(true) { + None + } else { + info.inner_rect.map(|rect| rect.size()).filter(positive_size).or_else(|| screen_rect.map(|rect| rect.size()).filter(positive_size)) + }; + let pixels_per_point = + info.native_pixels_per_point.filter(|p| p.is_finite() && *p > 0.0).map_or(fallback_pixels_per_point, |native| native * zoom); + Self { + viewport_id, + viewport, + pixels_per_point, + zoom, + // Zero means "unreported" (a bare test context); `expected_size` treats it as no cap. + max_texture_side: max_texture_side.unwrap_or(0), + minimized, + occluded: info.occluded, + visible, + } + } + + /// The geometry of egui's current input. This is a fallback for callers that do not run + /// [`crate::SheetApp::raw_input_hook`] (tests and embedders); native eframe always records the + /// fresh [`Self::from_raw_input`] value first, so issuance and drain never fall back here. + pub fn current(ctx: &egui::Context) -> Self { + let (viewport_id, info, screen_rect, max_texture_side, fallback_ppp) = ctx.input(|i| { + let viewport_id = i.raw.viewport_id; + let info = i.raw.viewports.get(&viewport_id).cloned().unwrap_or_default(); + // `screen_rect` from the pass if one supplied it; otherwise egui's retained content + // rect (which is only fresh when this is called from a UI pass, never a logic tick). + let screen_rect = i.raw.screen_rect.or_else(|| Some(i.viewport_rect())); + (viewport_id, info, screen_rect, i.max_texture_side, i.pixels_per_point()) + }); + Self::build(viewport_id, &info, screen_rect, Some(max_texture_side), ctx.zoom_factor(), fallback_ppp) + } + + /// Physical pixel size the screenshot should have, rounded and capped by the surface limit. + /// `None` when the viewport has no positive, finite size (minimized or not laid out yet). + pub fn expected_size(&self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + let ppp = self.pixels_per_point; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + if !ppp.is_finite() || ppp <= 0.0 { + return None; + } + // An unreported surface limit (0) must not cap every capture to one pixel. + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let px = (f64::from(points) * f64::from(ppp)).round(); + if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { + return None; + } + Some((px as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + /// Checked `width * height * 4` bytes for the expected image, capped by the surface limit. + pub fn expected_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + w.checked_mul(h)?.checked_mul(4) + } + + /// Checked bytes of the largest image [`validate_image`] accepts for this geometry: one + /// physical pixel of rounding per axis. Reservations and the slot's per-image bound both use + /// this, so the aggregate budget is strict for the bytes actually retained. + pub fn retained_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + let w = w.checked_add(1)?; + let h = h.checked_add(1)?; + w.checked_mul(h)?.checked_mul(4) + } +} + +/// True only when both width and height are finite and positive. +fn positive_size(size: &egui::Vec2) -> bool { + size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 +} + +/// Conservative worst-case size of the PNG stream for a `bytes`-sized RGBA image with `height` +/// rows. PNG adds a filter byte per row and zlib framing; uncompressed blocks add a few bytes per +/// 64 KiB. 1% + 64 KiB plus the row bytes is safely above the real encoder output. +pub(super) fn png_worst_bytes(bytes: usize, height: usize) -> Option { + bytes.checked_add(bytes / 100)?.checked_add(height.checked_mul(2)?)?.checked_add(64 * 1024) +} + +/// Base64 length (with padding) for `len` bytes. +pub(super) fn base64_len(len: usize) -> Option { + len.checked_add(2)?.checked_div(3)?.checked_mul(4) +} + +/// Worst transient bytes for encoding one expected capture: the retained `ColorImage` plus the +/// RGBA copy made from it, then (for base64) the PNG stream and the encoded text. `None` on +/// overflow. Admission rejects a request whose working set exceeds +/// [`SCREENSHOT_MAX_ENCODING_BYTES`] before any slot or command exists. +pub fn encoding_working_bytes(expected: [usize; 2], base64: bool) -> Option { + let [w, h] = expected; + let bytes = w.checked_mul(h)?.checked_mul(4)?; + let retained_and_copy = bytes.checked_mul(2)?; + if !base64 { + return Some(retained_and_copy); + } + let png = png_worst_bytes(bytes, h)?; + let encoded = base64_len(png)?; + Some(retained_and_copy.max(bytes.checked_add(png)?.checked_add(encoded)?)) +} + +/// Reject an expected capture whose encoding working set or base64 reply would exceed the +/// explicit encoding limits. Checked before admission and again immediately before issuance. +pub(super) fn check_encoding_budget(expected: [usize; 2], base64: bool) -> Result<(), String> { + let [w, h] = expected; + let Some(bytes) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { + return Err("screenshot failed: the window size overflows the screenshot budget".to_string()); + }; + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if working > SCREENSHOT_MAX_ENCODING_BYTES { + return Err(format!( + "screenshot encoding budget exceeded: {working} bytes of transient working memory for one {w}×{h} capture, over the {SCREENSHOT_MAX_ENCODING_BYTES}-byte encoding limit" + )); + } + if base64 { + let Some(png) = png_worst_bytes(bytes, h) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let Some(encoded) = base64_len(png) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if encoded > SCREENSHOT_MAX_BASE64_BYTES { + return Err(format!( + "screenshot base64 budget exceeded: the reply for one {w}×{h} capture could be {encoded} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + )); + } + } + Ok(()) +} + +/// Reject a request whose reserved retained bytes plus its encoding working set exceed +/// [`SCREENSHOT_MAX_PEAK_BYTES`]. `reserved` already includes this request's own reservation. +pub(super) fn check_peak_budget(reserved: usize, expected: [usize; 2], base64: bool) -> Result<(), String> { + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let peak = reserved.saturating_add(working); + if peak > SCREENSHOT_MAX_PEAK_BYTES { + return Err(format!( + "screenshot peak-memory budget exceeded: {reserved} bytes retained plus {working} bytes encoding for one capture is over the {SCREENSHOT_MAX_PEAK_BYTES}-byte peak limit" + )); + } + Ok(()) +} + +/// One admitted `ui.screenshot` request. The slot owns the image and its viewport waker; the +/// callback holds only a weak reference to it and never writes a file or sends a reply. +pub(super) struct Shot { + pub(super) token: u64, + pub(super) path: Option, + pub(super) reply: Sender, + /// Monotonic deadline for the whole request, including retries. + pub(super) deadline: f64, + /// Settle time before the first issuance (the UI must be still). + pub(super) ready_at: f64, + /// Real (non-discarded) UI passes counted towards settling. + pub(super) frames: u32, + /// `cumulative_pass_nr` of the last pass counted in `frames`, so repeated `ui` calls in one + /// pass count once and a settled frame is never counted twice. + pub(super) settle_pass: Option, + pub(super) issued: bool, + pub(super) retries: u32, + /// Geometry at admission, updated immediately before issuance. + pub(super) geometry: CaptureGeometry, + /// The viewport this request was admitted for. A callback or slot from another viewport must + /// never fill this request. + pub(super) viewport_id: egui::ViewportId, + /// The active document (`DocState::uid`) at admission. A queued capture is never rebound to a + /// document that became active later. + pub(super) document: Option, + /// Monotonic geometry epoch at issuance; any later change rejects the image. + pub(super) epoch: u64, + /// Physical size expected at issuance; `None` while the window has no capturable size. + pub(super) expected: Option<[usize; 2]>, + /// Bytes reserved from the aggregate budget until the request completes or is dropped. This + /// is the largest image the request may retain (one pixel of rounding per axis included) and + /// is also the slot's per-image bound. + pub(super) bytes: usize, + /// Created at issuance (and anew for every retry), never at admission: the bound must match + /// the geometry and reservation the command is issued against. + pub(super) slot: Option, +} + +#[derive(Default)] +pub struct Shots { + pub(super) token: u64, + pub(super) requests: Vec, +} + +impl Shots { + /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the + /// picture shows the settled state. + pub(crate) fn in_flight(&self) -> bool { + !self.requests.is_empty() + } + + /// Whether a hidden window still needs `RequestPaintWhileHidden` to reach issuance. Only a + /// request that is admitted or settling with a capturable size needs a first `ui` pass; once + /// `issue_screenshots` has sent the `Screenshot` command, that command's own `wants_paint` + /// paints the hidden window. A minimized request has no capturable size, so requesting hidden + /// full frames for its whole wait cannot make progress (the restore pass issues it). + pub(crate) fn needs_hidden_paint(&self) -> bool { + self.requests.iter().any(|r| !r.issued && r.expected.is_some()) + } + + /// Bytes currently reserved by admitted requests. + pub(super) fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, r| sum.saturating_add(r.bytes)) + } + + /// Drop every outstanding request without a reply (app shutdown): callbacks become inert. + pub fn cancel_all(&mut self) { + for request in self.requests.drain(..) { + if let Some(slot) = request.slot { + slot.cancel(); + } + } + } +} + +/// Sends one screenshot rejection carrying the request token. +pub(super) fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(json!({"ok": false, "error": message.to_string(), "request": token})); +} + +/// The geometry to use for admission, issuance and drain. +/// +/// Native eframe calls [`crate::SheetApp::raw_input_hook`] before every frame (including a +/// logic-only tick), so the recorded value carries fresh `RawInput` geometry even when egui's +/// last UI pass is stale. Tests and embedders that never call the hook fall back to egui's current +/// input; that fallback is only used when no raw frame was seen. +pub(crate) fn current_capture_geometry(app: &SheetApp, ctx: &egui::Context) -> CaptureGeometry { + if app.capture_geometry_from_raw { app.capture_geometry.unwrap_or_else(|| CaptureGeometry::current(ctx)) } else { CaptureGeometry::current(ctx) } +} + +/// Record `geometry` as the layout captures are bound to and bump the epoch when it differs. +/// +/// Issuance and drain use this instead of writing the fields separately: during a hidden +/// logic-only tick the viewport info can be newer than the last UI pass, and the epoch must +/// change together with the geometry or a valid image would be retried against its own epoch. +pub(crate) fn note_capture_geometry(app: &mut SheetApp, geometry: CaptureGeometry) -> u64 { + if app.capture_geometry != Some(geometry) { + app.capture_geometry = Some(geometry); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + app.capture_epoch +} + +/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the +/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a +/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the +/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks +/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is +/// known. +fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { + let bytes = match expected { + Some(expected) => { + let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + )); + } + check_encoding_budget(expected, base64)?; + bytes + } + None => UNKNOWN_CAPTURE_BYTES, + }; + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + )); + } + if let Some(expected) = expected { + check_peak_budget(others.saturating_add(bytes), expected, base64)?; + } + Ok(bytes) +} + +/// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the +/// geometry and reserves the exact retained bytes, then sends one +/// `ViewportCommand::Screenshot(callback)` per request. +/// +/// The slot is created here, not at admission. Its per-image bound is the same reservation the +/// aggregate budget holds, so an image that would exceed the budget cannot be retained; a retry +/// after a resize creates a fresh slot for the new geometry. +pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let pass = ctx.cumulative_pass_nr(); + // A pass whose output is already going to be discarded is not a settling frame and its + // layout is not the one to capture. GridCraft requests no discard after this call. + let discard = ctx.will_discard(); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let len = app.shots.requests.len(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..len { + let (issued, ready_at, settle_pass) = { + let r = &app.shots.requests[i]; + (r.issued, r.ready_at, r.settle_pass) + }; + if issued || discard || settle_pass == Some(pass) { + continue; + } + // One count per egui pass, however many times this `ui` runs in it. + let frames = { + let r = &mut app.shots.requests[i]; + r.settle_pass = Some(pass); + r.frames = r.frames.saturating_add(1); + r.frames + }; + if now < ready_at || frames < 3 { + continue; + } + // A queued capture belongs to the document and viewport it was admitted for. A switch + // before issuance is reported once and never rebound to the new document. + let (viewport_id, document, base64) = { + let r = &app.shots.requests[i]; + (r.viewport_id, r.document, r.path.is_none()) + }; + if viewport_id != current_viewport { + failures.push((i, SCREENSHOT_VIEWPORT_CHANGED.into())); + continue; + } + if document != active_document { + failures.push((i, SCREENSHOT_DOCUMENT_CHANGED.into())); + continue; + } + let current = current_capture_geometry(app, ctx); + let Some(expected) = current.expected_size() else { + // Still minimized or not laid out: keep the request queued and try again on a later + // real pass; `drain_screenshots` reports the size-specific timeout if none arrives. + app.shots.requests[i].expected = None; + continue; + }; + let bytes = match capture_bytes(app, i, current, Some(expected), base64) { + Ok(bytes) => bytes, + Err(message) => { + failures.push((i, message)); + continue; + } + }; + // Update the geometry and its epoch together, so the drain accepts exactly the layout the + // capture is issued against even when a hidden logic tick already saw the new viewport. + let epoch = note_capture_geometry(app, current); + let r = &mut app.shots.requests[i]; + r.geometry = current; + r.expected = Some(expected); + r.bytes = bytes; + r.epoch = epoch; + if let Some(old) = r.slot.take() { + old.cancel(); + } + let (slot, callback) = CaptureSlot::new(ctx, viewport_id, bytes); + r.slot = Some(slot); + ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Screenshot(callback)); + r.issued = true; + } + for (i, message) in failures.into_iter().rev() { + reject_shot_at(app, i, message); + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// Checks that the image has the pixel count its dimensions imply, is within one pixel of the expected size on each axis, and keeps the expected aspect ratio within that rounding. +pub(super) fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), String> { + let [w, h] = image.size; + let [ew, eh] = expected; + let pixels = w.checked_mul(h).ok_or_else(|| "screenshot dimensions overflow".to_string())?; + if image.pixels.len() != pixels { + return Err(format!("screenshot has {} pixels for its {w}×{h} dimensions", image.pixels.len())); + } + // Allow one physical pixel of rounding per axis, but not independent distortion: an image + // that is one pixel wider and one pixel shorter has a different aspect and cannot be the + // recorded viewport. Rejecting it forces the retry/timeout path instead of saving skewed pixels. + if w.abs_diff(ew) > 1 || h.abs_diff(eh) > 1 { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the window changed size while it was captured")); + } + // Keep the returned scale's aspect within the rounding error of the recorded logical viewport. + let cross = w.checked_mul(eh).and_then(|a| h.checked_mul(ew).map(|b| a.abs_diff(b))); + let Some(cross) = cross else { + return Err("screenshot dimensions overflow the aspect check".to_string()); + }; + if cross > ew.max(eh) { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the aspect ratio changed while it was captured")); + } + Ok(()) +} + +/// What [`drain_screenshots`] must do with one request after inspecting it. +enum DrainAction { + /// Still waiting; leave the request at the same index. + Keep, + /// Answer the request with this reply and remove it. + Remove(Json), + /// Re-arm the request for a fresh capture within its retry budget. + Restart, +} + +/// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch +/// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized +/// or late image is dropped without writing a file. +pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let current_geometry = current_capture_geometry(app, ctx); + let epoch = note_capture_geometry(app, current_geometry); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let mut i = 0; + while i < app.shots.requests.len() { + match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { + DrainAction::Keep => i += 1, + DrainAction::Remove(reply) => finish_shot(app, i, reply), + DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { + Ok(()) => i += 1, + Err(message) => reject_shot_at(app, i, message), + }, + } + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// Validates one request and decides whether it stays queued, is answered or is re-armed. +fn drain_action( + app: &mut SheetApp, + i: usize, + now: f64, + current_geometry: CaptureGeometry, + epoch: u64, + active_document: Option, + current_viewport: egui::ViewportId, +) -> DrainAction { + let r = &mut app.shots.requests[i]; + // A document or viewport change is a definitive mismatch: cancel once, never retry and + // never write the pixels against the new identity. + if r.viewport_id != current_viewport { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); + } + if r.document != active_document { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); + } + if !r.issued { + return drain_unissued(r, now); + } + drain_completion(r, now, current_geometry, epoch) +} + +/// A request that never issued a capture: keep waiting, or report the timeout that fits whether +/// the window ever had a capturable size. +fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { + if now < r.deadline { + return DrainAction::Keep; + } + cancel_shot(r); + let message = if r.expected.is_none() { + "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" + } else { + "no frame was presented before the screenshot timeout" + }; + DrainAction::Remove(timeout_reply(message, r.token)) +} + +/// A request that issued a capture: inspect its slot once, then apply the deadline, validation +/// and geometry checks in the same order as issuance. +fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + match r.slot.as_ref().and_then(|slot| slot.take()) { + // A completion that arrives past the deadline is late even if the pixels are valid: the + // client was promised one answer within the deadline, so nothing is written for it. + Some(result) if now >= r.deadline => { + drop(result); + DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) + } + Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), + // The slot refused the image before `validate_image` saw it: an image larger than the + // reservation is as much evidence of a resize as a smaller one. Retry within the same + // bounded budget instead of failing only when the window grew. A non-resize fault still + // exhausts the retries and reports the slot's error, so this stays fail-closed. + Some(Err(_)) if retry_allowed(r, now) => DrainAction::Restart, + Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), + None if now >= r.deadline => { + cancel_shot(r); + DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) + } + None => DrainAction::Keep, + } +} + +/// Validates a completed image against the size recorded at issuance and the current +/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the +/// request's deadline and retry budget gets a fresh attempt. +fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + let validated = match r.expected { + Some(expected) => validate_image(&image, expected), + None => Err("screenshot has no expected size".to_string()), + }; + match validated { + Err(_) if retry_allowed(r, now) => { + drop(image); + DrainAction::Restart + } + Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), + Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { + if !retry_allowed(r, now) { + return DrainAction::Remove(json!({ + "ok": false, + "error": "screenshot geometry changed while it was captured; no image was written" + })); + } + drop(image); + DrainAction::Restart + } + Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), + } +} + +/// Whether this request may take another capture: both limits are fixed for the whole request. +fn retry_allowed(r: &Shot, now: f64) -> bool { + r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline +} + +/// Takes and cancels the request's capture slot, if it has one. +fn cancel_shot(r: &mut Shot) { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } +} + +/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. +fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + let _ = request.reply.send(with_token(reply, request.token)); +} + +/// Remove request `i`, cancel its slot and reject it with `message`. +fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); +} + +/// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum +/// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. +/// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by +/// the caller. +fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { + let expected = geometry.expected_size(); + let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); + let bytes = capture_bytes(app, i, geometry, expected, base64)?; + let r = &mut app.shots.requests[i]; + r.retries = r.retries.saturating_add(1); + r.issued = false; + r.frames = 3; + r.settle_pass = None; + r.ready_at = now; + r.expected = expected; + r.bytes = bytes; + r.geometry = geometry; + r.epoch = epoch; + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + Ok(()) +} + +/// Encodes a screenshot as PNG to `path`, or returns it as base64. +/// +/// The retained `ColorImage` is released before encoding, and a path capture streams the PNG +/// straight to the file. A base64 reply is length-checked against +/// [`SCREENSHOT_MAX_BASE64_BYTES`] *before* the string is allocated, so one admitted capture cannot +/// retain an unbounded reply alongside its image. +pub fn save_image(image: std::sync::Arc, path: Option<&str>) -> Json { + use image::ImageEncoder as _; + let [w, h] = image.size; + let Some(capacity) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { return json!({"ok": false, "error": "bad image"}) }; + if image.pixels.len().checked_mul(4) != Some(capacity) { + return json!({"ok": false, "error": "bad image"}); + } + let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return json!({"ok": false, "error": "bad image"}) }; + // One RGBA copy is unavoidable with the `image` encoder API. Drop the retained image before + // encoding so the peak is the copy plus encoder buffers, not both images plus the PNG stream. + let mut rgba = Vec::with_capacity(capacity); + for p in &image.pixels { + rgba.extend_from_slice(&p.to_array()); + } + drop(image); + match path { + Some(p) => match std::fs::File::create(p) { + Ok(file) => { + let encoder = image::codecs::png::PngEncoder::new(file); + match encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), + Err(e) => { + // Do not leave a truncated PNG behind. + let _ = std::fs::remove_file(p); + json!({"ok": false, "error": e.to_string()}) + } + } + } + Err(e) => json!({"ok": false, "error": e.to_string()}), + }, + None => { + let mut png = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + return json!({"ok": false, "error": e.to_string()}); + } + let Some(encoded_len) = base64_len(png.len()) else { + return json!({"ok": false, "error": "screenshot base64 length overflow"}); + }; + if encoded_len > SCREENSHOT_MAX_BASE64_BYTES { + return json!({ + "ok": false, + "error": format!( + "screenshot base64 budget exceeded: the {w}×{h} reply would be {encoded_len} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + ) + }); + } + json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}) + } + } +} diff --git a/crates/ui-egui/src/control/synthetic.rs b/crates/ui-egui/src/control/synthetic.rs new file mode 100644 index 000000000..e5a1a7acd --- /dev/null +++ b/crates/ui-egui/src/control/synthetic.rs @@ -0,0 +1,145 @@ +use super::{CellRef, Json, SheetApp}; + +pub(super) fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" | "del" => Some(egui::Key::Delete), + "backspace" => Some(egui::Key::Backspace), + "left" => Some(egui::Key::ArrowLeft), + "right" => Some(egui::Key::ArrowRight), + "up" => Some(egui::Key::ArrowUp), + "down" => Some(egui::Key::ArrowDown), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + "pageup" => Some(egui::Key::PageUp), + "pagedown" => Some(egui::Key::PageDown), + "home" => Some(egui::Key::Home), + "end" => Some(egui::Key::End), + "f2" => Some(egui::Key::F2), + "f4" => Some(egui::Key::F4), + "f9" => Some(egui::Key::F9), + _ => None, + }) +} + +pub(super) fn modifiers(p: &Json) -> egui::Modifiers { + let b = |n: &str| p.get(n).and_then(Json::as_bool).unwrap_or(false); + egui::Modifiers { + alt: b("alt"), + ctrl: b("ctrl"), + shift: b("shift"), + mac_cmd: b("cmd") && cfg!(target_os = "macos"), + command: b("cmd") || (b("ctrl") && !cfg!(target_os = "macos")), + } +} + +pub(super) fn cell_center(app: &SheetApp, c: CellRef) -> Option { + let d = app.session.active()?; + let sh = d.wb.active()?; + let rect = app.grid.rect?; + let geo = crate::grid::Geo::new(sh, rect, app.view().scroll); + Some(geo.screen_cell_rect(sh, c).center()) +} + +/// Most synthetic events (pointer, key, text and move alike) a control client may keep queued at +/// once. The queue offers one batch per UI pass, and a hidden logic-only frame keeps the same +/// batch unapplied, so every producer must honour this budget. +pub const MAX_QUEUED_SYNTHETIC_EVENTS: usize = 10_000; + +/// Most bytes of synthetic input queued at once: the `Event` storage plus every text payload. +pub const MAX_QUEUED_SYNTHETIC_BYTES: usize = 8 * 1024 * 1024; + +/// Largest single `ui.text` string accepted (well below the control line limit; one paste-sized +/// string is plenty for a spreadsheet cell and keeps a burst from exhausting memory). +pub const MAX_SYNTHETIC_TEXT_BYTES: usize = 1024 * 1024; + +/// Bytes charged to one synthetic event: `size_of::()` plus the payload of a `Text` event. +fn event_bytes(event: &egui::Event) -> usize { + std::mem::size_of::() + + match event { + egui::Event::Text(text) => text.len(), + _ => 0, + } +} + +/// Saturating totals of queued synthetic events and their charged bytes across every pending batch. +fn synthetic_usage(app: &SheetApp) -> (usize, usize) { + app.synthetic + .iter() + .flat_map(|batch| batch.iter()) + .fold((0usize, 0usize), |(events, bytes), event| (events.saturating_add(1), bytes.saturating_add(event_bytes(event)))) +} + +/// Refuses a request that would push the queued synthetic input past either limit; the queue is +/// left unchanged. +fn synthetic_budget(app: &SheetApp, extra_events: usize, extra_bytes: usize) -> Result<(), String> { + let (events, bytes) = synthetic_usage(app); + if events.saturating_add(extra_events) > MAX_QUEUED_SYNTHETIC_EVENTS { + return Err(format!("too many synthetic input events (limit {MAX_QUEUED_SYNTHETIC_EVENTS} queued at once)")); + } + if bytes.saturating_add(extra_bytes) > MAX_QUEUED_SYNTHETIC_BYTES { + return Err(format!("synthetic input exceeds the {MAX_QUEUED_SYNTHETIC_BYTES}-byte queue limit")); + } + Ok(()) +} + +/// Checks the synthetic-event budget, then queues one pointer move followed by `count` press/release pairs (`count` is at least one). +pub(super) fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { + let count = count.max(1) as usize; + let events = count.saturating_mul(2).saturating_add(1); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); + let mut batch = Vec::with_capacity(count * 2); + for _ in 0..count { + batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + } + app.synthetic.push_back(batch); + Ok(()) +} + +/// Checks the synthetic-event budget, then queues a move, a press, `steps` interpolated moves and a release. +pub(super) fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { + let steps = steps.max(1); + let events = steps.saturating_add(3); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; + app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); + app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); + for i in 1..=steps { + let t = i as f32 / steps as f32; + app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); + } + app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); + Ok(()) +} + +/// Checks the synthetic-event budget and queues one key press/release pair. +pub(super) fn push_key(app: &mut SheetApp, key: egui::Key, m: egui::Modifiers) -> Result<(), String> { + let events = 2; + synthetic_budget(app, events, events * std::mem::size_of::())?; + app.synthetic.push_back(vec![ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + Ok(()) +} + +/// Rejects text longer than `MAX_SYNTHETIC_TEXT_BYTES`, then queues one `Text` event under the shared queue budget. +pub(super) fn push_text(app: &mut SheetApp, text: String) -> Result<(), String> { + if text.len() > MAX_SYNTHETIC_TEXT_BYTES { + return Err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); + } + let event = egui::Event::Text(text); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} + +/// Queues one pointer-move event under the shared synthetic queue budget. +pub(super) fn push_move(app: &mut SheetApp, pos: egui::Pos2) -> Result<(), String> { + let event = egui::Event::PointerMoved(pos); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs new file mode 100644 index 000000000..a680279e5 --- /dev/null +++ b/crates/ui-egui/src/control/tests.rs @@ -0,0 +1,979 @@ +use super::*; + +fn sample() -> Vec { + vec![ + json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), + json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), + json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), + ] +} + +fn ids(v: &[Json]) -> Vec<&str> { + v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() +} + +#[test] +fn search_filters_case_insensitively() { + assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); + assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); + assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); + assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); + assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); + assert_eq!(filter_commands(sample(), None).len(), 3); +} + +fn test_app() -> SheetApp { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + SheetApp::new(session, crate::Services::default()) +} + +#[test] +/// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. +fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { + let mut app = test_app(); + let p = egui::pos2(20.0, 20.0); + let m = egui::Modifiers::NONE; + let btn = egui::PointerButton::Primary; + assert!(push_click(&mut app, p, btn, 2, m).is_ok()); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + // Already queued input counts toward the budget. + assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + // Normal requests still queue in full. + assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); + assert_eq!(app.synthetic.len(), 8); +} + +#[test] +fn print_command_discovery_documents_settings_and_submission() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { + panic!("command discovery must return immediately"); + }; + assert_eq!(reply["ok"], true); + let commands = reply["result"].as_array().unwrap(); + for (id, expected_docs) in [ + ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), + ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), + ] { + let command = commands.iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["ui"], true); + let docs = command["params"].as_str().unwrap(); + for expected in expected_docs { + assert!(docs.contains(expected), "{id}: missing {expected}"); + } + } + assert!(!app.printing.busy(), "discovery must not submit a job"); + assert!(app.backstage.is_none(), "discovery must not open settings"); +} + +#[test] +fn language_command_docs_list_every_language() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().unwrap(); + let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); + for l in crate::i18n::Language::ALL { + assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); + } +} +/// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping +/// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). +fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out +} + +/// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. +fn sized_context(size: egui::Vec2) -> egui::Context { + let ctx = egui::Context::default(); + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() }; + let _ = ui_pass(&ctx, raw, |_| {}); + ctx +} + +/// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. +fn raw_of(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } +} + +/// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. +fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { + out.viewport_output + .get(&ctx.viewport_id()) + .map(|output| { + output + .commands + .iter() + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// Run three settled passes with `raw` and return the callbacks issued in them. +fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); + callbacks.extend(screenshot_callbacks(&out, ctx)); + } + callbacks +} + +/// Returns a per-process temporary PNG path after removing any stale file at that path. +fn temp_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + path +} + +#[test] +/// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. +fn capture_geometry_reports_checked_capped_pixels() { + let geometry = CaptureGeometry { + viewport_id: egui::ViewportId::ROOT, + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 2.0, + zoom: 1.0, + max_texture_side: 4096, + minimized: None, + occluded: None, + visible: None, + }; + assert_eq!(geometry.expected_size(), Some([1600, 1200])); + assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); + assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); + let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; + assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); + let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; + assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); + assert_eq!(empty.retained_bytes(), None); + let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; + assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); +} + +#[test] +/// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. +fn screenshot_admission_stops_on_the_count_budget_without_evicting() { + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut replies = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + replies.push(rx); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("the over-count request is rejected"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); + assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); +} + +#[test] +/// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. +fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { + let ctx = sized_context(egui::vec2(5000.0, 5000.0)); + let mut app = test_app(); + let path = temp_png("aggregate").to_string_lossy().into_owned(); + let mut rejected = None; + for _ in 0..3 { + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture (not base64) isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.clone()), tx); + if let Ok(reply) = rx.try_recv() { + rejected = Some(reply); + } + } + let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); + assert_eq!(app.shots.requests.len(), 2); +} + +#[test] +/// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. +fn requests_without_a_size_stay_queued_and_time_out() { + let ctx = sized_context(egui::Vec2::ZERO); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); + // Settling passes without a size neither issue nor fail the request. + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); + assert!(callbacks.is_empty()); + assert!(!app.shots.requests[0].issued); + assert!(rx.try_recv().is_err()); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the size-less request times out once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); + assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); +} + +/// Counts `RequestPaintWhileHidden` commands in a logic output. +fn hidden_paints(out: &egui::LogicOutput) -> usize { + out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() +} + +#[test] +/// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. +fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); + + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); +} + +#[test] +/// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. +fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut raw); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err()); + let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); + app.shots.cancel_all(); +} + +#[test] +/// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. +fn one_admitted_capture_issues_one_callback_and_replies_once() { + use egui_kittest::kittest::Queryable as _; + + let mut app = test_app(); + app.use_prepared_fonts(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .with_context_setup(|ctx| { + ctx.set_fonts(crate::theme::pinned_font_definitions()); + crate::theme::apply(ctx, false); + }) + // This test completes the callback itself. Keep the real app/AccessKit tree, but + // do not let kittest's automatic screenshot handling initialize a GPU renderer. + .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) + .build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); + h.check_accessibility(); + + let ctx = h.ctx.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(h.state_mut(), &ctx, None, tx); + let token = h.state().shots.requests[0].token; + h.state_mut().shots.requests[0].ready_at = 0.0; + let mut callbacks = Vec::new(); + for _ in 0..3 { + h.step(); + callbacks.extend(screenshot_callbacks(h.output(), &ctx)); + } + assert_eq!(callbacks.len(), 1, "the settled request is issued once"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); + h.step(); // the real logic drains the callback and the UI repaints + h.check_accessibility(); + assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); + let reply = rx.try_recv().expect("the drain replies once"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 900); + assert_eq!(reply["result"]["height"], 700); + assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); + assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); + assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); +} + +#[test] +/// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. +fn out_of_order_completions_reply_on_their_own_request() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("order-1"); + let p2 = temp_png("order-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let token1 = app.shots.requests[0].token; + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + let token2 = app.shots.requests[1].token; + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2, "one callback per admitted request"); + // Complete the second request first: slots, not callback order, carry the result. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let second = rx2.try_recv().expect("the second request gets its own reply"); + assert_eq!(second["ok"], true, "{second}"); + assert_eq!(second["request"].as_u64(), Some(token2)); + assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); + assert!(!p1.exists(), "the unfinished request has not written anything"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let first = rx1.try_recv().expect("the first request gets its own reply"); + assert_eq!(first["request"].as_u64(), Some(token1)); + assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); + assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); + let _ = std::fs::remove_file(&p1); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// An expired request replies once with a timeout, writes no file and releases its reservation. +fn a_timed_out_capture_replies_once_and_never_writes() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("timeout"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the timeout replies"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert!(!path.exists(), "a timed-out capture never writes a file"); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a timeout is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A valid image that completes after its deadline gets one timeout reply and is not written. +fn a_capture_completed_after_its_deadline_writes_nothing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("late"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + let token = app.shots.requests[0].token; + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // The deadline passes while the callback is outstanding; the image arrives afterwards. + app.shots.requests[0].deadline = 0.0; + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the late completion gets one timeout reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); + assert!(rx.try_recv().is_err(), "the late image is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. +fn a_callback_after_the_request_is_removed_is_inert() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("removed"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + app.shots.cancel_all(); + assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. +fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let admitted_epoch = app.shots.requests[0].epoch; + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); + assert_eq!(request.expected, Some([32, 24])); + assert_eq!(request.bytes, 33 * 25 * 4); + assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the once-issued capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. +fn the_retained_reservation_is_the_slot_bound() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. + assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the rounded image is accepted"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 11); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. +fn an_image_beyond_the_reservation_is_released_without_writing() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("over-1"); + let p2 = temp_png("over-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2); + // The slot refuses an image over the reservation instead of retaining bytes beyond it. + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + // The other request completes with valid pixels. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + // A larger image can be a genuine resize, so it receives the same bounded retries as + // a smaller one. Each oversized callback is released and never written while retrying. + for attempt in 1..=SCREENSHOT_MAX_RETRIES { + assert_eq!(app.shots.requests[0].retries, attempt); + assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); + assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); + assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + } + let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); + assert_eq!(refused["ok"], false, "{refused}"); + assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); + assert!(!p1.exists()); + let accepted = rx2.try_recv().expect("the valid image is written"); + assert_eq!(accepted["ok"], true, "{accepted}"); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0, "both reservations are released"); +} + +#[test] +/// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. +fn settling_counts_one_real_pass_and_skips_a_discarded_one() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // Two `ui` calls in one pass are one settling frame. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 1); + // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. + let mut asked = false; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + if !asked { + asked = true; + ui.ctx().request_discard("settling test"); + } + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); +} + +#[test] +/// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. +fn a_discard_requested_after_issuance_still_issues_and_writes_once() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("discard-after-issue"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let mut asked = false; + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { + asked = true; + ui.ctx().request_discard("discard after screenshot issuance"); + } + }); + callbacks.extend(screenshot_callbacks(&out, &ctx)); + } + assert!(asked, "the test requested a discard after issuance"); + assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + callbacks[0].clone().complete(image.clone()); + callbacks[0].clone().complete(image); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(path.exists(), "the image is written exactly once"); + assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); + let _ = std::fs::remove_file(&path); +} + +#[test] +/// A grow-resize spends one retry, reissues at the new size and then accepts the new image. +fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + + // The surface grew by two pixels while the image was outstanding. The slot's reservation + // refuses the larger image before `validate_image`; drain must retry at the new geometry + // instead of failing because the resize went in this direction. + let mut grown = raw_of(egui::vec2(20.0, 16.0)); + app.raw_input_hook(&ctx, &mut grown); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); + assert!(!request.issued); + assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. +fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let mut paths = Vec::new(); + let mut receivers = Vec::new(); + for n in 0..SCREENSHOT_MAX_REQUESTS { + let path = temp_png(&format!("minimize-retry-{n}")); + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + paths.push(path); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); + assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); + + // The window is minimized with every capture outstanding. The drain retries each one + // against the new surface generation, which has no capturable size, so every retry + // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate + // limit; the rest must be reported instead of inflating `reserved()` over the budget. + let mut minimized = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = None; + viewport.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); + + assert!( + app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, + "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", + app.shots.reserved() + ); + let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); + assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); + assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); + assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); + assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); + + // Restoring re-issues only the surviving requests at the real size; both fit because the + // over-reserved retries were rejected rather than kept. + let mut restored = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + let callbacks = issue_with(&ctx, &mut app, restored.clone()); + assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); + + let mut delivered = 0; + for rx in &mut receivers { + if let Ok(reply) = rx.try_recv() { + assert_eq!(reply["ok"], true, "{reply}"); + delivered += 1; + } + } + assert_eq!(delivered, 2, "the two surviving captures reply once each"); + let written = paths.iter().filter(|path| path.exists()).count(); + assert_eq!(written, 2, "only the surviving captures write their image"); + for path in &paths { + let _ = std::fs::remove_file(path); + } + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. +fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + + // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says + // 64x48, so only the fresh viewport info lets the drain see the change. + let hidden = || { + let mut raw = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); + viewport.native_pixels_per_point = Some(1.0); + } + raw + }; + let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert!(!request.issued, "the image captured before the hidden resize is not accepted"); + assert_eq!(request.retries, 1); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + // The next real pass issues against the new size and its image is accepted. + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Draining four completed captures replies to all of them and permits a new admission. +fn a_drained_capture_frees_its_slot_for_the_next_admission() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let mut receivers = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + for callback in &callbacks { + callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + // Draining before admitting is what `logic` does: completed requests release first. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert_eq!(app.shots.requests.len(), 0); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); + assert_eq!(app.shots.requests.len(), 1); +} + +#[test] +/// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. +fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { + // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(1280.0, 720.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut raw); + let geo = app.capture_geometry.expect("the raw frame records its geometry"); + assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); + assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); + assert_eq!(geo.expected_size(), Some([2560, 1440])); + + let epoch = app.capture_epoch; + // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. + let mut hidden = raw_of(egui::vec2(640.0, 480.0)); + if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(true); + } + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); +} + +#[test] +/// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. +fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut minimized = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let geo = app.capture_geometry.expect("recorded"); + assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); + assert_eq!(geo.visible, Some(false)); + + // Admission while minimized reserves the unknown-size maximum and waits. + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); + assert!(rx.try_recv().is_err()); + let epoch = app.capture_epoch; + + // Restoring at the same logical size is still a new surface generation. + let mut restored = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); + app.shots.cancel_all(); +} + +#[test] +/// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. +fn a_document_switch_before_issuance_cancels_without_writing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-queued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let admitted = app.shots.requests[0].document; + assert!(app.run("file.new", json!({})).is_ok()); + assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); + + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert!(callbacks.is_empty(), "a capture for another document is never issued"); + let reply = rx.try_recv().expect("the queued request is cancelled once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "no pixels for the old document are written to its path"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Switching documents after issuance rejects the completed image once and writes nothing. +fn a_document_switch_after_issuance_rejects_the_completed_image() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-issued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + assert!(app.run("file.new", json!({})).is_ok()); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the switched document is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "a completion after a document switch never writes"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A request bound to another viewport is rejected once with a viewport error and writes nothing. +fn a_completion_for_another_viewport_is_rejected() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("viewport"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // A slot/request bound to a different viewport must not fill this one. + app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the viewport mismatch is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. +fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { + let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); + assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); + assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); + assert!(validate_image(&image(99, 101), [100, 100]).is_err()); + assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); +} + +#[test] +/// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. +fn encoding_working_set_is_bounded_for_admitted_captures() { + assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + // The base64 reply itself is capped too, before the string exists. + let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); + assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); + // Retained requests plus one encoding working set are held to a hard peak. + assert!(check_peak_budget(0, [64, 48], false).is_ok()); + assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); +} + +#[test] +/// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. +fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { + let ctx = sized_context(egui::vec2(5000.0, 4000.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), "the error names the encoding budget: {reply}"); + assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); + + // The same capture as a file is admitted within the per-image and encoding budgets. + let path = temp_png("encoding-path"); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); + assert!(rx2.try_recv().is_err(), "the path form is admitted"); + assert_eq!(app.shots.requests.len(), 1); + app.shots.cancel_all(); +} + +#[test] +/// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. +fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { + let ctx = egui::Context::default(); + let mut app = test_app(); + // One oversized text event is refused before it is queued. + let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert!(app.synthetic.is_empty()); + + // The byte budget stops repeated maximum-size texts; the queue is left unchanged. + let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); + let mut refused_at = None; + for i in 0..64 { + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { panic!("ui.text returns immediately") }; + if reply["ok"] == false { + refused_at = Some((i, app.synthetic.len())); + break; + } + } + let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); + assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); + + // Event count: keys and moves use the same overall budget as pointer gestures. + app.synthetic.clear(); + let pos = egui::pos2(1.0, 1.0); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; + assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); + + app.synthetic.clear(); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index adf13bfea..95c39a1a3 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,7 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is -//! read at run time. +//! build.rs from contributors/contributors.json; nothing is read at run time. use std::cmp::Ordering; @@ -223,7 +222,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } ui.separator(); ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8d8e71665..92187d5c0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,31 @@ use serde_json::{Map, Value as Json, json}; use crate::SheetApp; use crate::theme; +mod about; +mod analysis; +mod charts; +mod command_search; +mod cond_format_rules; +mod data; +mod find; +mod format_cells; +mod insert_function; +mod names; +mod paste; +mod show; +mod spelling; +mod zoom; + +use analysis::toolpak_defaults; +use charts::select_chart; +use data::{abs, data_validation_fields, remove_duplicates_range, validation_defaults}; +use find::go_to_entries; +pub(crate) use names::insert_name; +use names::pasteable_names; +pub(crate) use show::build_params; +pub use show::show; +use zoom::zoom_preset; + #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, @@ -47,6 +72,8 @@ pub struct Dialog { focus_initialized: bool, /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). pick: Option, + /// The linked cell's initial display text; unchanged text must not replace a number or formula. + original_link_text: Option, } /// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute @@ -140,6 +167,7 @@ impl Dialog { self.values.insert(pick.key.into(), json!(ed.text)); } + /// Creates a command-backed form with its initial field values. fn form(name: &str, title: &str, command: &'static str, fields: Vec, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -158,10 +186,12 @@ impl Dialog { back: None, focus_initialized: false, pick: None, + original_link_text: None, } } - fn custom(name: &str, title: &str, defaults: Json) -> Dialog { + /// Creates a dialog whose body handles its own controls instead of a standard form. + pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog { Dialog { name: name.into(), title: title.into(), @@ -179,9 +209,11 @@ impl Dialog { back: None, focus_initialized: false, pick: None, + original_link_text: None, } } + /// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink. pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option { let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default(); let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default(); @@ -469,17 +501,30 @@ impl Dialog { json!({"url": url}), ) } - "insertLink" => Dialog::form( - "insertLink", - "Insert Hyperlink", - "insert.link", - vec![ - Text { key: "text", label: "Display:" }, - Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" }, - Text { key: "tooltip", label: "ScreenTip:" }, - ], - json!({"target": "https://"}), - ), + "insertLink" => { + let mut d = Dialog::form( + "insertLink", + "Insert Hyperlink", + "insert.link", + vec![ + Text { key: "text", label: "Display:" }, + Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" }, + Text { key: "tooltip", label: "ScreenTip:" }, + ], + json!({"target": "https://"}), + ); + if let Some(doc) = app.session.active() + && let Some(sheet) = doc.wb.active() + && let Some(link) = sheet.hyperlinks.get(&doc.selection.active) + { + let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active); + d.values.insert("target".into(), json!(link.target)); + d.values.insert("text".into(), json!(text)); + d.values.insert("tooltip".into(), json!(link.tooltip)); + d.original_link_text = Some(text); + } + d + } "sparkline" => Dialog::form( "sparkline", "Create Sparklines", @@ -920,11 +965,9 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), - "macros" => { - let mut d = Dialog::custom("macros", "Macros", json!({})); - d.result = app.session.run("view.macros", json!({})).ok(); - d - } + "macros" => crate::macro_ui::open_dialog(app, ¶ms), + "recordMacro" => return crate::macro_ui::open_record(app, ¶ms), + "macroEditor" => return crate::macro_editor::open(app, ¶ms), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -999,2400 +1042,6 @@ impl Dialog { } } -/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without -/// edits keeps it; a plain list rule when the cell has none. -fn validation_defaults(app: &SheetApp) -> Json { - let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); - let Some(doc) = app.session.active() else { return v }; - let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { - return v; - }; - // The enums serialize as their variant names; the command takes them in camelCase. - let camel = |x: Json| { - let name = x.as_str().unwrap_or_default(); - let mut chars = name.chars(); - chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() - }; - v["type"] = json!(camel(json!(dv.kind))); - v["operator"] = json!(camel(json!(dv.op))); - v["errorStyle"] = json!(camel(json!(dv.error_style))); - v["formula1"] = json!(dv.f1); - if let Some(f2) = &dv.f2 { - v["formula2"] = json!(f2); - } - v["ignoreBlank"] = json!(dv.allow_blank); - v["dropdown"] = json!(dv.in_cell_dropdown); - v["inputTitle"] = json!(dv.input_title); - v["inputMessage"] = json!(dv.input_message); - v["errorTitle"] = json!(dv.error_title); - v["errorMessage"] = json!(dv.error_message); - v -} - -/// The range Remove Duplicates starts with. A block selection is used as given; with only the -/// cursor placed, the table's data rows or the current region, as the command picks when it gets -/// no range (a single-cell range would remove nothing). -fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { - let Some(d) = app.session.active() else { return sel.to_string() }; - let Some(sh) = d.wb.active() else { return sel.to_string() }; - if !d.selection.current().is_single() { - return sel.to_string(); - } - let range = match sh.table_at(d.selection.active) { - Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), - None => gridcraft_engine::selection::current_region(sh, d.selection.active), - }; - range.a1() -} - -fn abs(r: &gridcraft_engine::core::RangeRef) -> String { - let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); - if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } -} - -/// The criterion controls in Excel's Settings tab depend on both Allow and Data. -/// `formula1` is the sole source for a list or custom rule; `formula2` is a -/// second bound only for Between and Not between. -fn data_validation_fields(kind: &str, operator: &str) -> Vec { - use Field::*; - let mut fields = vec![Choice { - key: "type", - label: "Allow", - options: vec![ - ("any", "Any value"), - ("whole", "Whole number"), - ("decimal", "Decimal"), - ("list", "List"), - ("date", "Date"), - ("time", "Time"), - ("textLength", "Text length"), - ("custom", "Custom"), - ], - }]; - let two_bounds = matches!(operator, "between" | "notBetween"); - match kind { - "list" => { - fields.push(Text { key: "formula1", label: "Source:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); - } - "custom" => { - fields.push(Text { key: "formula1", label: "Formula:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - "whole" | "decimal" | "date" | "time" | "textLength" => { - fields.push(Choice { - key: "operator", - label: "Data", - options: vec![ - ("between", "between"), - ("notBetween", "not between"), - ("equal", "equal to"), - ("notEqual", "not equal to"), - ("greater", "greater than"), - ("less", "less than"), - ("greaterOrEqual", "greater than or equal to"), - ("lessOrEqual", "less than or equal to"), - ], - }); - let lower = matches!(operator, "greater" | "greaterOrEqual"); - let (first, second) = match kind { - "date" => ( - if two_bounds || lower { - "Start date:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End date:" - } else { - "Date:" - }, - "End date:", - ), - "time" => ( - if two_bounds || lower { - "Start time:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End time:" - } else { - "Time:" - }, - "End time:", - ), - _ => ( - if two_bounds || lower { - "Minimum:" - } else if matches!(operator, "less" | "lessOrEqual") { - "Maximum:" - } else { - "Value:" - }, - "Maximum:", - ), - }; - fields.push(Text { key: "formula1", label: first }); - if two_bounds { - fields.push(Text { key: "formula2", label: second }); - } - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - _ => {} - } - fields.push(Text { key: "inputMessage", label: "Input message:" }); - fields.push(Choice { - key: "errorStyle", - label: "Error style", - options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], - }); - fields.push(Text { key: "errorMessage", label: "Error message:" }); - fields -} - -fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - d.fields = data_validation_fields(kind, operator); - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use Field::*; - let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); - d.fields = vec![ - Choice { - key: "action", - label: "Action", - options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], - }, - Text { key: "range", label: "List range:" }, - Text { key: "criteria", label: "Criteria range (optional):" }, - ]; - if copy { - d.fields.push(Text { key: "copyTo", label: "Copy to:" }); - } - d.fields.push(Check { key: "unique", label: "Unique rows only" }); - form_fields(ui, d); - // Constrain the button row: the shared right-to-left layout otherwise - // consumes the window's remaining height instead of fitting the buttons. - ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); -} - -/// Builds the command params from form values for dialogs that need translation. -/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. -pub(crate) fn build_params(d: &Dialog) -> Json { - let mut v = Json::Object(d.values.clone()); - match d.name.as_str() { - "advancedFilter" => { - if let Some(m) = v.as_object_mut() { - m.remove("action"); - if d.values.get("action").and_then(Json::as_str) != Some("copy") { - m.remove("copyTo"); - } - if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { - m.remove("criteria"); - } - } - } - "dataValidation" => { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - if let Some(m) = v.as_object_mut() { - if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { - m.remove("formula2"); - } - if matches!(kind, "any" | "list" | "custom") { - m.remove("operator"); - } - if kind == "any" { - m.remove("formula1"); - } - if kind != "list" { - m.remove("dropdown"); - } - } - } - "textToColumns" => { - let mut delims = Vec::new(); - for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { - if d.values.get(k).and_then(Json::as_bool) == Some(true) { - delims.push(json!(c)); - } - } - v["delimiters"] = Json::Array(delims); - } - "removeDuplicates" | "subtotal" => { - if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { - v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); - } - } - "allowEditRanges" => { - if let Some(m) = v.as_object_mut() { - // Blank fields mean "unchanged"; the check box clears a password. - m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); - if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { - m.insert("password".into(), json!("")); - } - } - } - "cfQuick" => { - let mut rule = Map::new(); - for (k, val) in &d.values { - rule.insert(k.clone(), val.clone()); - } - v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { - v["sheet"] = json!(pick.sheet.as_str()); - v["range"] = json!(pick.applies_to); - } - } - "series" => { - if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { - v["stop"] = json!(s); - } else if let Some(m) = v.as_object_mut() { - m.remove("stop"); - } - } - "defineName" => { - if let Some(m) = v.as_object_mut() { - for key in ["scope", "oldScope"] { - if m.get(key).and_then(Json::as_str) == Some("Workbook") { - m.remove(key); - } - } - } - } - _ => {} - } - v -} - -/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. -pub fn show(app: &mut SheetApp, ctx: &egui::Context) { - let Some(mut d) = app.dialog.take() else { return }; - let home = app.ui_window(); - if d.name == "pasteSpecial" { - paste_special_shortcuts(&mut d, ctx); - } - let mut open = true; - let mut win_open = true; - let mut confirm = false; - let width = match d.name.as_str() { - "formatCells" => 560.0, - "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, - "recommendedCharts" => 600.0, - "analysisToolPak" => 420.0, - "solver" => 460.0, - "zoom" => 220.0, - "pasteSpecial" => 480.0, - _ => 380.0, - }; - let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::unique("dialog").with(&d.name)) - .collapsible(false) - .resizable(false) - .default_width(width) - // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) - .open(&mut win_open); - let window = match d.name.as_str() { - "insertPicture" => window.default_height(240.0), - "advancedFilter" => window.default_height(0.0), - _ => window, - }; - window.show(ctx, |ui| { - ui.set_min_width(width - 20.0); - match d.name.as_str() { - "formatCells" => format_cells(app, ui, &mut d, &mut confirm), - "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), - "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), - "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), - "find" => find(app, ui, &mut d), - "goTo" => go_to(app, ui, &mut d, &mut confirm), - "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), - "sort" => sort(app, ui, &mut d, &mut confirm), - "nameManager" => name_manager(app, ui, &mut d, &mut open), - "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), - "manageRules" => manage_rules(app, ui), - "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), - "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), - "commandSearch" => command_search(app, ui, &mut d, &mut confirm), - "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), - "solverResults" => solver_results(app, ui, &mut d, &mut open), - "spelling" => spelling(app, ui, &mut d, &mut open), - "agents" => { - ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); - ui.add_space(4.0); - ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); - ui.add_space(4.0); - ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); - } - "about" => about(ui, &mut d), - "journal" => { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (id, p) in app.session.journal.iter().rev().take(500) { - ui.monospace(format!("{id} {p}")); - } - }); - } - "macros" => macros(app, ui, &mut d), - "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { - let r = if d.name == "comments" { - app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) - } else { - d.result.clone() - }; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); - }); - } - "insertPicture" => { - // A private inbox dies with this dialog; late browser results - // cannot affect a later picture choice or another workbook. - let arrived = d.picture_inbox.as_ref().and_then(|inbox| { - inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() - }); - if let Some((name, bytes)) = arrived { - d.picture_inbox = None; - if bytes.len() > 16 * 1024 * 1024 { - app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); - } else { - d.values.remove("path"); - d.values.insert("alt".into(), json!(name)); - d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); - } - } - if app.services.pick_picture_async.is_some() { - let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); - ui.label(name); - } else { - ui.label("Picture file path (PNG or JPEG):"); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut path).changed() { - d.values.remove("base64"); - d.values.insert("path".into(), json!(path)); - } - } - if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { - if let Some(start) = &app.services.pick_picture_async { - let inbox = crate::Inbox::default(); - start(inbox.clone()); - d.picture_inbox = Some(inbox); - } else if let Some(pick) = &app.services.pick_picture - && let Some(path) = pick() - { - d.values.remove("base64"); - let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - d.values.insert("alt".into(), json!(alt)); - d.values.insert("path".into(), json!(path)); - } - } - ui.label("Description (optional):"); - let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut alt).changed() { - d.values.insert("alt".into(), json!(alt)); - } - if d.values.get("placement").and_then(Json::as_str) == Some("cell") { - ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); - } - ok_cancel(ui, &mut confirm, &mut open); - } - "pickList" => { - let items: Vec = app - .session - .active() - .and_then(|doc| { - let sh = doc.wb.active()?; - let a = doc.selection.active; - let mut seen = std::collections::BTreeSet::new(); - let mut r = a.row; - while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { - r -= 1; - seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); - } - Some(seen.into_iter().collect()) - }) - .unwrap_or_default(); - for it in items { - if ui.button(&it).clicked() { - app.run_or_alert("cell.set", json!({"input": it})); - open = false; - } - } - } - "saveCopy" => { - let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); - ui.label(format!("Save a copy as .{fmt}")); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - ui.text_edit_singleline(&mut path); - d.values.insert("path".into(), json!(path.clone())); - ok_cancel(ui, &mut confirm, &mut open); - if confirm { - // A bare name has no folder: written as typed it would land in the - // process's working directory (`/` from the Finder, the install folder on - // Windows), never where the user looks. The picker chooses the folder. - let pick = app.services.pick_save.as_ref(); - let target = if path.is_empty() { - pick.and_then(|f| f(&format!("Copy.{fmt}"))) - } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { - f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) - } else { - Some(path) - }; - if let Some(t) = target { - let id = match fmt.as_str() { - "csv" => "file.exportCsv", - "html" => "file.exportHtml", - _ => "file.saveAs", - }; - app.run_or_alert(id, json!({"path": t})); - } - } - } - "saveChanges" => { - ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); - let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); - ui.horizontal(|ui| { - for (label, answer) in - [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] - { - if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { - open = false; - } - } - }); - } - "start" => { - ui.heading("Start a new workbook"); - ui.horizontal_wrapped(|ui| { - if ui.button("Blank workbook").clicked() { - app.run_or_alert("file.new", json!({})); - open = false; - } - for (n, t) in gridcraft_engine::sample::SAMPLES { - if ui.button(*t).clicked() { - app.run_or_alert("file.new", json!({"sample": n})); - open = false; - } - } - }); - ui.separator(); - ui.label(egui::RichText::new("Recent").strong()); - for path in app.ui.recent.clone() { - if ui.link(&path).clicked() { - app.open_path(&path); - open = false; - } - } - if ui.button("Open…").clicked() { - app.open_dialog("open", json!({})); - open = false; - } - } - _ => form(ui, &mut d, &mut confirm, &mut open), - } - }); - // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since - // the native shell has no browser integration of its own. - if let Some(open_url) = &app.services.open_url { - let urls: Vec = ctx.output_mut(|o| { - let mut urls = vec![]; - o.commands.retain(|c| match c { - egui::OutputCommand::OpenUrl(u) => { - urls.push(u.url.clone()); - false - } - _ => true, - }); - urls - }); - for u in urls { - open_url(&u); - } - } - if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { - 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())); - confirm = false; - } - if confirm && let Some(cmd) = d.command { - let params = build_params(&d); - match app.run(cmd, params) { - Ok(r) => { - open = false; - if d.name == "solver" { - app.open_dialog("solverResults", r); - } else if d.name == "openPassword" { - let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); - app.after_open(path.as_deref(), &r); - } - } - // Data Analysis asks before replacing data in its output range. - Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { - d.values.insert("askOverwrite".into(), json!(true)); - } - // A wrong password: say so, clear the field and stay open for another try. - Err(e) if d.name == "openPassword" => { - d.values.insert("password".into(), json!("")); - app.message = Some((d.title.clone(), crate::clean_error(&e))); - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } else if confirm { - open = false; - } - if app.ui_window() != home { - // A command run from the dialog switched windows: whatever it opened shows in the new - // window, and this dialog (or the one it returns to) stays in its own. - let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; - if let Some(keep) = keep { - app.return_dialog(home, keep); - } - return; - } - if let Some(child) = app.dialog.as_mut() { - // The body opened another dialog (Name Manager › New…). It takes over; this one waits - // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To - // hands over to Go To Special for good, as in Excel. - if open && d.name != "goTo" { - child.back = Some(Box::new(d)); - } else if child.back.is_none() { - child.back = d.back.take(); - } - return; - } - if open { - app.dialog = Some(d); - } else if let Some(back) = d.back.take() { - app.dialog = Some(*back); - } -} - -/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip -/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options -/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. -fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = app.ui.language; - let t = theme::Tokens::get(ui.ctx()); - let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d - .fields - .iter() - .filter_map(|f| match f { - Field::Choice { key, label, options } => Some((*key, *label, options.clone())), - _ => None, - }) - .collect(); - for (key, label, options) in choices { - ui.add_space(4.0); - ui.label(egui::RichText::new(lang.tr(label)).strong()); - egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { - ui.set_min_width(ui.available_width()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - // Column by column, as Excel fills them. - let rows = options.len().div_ceil(2); - egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for r in 0..rows { - for c in 0..2 { - if let Some((value, text)) = options.get(c * rows + r) { - if ui.radio(cur == *value, lang.tr(text)).clicked() { - d.set_value(key, json!(value)); - } - } else { - ui.label(""); - } - } - ui.end_row(); - } - }); - }); - } - ui.add_space(8.0); - // In the same columns as the options above. - egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { - egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - ui.end_row(); - }); - }); - ui.add_space(8.0); - ui.separator(); - ui.horizontal(|ui| { - // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { - d.values.insert("what".into(), json!("all")); - d.values.insert("operation".into(), json!("none")); - d.values.insert("link".into(), json!(true)); - *confirm = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }); - }); -} - -/// Paste Special's legacy mnemonics select options; Enter still performs the paste. -fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { - if ctx.text_edit_focused() { - return; - } - let right_alt = ctx.input(|i| { - i.key_down(Key::AltRight) - || i.events.iter().any(|event| { - matches!(event, egui::Event::Key { key: Key::AltRight, .. }) - || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) - }) - }); - let mac = ctx.os() == egui::os::OperatingSystem::Mac; - let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); - let mnemonic = |key| match key { - Key::T => Some('t'), - Key::V => Some('v'), - Key::F => Some('f'), - Key::C => Some('c'), - Key::E => Some('e'), - _ => None, - }; - let mut text_to_consume = Vec::new(); - for event in ctx.input(|i| i.events.clone()) { - let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; - if !allowed(modifiers) { - continue; - } - let Some(letter) = mnemonic(key) else { continue }; - text_to_consume.push(letter); - if repeat { - continue; - } - if key == Key::E { - let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); - d.values.insert("transpose".into(), json!(!transpose)); - } else { - let what = match key { - Key::T => "formats", - Key::V => "values", - Key::F => "formulas", - _ => "comments", - }; - d.values.insert("what".into(), json!(what)); - } - } - // Browsers can deliver Text before or after Key. Act only on Key, then remove - // its matching text so one physical press cannot toggle Transpose twice. - ctx.input_mut(|i| { - i.events.retain(|event| match event { - egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), - egui::Event::Text(text) if text.len() == 1 => { - let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); - if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { - text_to_consume.remove(index); - false - } else { - true - } - } - _ => true, - }); - }); -} - -fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { - ui.add_space(8.0); - // A row as tall as the buttons: a right-to-left layout straight in the window takes all the - // height it is offered, which stretched every form dialog to the screen's height. - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let lang = crate::i18n::current(ui.ctx()); - let t = crate::theme::Tokens::get(ui.ctx()); - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }) - }); -} - -fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -/// Gives keyboard focus to the first text or number field the first time a form is drawn. A -/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and -/// 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) { - if !d.focus_first_field { - return; - } - if !r.has_focus() { - r.request_focus(); - return; - } - // Focus has landed, and the field is now a text box: select its whole value so typing - // replaces it (the field's own select-on-focus fired before it was a text box). - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); - state.cursor.set_char_range(Some(all)); - state.store(&r.ctx, r.id); - d.focus_first_field = false; -} - -/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a -/// reference in it (the click took the focus), its caret after the reference. -fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { - if typed { - pick.point = None; - } - if pick.refocus { - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); - state.store(&r.ctx, r.id); - r.request_focus(); - pick.refocus = false; - } else if r.has_focus() - && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) - { - pick.caret = range.primary.index.into(); - } -} - -/// Draws a dialog's `fields`, editing its values. -fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { - // Field labels, choices and notes are English source strings, translated as they are drawn. - let lang = crate::i18n::current(ui.ctx()); - egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - for f in d.fields.clone() { - match f { - Field::Text { key, label } => { - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d - .values - .get(key) - .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) - .unwrap_or_default(); - let multiline = key == "text"; - let r = if multiline { - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) - } else { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) - }; - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(s)); - } - if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { - track_pick(pick, &r, r.changed()); - } - }); - } - Field::Password { key, label } => { - // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); - if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() - { - d.values.insert(key.into(), json!(s)); - } - ui.checkbox(&mut d.show_password, lang.tr("Show password")); - }); - } - Field::Number { key, label } => { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(label)); - let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); - // Typed values count straight away: otherwise Enter or OK in the same frame - // confirms the old value, because a number field only commits when it loses focus. - let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(n)); - } - }); - } - Field::Choice { key, label, options } => { - ui.vertical(|ui| { - let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if d.name == "dataValidation" { - let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); - egui::ComboBox::from_id_salt(("dataValidation", key)) - .selected_text(selected) - .width(300.0) - .show_ui(ui, |ui| { - for (val, text) in options { - if ui.selectable_label(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - }) - .response - .labelled_by(field_label.id); - } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - } - }); - } - Field::Check { key, label } => { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - Field::Note(t) => { - // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a - // context separator, so such a note is shown as it is. - let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; - ui.label(egui::RichText::new(text).small()); - } - } - ui.end_row(); - } - }); -} - -/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits -/// are compiled in (`crate::credits`, docs/contributors.md). -fn about(ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["About", "Contributors", "Models"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - match d.tab.as_str() { - "Contributors" => crate::credits::contributors_ui(ui), - "Models" => crate::credits::models_ui(ui), - _ => { - ui.heading("GridCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.add_space(4.0); - ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); - ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); - ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); - } - } -} - -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} - -/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. -fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn - // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a - // different word in some languages. - let lang = app.ui.language; - ui.horizontal(|ui| { - for (tab, key) in [ - ("Number", "Number"), - ("Alignment", "Alignment"), - ("Font", "Font"), - ("Border", "Border"), - ("Fill", "Format Cells|Fill"), - ("Protection", "Protection"), - ] { - if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); - match d.tab.as_str() { - "Number" => { - let mut code = st.num_fmt.as_str().to_string(); - ui.columns(2, |cols| { - cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); - for name in - ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] - { - let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { - code = c; - } - } - cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); - let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); - cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); - cols[1].add_space(8.0); - let label = cols[1].label(lang.tr("Type (custom format code):")); - cols[1].text_edit_singleline(&mut code).labelled_by(label.id); - for c in [ - "0", - "0.00", - "#,##0", - "#,##0.00", - "#,##0;[Red]-#,##0", - "0%", - "0.00%", - "0.00E+00", - "# ?/?", - "m/d/yyyy", - "d-mmm-yy", - "mmmm d, yyyy", - "h:mm AM/PM", - "h:mm:ss", - "[h]:mm:ss", - "@", - ] { - if cols[1].small_button(c).clicked() { - code = c.to_string(); - } - } - }); - st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); - } - "Alignment" => { - use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); - ui.horizontal_wrapped(|ui| { - for (h, n) in [ - (HAlign::General, "General"), - (HAlign::Left, "Left"), - (HAlign::Center, "Center"), - (HAlign::Right, "Right"), - (HAlign::Fill, "Fill"), - (HAlign::Justify, "Justify"), - (HAlign::CenterAcross, "Center Across Selection"), - (HAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.h, h, lang.tr(n)); - } - }); - ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); - ui.horizontal_wrapped(|ui| { - for (v, n) in [ - (VAlign::Top, "Top"), - (VAlign::Center, "Center"), - (VAlign::Bottom, "Bottom"), - (VAlign::Justify, "Justify"), - (VAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.v, v, lang.tr(n)); - } - }); - let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); - st.align.indent = indent as u8; - let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); - st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); - ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); - } - "Font" => { - egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { - for f in crate::fonts::families() { - ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); - } - }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); - ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, lang.tr("Bold")); - ui.checkbox(&mut st.font.italic, lang.tr("Italic")); - ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); - }); - use gridcraft_engine::model::{Underline, VertAlign}; - ui.horizontal(|ui| { - for (u, n) in [ - (Underline::None, "None"), - (Underline::Single, "Single"), - (Underline::Double, "Double"), - (Underline::SingleAccounting, "Single Accounting"), - (Underline::DoubleAccounting, "Double Accounting"), - ] { - ui.radio_value(&mut st.font.underline, u, lang.tr(n)); - } - }); - ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); - }); - ui.label(lang.tr("Color:")); - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { - st.font.color = - if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; - } - } - "Border" => { - use gridcraft_engine::model::{BorderLine, BorderStyle}; - let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; - ui.horizontal(|ui| { - if ui.button(lang.tr("None")).clicked() { - st.borders = Default::default(); - } - if ui.button(lang.tr("Outline")).clicked() { - st.borders.top = line; - st.borders.bottom = line; - st.borders.left = line; - st.borders.right = line; - } - }); - for (n, slot) in [ - ("Top", &mut st.borders.top), - ("Bottom", &mut st.borders.bottom), - ("Left", &mut st.borders.left), - ("Right", &mut st.borders.right), - ("Diagonal ↘", &mut st.borders.diag_down), - ("Diagonal ↗", &mut st.borders.diag_up), - ] { - let n = lang.tr(n); - ui.horizontal(|ui| { - ui.label(format!("{n}:")); - for (s, label) in [ - (BorderStyle::None, "none"), - (BorderStyle::Thin, "thin"), - (BorderStyle::Medium, "medium"), - (BorderStyle::Thick, "thick"), - (BorderStyle::Dashed, "dashed"), - (BorderStyle::Dotted, "dotted"), - (BorderStyle::Double, "double"), - ] { - let label = lang.tr(label); - let response = ui.selectable_label(slot.style == s, label); - let selected = slot.style == s; - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); - if response.clicked() { - slot.style = s; - } - } - }); - } - let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); - crate::border_preview::sample(ui, &st.borders, &theme); - } - "Fill" => { - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label(lang.tr("Background Color:")); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { - st.fill = if c == "none" { - Default::default() - } else { - gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) - }; - } - } - "Protection" => { - ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); - ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); - ui.label( - egui::RichText::new( - lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), - ) - .small(), - ); - } - _ => {} - } - if let Ok(v) = serde_json::to_value(&st) { - d.values.insert("style".into(), v); - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(style) = d.values.get("style").cloned() { - app.run_or_alert("home.formatCells", json!({"style": style})); - } - if !open { - app.dialog = None; - *confirm = true; // closes - } -} - -/// 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). -fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - 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(); - }); - 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))); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - d.list_index = i; - *confirm = true; - } - } - }); - 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()); - } - 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); - let cur = app.editor.as_ref().map(|e| e.text.clone()); - let text = match cur { - Some(t) if t.starts_with('=') => format!("{t}{n}("), - _ => format!("={n}("), - }; - app.begin_edit(Some(text), false); - } - if !open { - *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, - } -} - -/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. -fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["Find", "Replace"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.into(); - } - } - }); - let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); - let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label("Find what:"); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); - // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. - if !d.values.contains_key("focused") { - r.request_focus(); - d.values.insert("focused".into(), json!(true)); - } - }); - if d.tab == "Replace" { - ui.horizontal(|ui| { - let label = ui.label("Replace with:"); - ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); - }); - } - let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); - let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); - let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); - ui.horizontal(|ui| { - ui.checkbox(&mut case, "Match case"); - ui.checkbox(&mut whole, "Match entire cell contents"); - ui.checkbox(&mut wb, "Within workbook"); - }); - d.values.insert("what".into(), json!(what)); - d.values.insert("with".into(), json!(with)); - d.values.insert("matchCase".into(), json!(case)); - d.values.insert("wholeCell".into(), json!(whole)); - d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); - ui.horizontal(|ui| { - if d.tab == "Replace" { - if ui.button("Replace All").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(true); - match app.run("edit.replace", p) { - Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), - Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), - } - } - if ui.button("Replace").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(false); - let _ = app.run("edit.replace", p); - let _ = app.run("edit.find", base.clone()); - } - } - if ui.button("Find All").clicked() { - let mut p = base.clone(); - p["all"] = json!(true); - d.result = app.run("edit.find", p).ok(); - } - if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - if let Err(e) = app.run("edit.find", base.clone()) { - app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); - } - app.grid.ensure_visible = true; - } - }); - if let Some(r) = &d.result - && let Some(list) = r["results"].as_array() - { - ui.separator(); - ui.label(format!("{} cell(s) found", list.len())); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for item in list { - let label = format!( - "{} {} {}", - item["sheet"].as_str().unwrap_or(""), - item["cell"].as_str().unwrap_or(""), - item["value"].as_str().unwrap_or("") - ); - if ui.selectable_label(false, label).clicked() { - if let Some(s) = item["sheet"].as_str() { - let _ = app.run("sheet.activate", json!({"sheet": s})); - } - let _ = app.run("selection.set", json!({"cell": item["cell"]})); - app.grid.ensure_visible = true; - } - } - }); - } -} - -/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. -const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; - -/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. -fn zoom_preset(percent: f64) -> &'static str { - ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") -} - -/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and -/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage -/// selects Custom; Fit selection zooms so the selected cells fill the grid. -fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); - ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); - for (val, text) in ZOOM_PRESETS { - if ui.radio(cur == val, text).clicked() { - d.set_value("preset", json!(val)); - } - } - if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { - d.set_value("preset", json!("fit")); - } - ui.horizontal(|ui| { - if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { - d.set_value("preset", json!("custom")); - } - // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. - let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; - if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { - d.set_value("percent", json!(percent)); - } - ui.label("%"); - }); - ok_cancel(ui, confirm, open); -} - -/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to -/// before, newest first. -fn go_to_entries(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| { - let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); - v.extend(doc.go_to_recent.iter().cloned()); - v - }) - .unwrap_or_default() -} - -/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. -fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let entries: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { - for n in entries.get(rows).unwrap_or_default() { - let resp = ui.selectable_label(current == *n, n); - if resp.clicked() || resp.double_clicked() { - d.values.insert("reference".into(), json!(n)); - } - if resp.double_clicked() { - *confirm = true; - } - } - }); - let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label(lang.tr("Reference:")); - ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); - }); - d.values.insert("reference".into(), json!(r.clone())); - if ui.button("Special…").clicked() { - app.open_dialog("goToSpecial", json!({})); - return; - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); - app.grid.ensure_visible = true; - } - if !open { - *confirm = true; - } -} - -/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. -fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); - ui.checkbox(&mut header, "My list has headers"); - d.values.insert("header".into(), json!(header)); - let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); - // Columns of the region around the active cell, labelled with their header (or first value). - let columns: Vec<(String, String)> = app - .session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - let region = gridcraft_engine::selection::current_region(sh, d.selection.active); - Some( - (region.start.col..=region.end.col) - .map(|c| { - let letter = gridcraft_engine::core::col_to_letters(c); - // The region starts at the header row when one is present, so the - // label always comes from start.row: header text with headers on, - // first data value with them off. - let label_row = region.start.row; - let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); - let text: String = text.chars().take(24).collect(); - (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) - }) - .collect(), - ) - }) - .unwrap_or_default(); - let mut remove = None; - for (i, lv) in levels.iter_mut().enumerate() { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); - let mut col = lv["column"].as_str().unwrap_or("A").to_string(); - if columns.is_empty() { - ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); - } else { - egui::ComboBox::from_id_salt(("sort_col", i)) - .selected_text(col.clone()) - .show_ui(ui, |ui| { - for (letter, label) in &columns { - ui.selectable_value(&mut col, letter.clone(), label.as_str()); - } - }) - .response - .labelled_by(label.id); - } - lv["column"] = json!(col.to_ascii_uppercase()); - let mut by = lv["by"].as_str().unwrap_or("values").to_string(); - egui::ComboBox::from_id_salt(("by", i)) - .selected_text(match by.as_str() { - "cellColor" => "Cell Color", - "fontColor" => "Font Color", - _ => "Cell Values", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut by, "values".into(), "Cell Values"); - ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); - ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); - }) - .response - .accessible_name(lang.tr("Sort On")); - lv["by"] = json!(by); - let mut desc = lv["order"].as_str() == Some("desc"); - ui.radio_value(&mut desc, false, "A to Z"); - ui.radio_value(&mut desc, true, "Z to A"); - lv["order"] = json!(if desc { "desc" } else { "asc" }); - if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove - && levels.len() > 1 - { - levels.remove(i); - } - if ui.button("+ Add Level").clicked() { - let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); - let next = columns - .iter() - .map(|(l, _)| l) - .find(|l| !used.contains(l)) - .cloned() - .or_else(|| columns.last().map(|(l, _)| l.clone())) - .unwrap_or_else(|| "B".into()); - levels.push(json!({"column": next, "order": "asc"})); - } - d.values.insert("levels".into(), Json::Array(levels.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); - } - if !open { - *confirm = true; - } -} - -fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); - let names = list.as_array().cloned().unwrap_or_default(); - let selected = names.get(d.list_index).cloned(); - let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - if ui.button("New…").clicked() { - app.open_dialog("defineName", json!({})); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() - && let Some(n) = &selected - { - app.open_dialog( - "defineName", - json!({ - "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), - "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), - }), - ); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() - && let Some(n) = &selected - { - app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); - } - }); - ui.add_space(4.0); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { - ui.strong("Name"); - ui.strong("Value"); - ui.strong("Refers To"); - ui.strong("Scope"); - ui.end_row(); - if names.is_empty() { - ui.label(egui::RichText::new("No names defined").italics()); - ui.end_row(); - } - for (i, n) in names.iter().enumerate() { - if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { - d.list_index = i; - } - ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); - ui.label(field(n, "refersTo")); - ui.label(field(n, "scope")); - ui.end_row(); - } - }); - }); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(" Close ").clicked() { - *open = false; - } - }); -} - -/// The names Paste Names offers in the active workbook. -fn pasteable_names(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) - .unwrap_or_default() -} - -/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it -/// starts one in the active cell with `=` and the name. -pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { - match app.editor.as_mut() { - Some(ed) => ed.insert_at_caret(name), - None => app.begin_edit(Some(format!("={name}")), false), - } -} - -/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the -/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it -/// refers to from the active cell. -fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let names: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - // The `name` value is the selection, so `ui.dialog.set` can choose one too. - if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { - d.list_index = i; - } - let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); - let last = names.len().saturating_sub(1); - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - d.list_index = d.list_index.min(last); - ui.label(app.ui.language.tr("Paste name")); - if names.is_empty() { - ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); - } - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - let step = row_height + ui.spacing().item_spacing.y; - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { - let top = ui.cursor().top() - rows.start as f32 * step; - for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { - let r = ui.selectable_label(i == d.list_index, n); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - *confirm = true; - } - } - // The arrow keys can move the selection onto a row that isn't laid out. - if down || up { - let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); - if !ui.clip_rect().contains_rect(rect) { - ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); - } - } - }); - ui.add_space(4.0); - // Paste List is disabled while a cell is being edited. - if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { - app.run_or_alert("formulas.pasteName", json!({"list": true})); - *open = false; - return; - } - if let Some(n) = names.get(d.list_index) { - d.values.insert("name".into(), json!(n)); - } - ok_cancel(ui, confirm, open); -} - -/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 21] = [ - ("column", "", "Clustered Column"), - ("column", "stacked", "Stacked Column"), - ("column", "stacked100", "100% Stacked Column"), - ("bar", "", "Clustered Bar"), - ("bar", "stacked", "Stacked Bar"), - ("bar", "stacked100", "100% Stacked Bar"), - ("line", "", "Line"), - ("line", "markers", "Line with Markers"), - ("line", "stacked", "Stacked Line"), - ("pie", "", "Pie"), - ("doughnut", "", "Doughnut"), - ("area", "", "Area"), - ("area", "stacked", "Stacked Area"), - ("area", "stacked100", "100% Stacked Area"), - ("scatter", "", "Scatter"), - ("scatter", "lines", "Scatter with Lines"), - ("radar", "", "Radar"), - ("histogram", "", "Histogram"), - ("waterfall", "", "Waterfall"), - ("funnel", "", "Funnel"), - ("combo", "", "Combo"), -]; - -/// The chart types listed on the dialog's current tab. -fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { - if d.tab == "All Charts" { - return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); - } - let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - d.result - .as_ref() - .and_then(Json::as_array) - .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) - .unwrap_or_default() -} - -/// Selects entry `i` of the current tab: OK inserts it. -fn select_chart(d: &mut Dialog, i: usize) { - let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; - d.list_index = i; - d.values.insert("type".into(), json!(t)); - d.values.insert("subtype".into(), json!(sub)); -} - -fn chart_icon(t: &str) -> Icon { - match t { - "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, - "line" | "area" | "radar" => Icon::ChartLine, - "pie" | "doughnut" => Icon::ChartPie, - "scatter" | "bubble" => Icon::ChartScatter, - _ => Icon::Chart, - } -} - -fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - ui.horizontal(|ui| { - for tab in ["Recommended Charts", "All Charts"] { - if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { - d.tab = tab.to_string(); - select_chart(d, 0); - } - } - }); - ui.separator(); - let choices = chart_choices(d); - let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(190.0); - egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { - for (i, (kind, _, label)) in choices.iter().enumerate() { - let resp = ui - .horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); - icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); - ui.selectable_label(i == d.list_index, label.as_str()) - }) - .inner; - if resp.clicked() { - select_chart(d, i); - } - if resp.double_clicked() { - select_chart(d, i); - *confirm = true; - } - } - }); - }); - ui.add_space(12.0); - ui.vertical(|ui| { - let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); - ui.strong(label); - let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); - ui.painter().rect_filled(rect, 2.0, Color32::WHITE); - ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - if let Some(doc) = app.session.active() - && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) - { - crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); - } - }); - }); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let params = - json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - match app.run("insert.chart", params) { - Ok(r) => { - // The new chart is selected, as after inserting any object. - app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); - *open = false; - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. -fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { - let label = ui.label(label); - let mut v = text(d, key); - if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { - d.values.insert(key.into(), json!(v)); - } - }; - edit(ui, d, "range", "Chart data range:"); - ui.add_space(6.0); - ui.strong("Legend Entries (Series)"); - let names: Vec = - d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); - let mut remove: Vec = - d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - let kept = names.len().saturating_sub(remove.len()); - egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - let i = i as u64; - let removed = remove.contains(&i); - ui.horizontal(|ui| { - let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; - ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); - if removed { - if ui.small_button("Keep").clicked() { - remove.retain(|x| *x != i); - } - } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { - remove.push(i); - } - }); - } - }); - d.values.insert("remove".into(), json!(remove)); - ui.add_space(6.0); - edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let mut params = serde_json::Map::new(); - params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); - let range_changed = text(d, "range") != text(d, "range0"); - if range_changed { - params.insert("range".into(), json!(text(d, "range"))); - } else if !remove.is_empty() { - // A new range rebuilds the series; removals only apply to the current ones. - params.insert("removeSeries".into(), json!(remove)); - } - if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { - params.insert("categories".into(), json!(text(d, "categories"))); - } - if params.len() == 1 { - *open = false; - return; - } - match app.run("chart.selectData", Json::Object(params)) { - Ok(_) => *open = false, - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { - let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); - let rules = list["rules"].as_array().cloned().unwrap_or_default(); - if rules.is_empty() { - ui.label("There are no conditional formatting rules on this sheet."); - } - for (i, r) in rules.iter().enumerate() { - ui.horizontal(|ui| { - let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); - ui.label(format!( - "{}. {kind} applies to {}", - i + 1, - r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() - )); - if ui.small_button("▲").clicked() { - app.run_or_alert("home.manageRules", json!({"moveUp": i})); - } - if ui.small_button("▼").clicked() { - app.run_or_alert("home.manageRules", json!({"moveDown": i})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("home.manageRules", json!({"delete": i})); - } - }); - } -} - -fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { - ( - i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), - i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), - i.consume_key(egui::Modifiers::CTRL, Key::Home), - i.consume_key(egui::Modifiers::CTRL, Key::End), - i.consume_key(egui::Modifiers::NONE, Key::PageUp), - i.consume_key(egui::Modifiers::NONE, Key::PageDown), - ) - }); - let width = ui.available_width(); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); - - if !d.focus_initialized { - r.request_focus(); - d.focus_initialized = true; - } - - let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String)> = app - .session - .commands() - .into_iter() - .filter(|c| c.enabled) - .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) - .take(40) - .collect(); - - let last = cmds.len().saturating_sub(1); - - let row_h = ui.spacing().interact_size.y.max(20.0); - let row_step = row_h + ui.spacing().item_spacing.y; - - const LIST_HEIGHT: f32 = 320.0; - - let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); - - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - if pgdn { - d.list_index = (d.list_index + page_size).min(last); - } - if pgup { - d.list_index = d.list_index.saturating_sub(page_size); - } - if home { - d.list_index = 0; - } - if end { - d.list_index = last; - } - - d.list_index = d.list_index.min(last); - - egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { - let scroll_area_width = ui.available_width(); - for (i, (id, label, menu)) in cmds.iter().enumerate() { - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); - - if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { - r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); - } - - if r.clicked() { - d.list_index = i; - *confirm = true; - } - - r.on_hover_text(id); - } - }); - if ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { - let id = id.clone(); - app.dialog = None; - app.run_or_alert(&id, json!({})); - } -} - -fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); - egui::Grid::new("gs").show(ui, |ui| { - ui.label("Set cell:"); - ui.text_edit_singleline(&mut set); - ui.end_row(); - ui.label("To value:"); - ui.text_edit_singleline(&mut to); - ui.end_row(); - ui.label("By changing cell:"); - ui.text_edit_singleline(&mut changing); - ui.end_row(); - }); - d.values.insert("set".into(), json!(set.clone())); - d.values.insert("to".into(), json!(to.clone())); - d.values.insert("changing".into(), json!(changing.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { - Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), - Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), - } - } - if !open { - *confirm = true; - } -} - -/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). -fn toolpak_fields(tool: &str) -> Vec { - use Field::*; - let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; - let labels = || Check { key: "labels", label: "Labels in first row" }; - let range = || Text { key: "range", label: "Input Range:" }; - let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; - match tool { - "descriptive" => vec![ - range(), - grouped(), - labels(), - Check { key: "summary", label: "Summary statistics" }, - Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, - Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, - Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, - ], - "histogram" => vec![ - range(), - Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, - labels(), - Check { key: "pareto", label: "Pareto (sorted histogram)" }, - Check { key: "cumulative", label: "Cumulative Percentage" }, - ], - "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], - "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], - "regression" => vec![ - Text { key: "inputY", label: "Input Y Range:" }, - Text { key: "inputX", label: "Input X Range:" }, - labels(), - Check { key: "constantZero", label: "Constant is Zero" }, - Text { key: "confidence", label: "Confidence Level (%):" }, - Check { key: "residuals", label: "Residuals" }, - Check { key: "standardizedResiduals", label: "Standardized Residuals" }, - Check { key: "normalProbability", label: "Normal Probability" }, - ], - "movingAverage" => { - vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "exponentialSmoothing" => { - vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "fTest" => { - let mut v = two(); - v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); - v - } - _ => { - let mut v = two(); - v.extend([ - Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, - labels(), - Text { key: "alpha", label: "Alpha:" }, - ]); - v - } - } -} - -/// The starting values of a tool's form: the selection as its input, a new sheet as output. -fn toolpak_defaults(sel: &str, tool: &str) -> Map { - let mut v = json!({ - "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", - "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", - "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", - "outputTo": "newSheet", "output": "", "newSheet": "", - }); - if tool == "regression" { - v["confidence"] = json!("95"); - v["inputY"] = json!(sel); - } - v.as_object().cloned().unwrap_or_default() -} - -/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` -/// (through the dialog's command); a report that would replace data asks first. -fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use gridcraft_engine::cmd::analysis::TOOLS; - if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { - ui.label("The output range already holds data. Replace it with the report?"); - ui.horizontal(|ui| { - if ui.button("Replace").clicked() { - d.values.remove("askOverwrite"); - d.values.insert("overwrite".into(), json!(true)); - *confirm = true; - } - if ui.button("Cancel").clicked() { - d.values.remove("askOverwrite"); - } - }); - return; - } - let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); - let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { - ui.label(egui::RichText::new("Analysis Tools").strong()); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - let mut chosen = false; - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (id, label) in TOOLS { - let r = ui.selectable_label(pick == *id, *label); - if r.clicked() { - d.values.insert("pick".into(), json!(id)); - } - if r.double_clicked() { - chosen = true; - } - } - }); - let mut ok = false; - ok_cancel(ui, &mut ok, open); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { - let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); - d.values = toolpak_defaults(&sel, &pick); - } - return; - }; - d.title = label.to_string(); - d.fields = toolpak_fields(&tool); - form_fields(ui, d); - ui.separator(); - ui.label(egui::RichText::new("Output options").strong()); - let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); - for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { - ui.horizontal(|ui| { - if ui.radio(to == choice, text_label).clicked() { - d.values.insert("outputTo".into(), json!(choice)); - } - let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { - d.values.insert(key.into(), json!(t)); - d.values.insert("outputTo".into(), json!(choice)); - } - }); - } - ok_cancel(ui, confirm, open); -} - -/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's -/// command; the result opens Solver Results. -fn solver(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| { - let mut t = text(d, k); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { - d.values.insert(k.into(), json!(t)); - } - }; - ui.horizontal(|ui| { - ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0, "Set Objective:"); - }); - let goal = text(d, "goal"); - ui.horizontal(|ui| { - ui.label("To:"); - for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { - if ui.radio(goal == g, label).clicked() { - d.values.insert("goal".into(), json!(g)); - } - } - edit(ui, d, "value", 80.0, "Value Of:"); - }); - ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); - ui.label("Subject to the Constraints:"); - let list: Vec = d - .values - .get("constraints") - .and_then(Json::as_array) - .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) - .unwrap_or_default(); - egui::Frame::group(ui.style()).show(ui, |ui| { - ui.set_min_size(vec2(420.0, 90.0)); - egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { - for (i, c) in list.iter().enumerate() { - if ui.selectable_label(d.list_index == i, c).clicked() { - d.list_index = i; - } - } - }); - }); - ui.horizontal(|ui| { - edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); - let op = text(d, "newOp"); - egui::ComboBox::from_id_salt("solverOp") - .selected_text(op.clone()) - .width(60.0) - .show_ui(ui, |ui| { - for o in ["<=", "=", ">=", "int", "bin"] { - if ui.selectable_label(op == o, o).clicked() { - d.values.insert("newOp".into(), json!(o)); - } - } - }) - .response - .accessible_name(lang.tr("Constraint Operator")); - if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0, "Constraint Value"); - } - if ui.button("Add").clicked() { - let lhs = text(d, "newLhs"); - if !lhs.trim().is_empty() { - let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; - let mut list = list.clone(); - list.push(format!("{} {op}{rhs}", lhs.trim())); - d.values.insert("constraints".into(), json!(list)); - d.values.insert("newLhs".into(), json!("")); - d.values.insert("newRhs".into(), json!("")); - } - } - if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { - let mut list = list.clone(); - if d.list_index < list.len() { - list.remove(d.list_index); - } - d.values.insert("constraints".into(), json!(list)); - } - }); - let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); - if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { - d.values.insert("nonNegative".into(), json!(nonneg)); - } - let method = text(d, "method"); - ui.horizontal(|ui| { - ui.label("Select a Solving Method:"); - let label = |m: &str| match m { - "simplex" => "Simplex LP", - "evolutionary" => "Evolutionary", - _ => "GRG Nonlinear", - }; - egui::ComboBox::from_id_salt("solverMethod") - .selected_text(label(&method)) - .show_ui(ui, |ui| { - for m in ["grg", "simplex", "evolutionary"] { - if ui.selectable_label(method == m, label(m)).clicked() { - d.values.insert("method".into(), json!(m)); - } - } - }) - .response - .accessible_name(lang.tr("Select a Solving Method:")); - }); - ui.label( - egui::RichText::new(match method.as_str() { - "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", - "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", - _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", - }) - .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; - } - }); -} - -/// Solver Results: the outcome, and keeping or restoring the variable cells. -fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); - ui.label(message); - let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); - let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); - if changed { - ui.add_space(4.0); - if ui.radio(!restore, "Keep Solver Solution").clicked() { - d.values.insert("restore".into(), json!(false)); - } - if ui.radio(restore, "Restore Original Values").clicked() { - d.values.insert("restore".into(), json!(true)); - } - } - // Reports describe the solution whether it is kept or not. - let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); - if solved { - ui.add_space(4.0); - ui.label("Reports:"); - for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { - let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut on, label).changed() { - d.values.insert(key.into(), json!(on)); - } - } - } - let mut confirm = false; - ok_cancel(ui, &mut confirm, open); - if confirm { - // The solution (and the saved model) is one undo step. - if changed && restore { - app.run_or_alert("edit.undo", json!({})); - } - let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] - .into_iter() - .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) - .map(|(_, kind)| kind) - .collect(); - if solved && !reports.is_empty() { - let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); - let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); - app.run_or_alert("data.solverReport", params); - } - *open = false; - } -} - -fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let r = d.result.clone().unwrap_or(Json::Null); - if let Some(e) = r.get("error").and_then(Json::as_str) { - ui.label(app.ui.language.tr_error(e)); - return; - } - let issues = r["issues"].as_array().cloned().unwrap_or_default(); - let Some(it) = issues.get(d.list_index) else { - ui.label("The spelling check is complete for the entire sheet."); - if ui.button("OK").clicked() { - *open = false; - } - return; - }; - let word = it["word"].as_str().unwrap_or("").to_string(); - let cell = it["cell"].as_str().unwrap_or("").to_string(); - ui.label(format!("Not in Dictionary ({cell}):")); - ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); - let sugg: Vec = - it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label("Suggestions:"); - let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - for s in &sugg { - if ui.selectable_label(pick == *s, s).clicked() { - pick = s.clone(); - } - } - if sugg.is_empty() { - ui.label(egui::RichText::new("(No suggestions)").italics()); - } - d.values.insert("pick".into(), json!(pick.clone())); - let _ = app.session.run("selection.set", json!({"cell": cell})); - ui.horizontal(|ui| { - if ui.button("Ignore Once").clicked() { - d.list_index += 1; - } - if ui.button("Add to Dictionary").clicked() { - let _ = app.session.run("review.addToDictionary", json!({"word": word})); - d.list_index += 1; - } - if !pick.is_empty() { - if ui.button("Change").clicked() { - app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); - d.list_index += 1; - } - if ui.button("Change All").clicked() { - app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); - d.list_index += 1; - } - } - }); -} - -#[cfg(test)] -mod data_validation_tests { - use super::*; - - #[test] - fn list_uses_one_source_and_does_not_submit_a_second_list() { - let fields = data_validation_fields("list", "between"); - assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); - assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - fields, - json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), - ); - let params = build_params(&dialog); - assert_eq!(params["formula1"], "ABC,DEF"); - assert!(params.get("formula2").is_none()); - assert!(params.get("operator").is_none()); - } - - #[test] - fn only_between_operators_show_and_submit_two_bounds() { - let two = data_validation_fields("whole", "between"); - assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); - let one = data_validation_fields("date", "lessOrEqual"); - assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); - assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - one, - json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), - ); - let params = build_params(&dialog); - assert_eq!(params["operator"], "lessOrEqual"); - assert_eq!(params["formula1"], "2026-10-12"); - assert!(params.get("formula2").is_none()); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 000000000..a95878e92 --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,26 @@ +use super::Dialog; + +/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits +/// are compiled in (`crate::credits`, docs/contributors.md). +pub(super) fn about(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["About", "Contributors", "Models"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + match d.tab.as_str() { + "Contributors" => crate::credits::contributors_ui(ui), + "Models" => crate::credits::models_ui(ui), + _ => { + ui.heading("GridCraft"); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.add_space(4.0); + ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); + ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); + ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs new file mode 100644 index 000000000..cc0dc2f9a --- /dev/null +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -0,0 +1,344 @@ +use super::show::{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) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); + egui::Grid::new("gs").show(ui, |ui| { + ui.label("Set cell:"); + ui.text_edit_singleline(&mut set); + ui.end_row(); + ui.label("To value:"); + ui.text_edit_singleline(&mut to); + ui.end_row(); + ui.label("By changing cell:"); + ui.text_edit_singleline(&mut changing); + ui.end_row(); + }); + d.values.insert("set".into(), json!(set.clone())); + d.values.insert("to".into(), json!(to.clone())); + d.values.insert("changing".into(), json!(changing.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { + Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), + Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), + } + } + if !open { + *confirm = true; + } +} + +/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). +fn toolpak_fields(tool: &str) -> Vec { + use Field::*; + let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; + let labels = || Check { key: "labels", label: "Labels in first row" }; + let range = || Text { key: "range", label: "Input Range:" }; + let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; + match tool { + "descriptive" => vec![ + range(), + grouped(), + labels(), + Check { key: "summary", label: "Summary statistics" }, + Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, + Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, + Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, + ], + "histogram" => vec![ + range(), + Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, + labels(), + Check { key: "pareto", label: "Pareto (sorted histogram)" }, + Check { key: "cumulative", label: "Cumulative Percentage" }, + ], + "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], + "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], + "regression" => vec![ + Text { key: "inputY", label: "Input Y Range:" }, + Text { key: "inputX", label: "Input X Range:" }, + labels(), + Check { key: "constantZero", label: "Constant is Zero" }, + Text { key: "confidence", label: "Confidence Level (%):" }, + Check { key: "residuals", label: "Residuals" }, + Check { key: "standardizedResiduals", label: "Standardized Residuals" }, + Check { key: "normalProbability", label: "Normal Probability" }, + ], + "movingAverage" => { + vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "exponentialSmoothing" => { + vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "fTest" => { + let mut v = two(); + v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); + v + } + _ => { + let mut v = two(); + v.extend([ + Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, + labels(), + Text { key: "alpha", label: "Alpha:" }, + ]); + v + } + } +} + +/// The starting values of a tool's form: the selection as its input, a new sheet as output. +pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { + let mut v = json!({ + "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", + "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", + "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", + "outputTo": "newSheet", "output": "", "newSheet": "", + }); + if tool == "regression" { + v["confidence"] = json!("95"); + v["inputY"] = json!(sel); + } + v.as_object().cloned().unwrap_or_default() +} + +/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` +/// (through the dialog's command); a report that would replace data asks first. +pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use gridcraft_engine::cmd::analysis::TOOLS; + if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { + ui.label("The output range already holds data. Replace it with the report?"); + ui.horizontal(|ui| { + if ui.button("Replace").clicked() { + d.values.remove("askOverwrite"); + d.values.insert("overwrite".into(), json!(true)); + *confirm = true; + } + if ui.button("Cancel").clicked() { + d.values.remove("askOverwrite"); + } + }); + return; + } + let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); + let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { + ui.label(egui::RichText::new("Analysis Tools").strong()); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + let mut chosen = false; + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (id, label) in TOOLS { + let r = ui.selectable_label(pick == *id, *label); + if r.clicked() { + d.values.insert("pick".into(), json!(id)); + } + if r.double_clicked() { + chosen = true; + } + } + }); + let mut ok = false; + ok_cancel(ui, &mut ok, open); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { + let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); + d.values = toolpak_defaults(&sel, &pick); + } + return; + }; + d.title = label.to_string(); + d.fields = toolpak_fields(&tool); + form_fields(ui, d); + ui.separator(); + ui.label(egui::RichText::new("Output options").strong()); + let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); + for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { + ui.horizontal(|ui| { + if ui.radio(to == choice, text_label).clicked() { + d.values.insert("outputTo".into(), json!(choice)); + } + let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { + d.values.insert(key.into(), json!(t)); + d.values.insert("outputTo".into(), json!(choice)); + } + }); + } + ok_cancel(ui, confirm, open); +} + +/// 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) { + 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| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0, "Set Objective:"); + }); + let goal = text(d, "goal"); + ui.horizontal(|ui| { + ui.label("To:"); + for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { + if ui.radio(goal == g, label).clicked() { + d.values.insert("goal".into(), json!(g)); + } + } + edit(ui, d, "value", 80.0, "Value Of:"); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); + ui.label("Subject to the Constraints:"); + let list: Vec = d + .values + .get("constraints") + .and_then(Json::as_array) + .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) + .unwrap_or_default(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_size(vec2(420.0, 90.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for (i, c) in list.iter().enumerate() { + if ui.selectable_label(d.list_index == i, c).clicked() { + d.list_index = i; + } + } + }); + }); + ui.horizontal(|ui| { + edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); + let op = text(d, "newOp"); + craft_ui::ComboBox::from_id_salt("solverOp") + .selected_text(op.clone()) + .width(60.0) + .show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } + } + }) + .response + .accessible_name(lang.tr("Constraint Operator")); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0, "Constraint Value"); + } + if ui.button("Add").clicked() { + let lhs = text(d, "newLhs"); + if !lhs.trim().is_empty() { + let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; + let mut list = list.clone(); + list.push(format!("{} {op}{rhs}", lhs.trim())); + d.values.insert("constraints".into(), json!(list)); + d.values.insert("newLhs".into(), json!("")); + d.values.insert("newRhs".into(), json!("")); + } + } + if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { + let mut list = list.clone(); + if d.list_index < list.len() { + list.remove(d.list_index); + } + d.values.insert("constraints".into(), json!(list)); + } + }); + let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { + d.values.insert("nonNegative".into(), json!(nonneg)); + } + let method = text(d, "method"); + ui.horizontal(|ui| { + ui.label("Select a Solving Method:"); + let label = |m: &str| match m { + "simplex" => "Simplex LP", + "evolutionary" => "Evolutionary", + _ => "GRG Nonlinear", + }; + craft_ui::ComboBox::from_id_salt("solverMethod") + .selected_text(label(&method)) + .show_ui(ui, |ui| { + for m in ["grg", "simplex", "evolutionary"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }) + .response + .accessible_name(lang.tr("Select a Solving Method:")); + }); + ui.label( + egui::RichText::new(match method.as_str() { + "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", + "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", + _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", + }) + .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; + } + }); +} + +/// Solver Results: the outcome, and keeping or restoring the variable cells. +pub(super) fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); + ui.label(message); + let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); + let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); + if changed { + ui.add_space(4.0); + if ui.radio(!restore, "Keep Solver Solution").clicked() { + d.values.insert("restore".into(), json!(false)); + } + if ui.radio(restore, "Restore Original Values").clicked() { + d.values.insert("restore".into(), json!(true)); + } + } + // Reports describe the solution whether it is kept or not. + let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); + if solved { + ui.add_space(4.0); + ui.label("Reports:"); + for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, label).changed() { + d.values.insert(key.into(), json!(on)); + } + } + } + let mut confirm = false; + ok_cancel(ui, &mut confirm, open); + if confirm { + // The solution (and the saved model) is one undo step. + if changed && restore { + app.run_or_alert("edit.undo", json!({})); + } + let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] + .into_iter() + .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) + .map(|(_, kind)| kind) + .collect(); + if solved && !reports.is_empty() { + let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); + let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); + app.run_or_alert("data.solverReport", params); + } + *open = false; + } +} diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs new file mode 100644 index 000000000..612ba381f --- /dev/null +++ b/crates/ui-egui/src/dialogs/charts.rs @@ -0,0 +1,187 @@ +use super::show::ok_cancel; +use super::{Color32, Dialog, Icon, Json, Sense, SheetApp, Stroke, StrokeKind, icons, json, vec2}; + +/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. +const ALL_CHARTS: [(&str, &str, &str); 21] = [ + ("column", "", "Clustered Column"), + ("column", "stacked", "Stacked Column"), + ("column", "stacked100", "100% Stacked Column"), + ("bar", "", "Clustered Bar"), + ("bar", "stacked", "Stacked Bar"), + ("bar", "stacked100", "100% Stacked Bar"), + ("line", "", "Line"), + ("line", "markers", "Line with Markers"), + ("line", "stacked", "Stacked Line"), + ("pie", "", "Pie"), + ("doughnut", "", "Doughnut"), + ("area", "", "Area"), + ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), + ("scatter", "", "Scatter"), + ("scatter", "lines", "Scatter with Lines"), + ("radar", "", "Radar"), + ("histogram", "", "Histogram"), + ("waterfall", "", "Waterfall"), + ("funnel", "", "Funnel"), + ("combo", "", "Combo"), +]; + +/// The chart types listed on the dialog's current tab. +fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { + if d.tab == "All Charts" { + return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); + } + let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + d.result + .as_ref() + .and_then(Json::as_array) + .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) + .unwrap_or_default() +} + +/// Selects entry `i` of the current tab: OK inserts it. +pub(super) fn select_chart(d: &mut Dialog, i: usize) { + let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; + d.list_index = i; + d.values.insert("type".into(), json!(t)); + d.values.insert("subtype".into(), json!(sub)); +} + +fn chart_icon(t: &str) -> Icon { + match t { + "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, + "line" | "area" | "radar" => Icon::ChartLine, + "pie" | "doughnut" => Icon::ChartPie, + "scatter" | "bubble" => Icon::ChartScatter, + _ => Icon::Chart, + } +} + +pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + ui.horizontal(|ui| { + for tab in ["Recommended Charts", "All Charts"] { + if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { + d.tab = tab.to_string(); + select_chart(d, 0); + } + } + }); + ui.separator(); + let choices = chart_choices(d); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(190.0); + egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { + for (i, (kind, _, label)) in choices.iter().enumerate() { + let resp = ui + .horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); + ui.selectable_label(i == d.list_index, label.as_str()) + }) + .inner; + if resp.clicked() { + select_chart(d, i); + } + if resp.double_clicked() { + select_chart(d, i); + *confirm = true; + } + } + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); + ui.strong(label); + let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); + ui.painter().rect_filled(rect, 2.0, Color32::WHITE); + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + if let Some(doc) = app.session.active() + && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) + { + crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); + } + }); + }); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let params = + json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + match app.run("insert.chart", params) { + Ok(r) => { + // The new chart is selected, as after inserting any object. + app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); + *open = false; + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} + +/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. +pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let label = ui.label(label); + let mut v = text(d, key); + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { + d.values.insert(key.into(), json!(v)); + } + }; + edit(ui, d, "range", "Chart data range:"); + ui.add_space(6.0); + ui.strong("Legend Entries (Series)"); + let names: Vec = + d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); + let mut remove: Vec = + d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + let kept = names.len().saturating_sub(remove.len()); + egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + let i = i as u64; + let removed = remove.contains(&i); + ui.horizontal(|ui| { + let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; + ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); + if removed { + if ui.small_button("Keep").clicked() { + remove.retain(|x| *x != i); + } + } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { + remove.push(i); + } + }); + } + }); + d.values.insert("remove".into(), json!(remove)); + ui.add_space(6.0); + edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let mut params = serde_json::Map::new(); + params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); + let range_changed = text(d, "range") != text(d, "range0"); + if range_changed { + params.insert("range".into(), json!(text(d, "range"))); + } else if !remove.is_empty() { + // A new range rebuilds the series; removals only apply to the current ones. + params.insert("removeSeries".into(), json!(remove)); + } + if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { + params.insert("categories".into(), json!(text(d, "categories"))); + } + if params.len() == 1 { + *open = false; + return; + } + match app.run("chart.selectData", Json::Object(params)) { + Ok(_) => *open = false, + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs new file mode 100644 index 000000000..aed9843fc --- /dev/null +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -0,0 +1,88 @@ +use super::{Dialog, Key, SheetApp, json, vec2}; + +pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), + i.consume_key(egui::Modifiers::CTRL, Key::Home), + i.consume_key(egui::Modifiers::CTRL, Key::End), + i.consume_key(egui::Modifiers::NONE, Key::PageUp), + i.consume_key(egui::Modifiers::NONE, Key::PageDown), + ) + }); + let width = ui.available_width(); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); + + if !d.focus_initialized { + r.request_focus(); + d.focus_initialized = true; + } + + let q = d.search.to_ascii_lowercase(); + let cmds: Vec<(String, String, String)> = app + .session + .commands() + .into_iter() + .filter(|c| c.enabled) + .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) + .take(40) + .collect(); + + let last = cmds.len().saturating_sub(1); + + let row_h = ui.spacing().interact_size.y.max(20.0); + let row_step = row_h + ui.spacing().item_spacing.y; + + const LIST_HEIGHT: f32 = 320.0; + + let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); + + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + if pgdn { + d.list_index = (d.list_index + page_size).min(last); + } + if pgup { + d.list_index = d.list_index.saturating_sub(page_size); + } + if home { + d.list_index = 0; + } + if end { + d.list_index = last; + } + + d.list_index = d.list_index.min(last); + + egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { + let scroll_area_width = ui.available_width(); + for (i, (id, label, menu)) in cmds.iter().enumerate() { + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); + + if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); + } + + if r.clicked() { + d.list_index = i; + *confirm = true; + } + + r.on_hover_text(id); + } + }); + if ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { + let id = id.clone(); + app.dialog = None; + app.run_or_alert(&id, json!({})); + } +} diff --git a/crates/ui-egui/src/dialogs/cond_format_rules.rs b/crates/ui-egui/src/dialogs/cond_format_rules.rs new file mode 100644 index 000000000..4d8a22375 --- /dev/null +++ b/crates/ui-egui/src/dialogs/cond_format_rules.rs @@ -0,0 +1,28 @@ +use super::{Json, SheetApp, json}; + +pub(super) fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { + let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); + let rules = list["rules"].as_array().cloned().unwrap_or_default(); + if rules.is_empty() { + ui.label("There are no conditional formatting rules on this sheet."); + } + for (i, r) in rules.iter().enumerate() { + ui.horizontal(|ui| { + let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); + ui.label(format!( + "{}. {kind} applies to {}", + i + 1, + r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() + )); + if ui.small_button("▲").clicked() { + app.run_or_alert("home.manageRules", json!({"moveUp": i})); + } + if ui.small_button("▼").clicked() { + app.run_or_alert("home.manageRules", json!({"moveDown": i})); + } + if ui.small_button("Delete").clicked() { + app.run_or_alert("home.manageRules", json!({"delete": i})); + } + }); + } +} diff --git a/crates/ui-egui/src/dialogs/data.rs b/crates/ui-egui/src/dialogs/data.rs new file mode 100644 index 000000000..ec5eabfc1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/data.rs @@ -0,0 +1,327 @@ +#[cfg(test)] +use super::show::build_params; +use super::show::{form_fields, ok_cancel}; +use super::{Dialog, Field, Icon, Json, SheetApp, json, vec2}; + +/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without +/// edits keeps it; a plain list rule when the cell has none. +pub(super) fn validation_defaults(app: &SheetApp) -> Json { + let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); + let Some(doc) = app.session.active() else { return v }; + let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { + return v; + }; + // The enums serialize as their variant names; the command takes them in camelCase. + let camel = |x: Json| { + let name = x.as_str().unwrap_or_default(); + let mut chars = name.chars(); + chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() + }; + v["type"] = json!(camel(json!(dv.kind))); + v["operator"] = json!(camel(json!(dv.op))); + v["errorStyle"] = json!(camel(json!(dv.error_style))); + v["formula1"] = json!(dv.f1); + if let Some(f2) = &dv.f2 { + v["formula2"] = json!(f2); + } + v["ignoreBlank"] = json!(dv.allow_blank); + v["dropdown"] = json!(dv.in_cell_dropdown); + v["inputTitle"] = json!(dv.input_title); + v["inputMessage"] = json!(dv.input_message); + v["errorTitle"] = json!(dv.error_title); + v["errorMessage"] = json!(dv.error_message); + v +} + +/// The range Remove Duplicates starts with. A block selection is used as given; with only the +/// cursor placed, the table's data rows or the current region, as the command picks when it gets +/// no range (a single-cell range would remove nothing). +pub(super) fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { + let Some(d) = app.session.active() else { return sel.to_string() }; + let Some(sh) = d.wb.active() else { return sel.to_string() }; + if !d.selection.current().is_single() { + return sel.to_string(); + } + let range = match sh.table_at(d.selection.active) { + Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), + None => gridcraft_engine::selection::current_region(sh, d.selection.active), + }; + range.a1() +} + +pub(super) fn abs(r: &gridcraft_engine::core::RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The criterion controls in Excel's Settings tab depend on both Allow and Data. +/// `formula1` is the sole source for a list or custom rule; `formula2` is a +/// second bound only for Between and Not between. +pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec { + use Field::*; + let mut fields = vec![Choice { + key: "type", + label: "Allow", + options: vec![ + ("any", "Any value"), + ("whole", "Whole number"), + ("decimal", "Decimal"), + ("list", "List"), + ("date", "Date"), + ("time", "Time"), + ("textLength", "Text length"), + ("custom", "Custom"), + ], + }]; + let two_bounds = matches!(operator, "between" | "notBetween"); + match kind { + "list" => { + fields.push(Text { key: "formula1", label: "Source:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); + } + "custom" => { + fields.push(Text { key: "formula1", label: "Formula:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + fields.push(Choice { + key: "operator", + label: "Data", + options: vec![ + ("between", "between"), + ("notBetween", "not between"), + ("equal", "equal to"), + ("notEqual", "not equal to"), + ("greater", "greater than"), + ("less", "less than"), + ("greaterOrEqual", "greater than or equal to"), + ("lessOrEqual", "less than or equal to"), + ], + }); + let lower = matches!(operator, "greater" | "greaterOrEqual"); + let (first, second) = match kind { + "date" => ( + if two_bounds || lower { + "Start date:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End date:" + } else { + "Date:" + }, + "End date:", + ), + "time" => ( + if two_bounds || lower { + "Start time:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End time:" + } else { + "Time:" + }, + "End time:", + ), + _ => ( + if two_bounds || lower { + "Minimum:" + } else if matches!(operator, "less" | "lessOrEqual") { + "Maximum:" + } else { + "Value:" + }, + "Maximum:", + ), + }; + fields.push(Text { key: "formula1", label: first }); + if two_bounds { + fields.push(Text { key: "formula2", label: second }); + } + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + _ => {} + } + fields.push(Text { key: "inputMessage", label: "Input message:" }); + fields.push(Choice { + key: "errorStyle", + label: "Error style", + options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], + }); + fields.push(Text { key: "errorMessage", label: "Error message:" }); + fields +} + +pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + d.fields = data_validation_fields(kind, operator); + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use Field::*; + let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); + d.fields = vec![ + Choice { + key: "action", + label: "Action", + options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], + }, + Text { key: "range", label: "List range:" }, + Text { key: "criteria", label: "Criteria range (optional):" }, + ]; + if copy { + d.fields.push(Text { key: "copyTo", label: "Copy to:" }); + } + d.fields.push(Check { key: "unique", label: "Unique rows only" }); + form_fields(ui, d); + // Constrain the button row: the shared right-to-left layout otherwise + // consumes the window's remaining height instead of fitting the buttons. + ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); +} + +/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. +pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); + ui.checkbox(&mut header, "My list has headers"); + d.values.insert("header".into(), json!(header)); + let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); + // Columns of the region around the active cell, labelled with their header (or first value). + let columns: Vec<(String, String)> = app + .session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + let region = gridcraft_engine::selection::current_region(sh, d.selection.active); + Some( + (region.start.col..=region.end.col) + .map(|c| { + let letter = gridcraft_engine::core::col_to_letters(c); + // The region starts at the header row when one is present, so the + // label always comes from start.row: header text with headers on, + // first data value with them off. + let label_row = region.start.row; + let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); + let text: String = text.chars().take(24).collect(); + (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) + }) + .collect(), + ) + }) + .unwrap_or_default(); + let mut remove = None; + for (i, lv) in levels.iter_mut().enumerate() { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); + let mut col = lv["column"].as_str().unwrap_or("A").to_string(); + if columns.is_empty() { + ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); + } else { + craft_ui::ComboBox::from_id_salt(("sort_col", i)) + .selected_text(col.clone()) + .show_ui(ui, |ui| { + for (letter, label) in &columns { + ui.selectable_value(&mut col, letter.clone(), label.as_str()); + } + }) + .response + .labelled_by(label.id); + } + lv["column"] = json!(col.to_ascii_uppercase()); + let mut by = lv["by"].as_str().unwrap_or("values").to_string(); + craft_ui::ComboBox::from_id_salt(("by", i)) + .selected_text(match by.as_str() { + "cellColor" => "Cell Color", + "fontColor" => "Font Color", + _ => "Cell Values", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut by, "values".into(), "Cell Values"); + ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); + ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); + }) + .response + .accessible_name(lang.tr("Sort On")); + lv["by"] = json!(by); + let mut desc = lv["order"].as_str() == Some("desc"); + ui.radio_value(&mut desc, false, "A to Z"); + ui.radio_value(&mut desc, true, "Z to A"); + lv["order"] = json!(if desc { "desc" } else { "asc" }); + if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove + && levels.len() > 1 + { + levels.remove(i); + } + if ui.button("+ Add Level").clicked() { + let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); + let next = columns + .iter() + .map(|(l, _)| l) + .find(|l| !used.contains(l)) + .cloned() + .or_else(|| columns.last().map(|(l, _)| l.clone())) + .unwrap_or_else(|| "B".into()); + levels.push(json!({"column": next, "order": "asc"})); + } + d.values.insert("levels".into(), Json::Array(levels.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); + } + if !open { + *confirm = true; + } +} + +#[cfg(test)] +mod data_validation_tests { + use super::*; + + #[test] + fn list_uses_one_source_and_does_not_submit_a_second_list() { + let fields = data_validation_fields("list", "between"); + assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); + assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + fields, + json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), + ); + let params = build_params(&dialog); + assert_eq!(params["formula1"], "ABC,DEF"); + assert!(params.get("formula2").is_none()); + assert!(params.get("operator").is_none()); + } + + #[test] + fn only_between_operators_show_and_submit_two_bounds() { + let two = data_validation_fields("whole", "between"); + assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); + let one = data_validation_fields("date", "lessOrEqual"); + assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); + assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + one, + json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), + ); + let params = build_params(&dialog); + assert_eq!(params["operator"], "lessOrEqual"); + assert_eq!(params["formula1"], "2026-10-12"); + assert!(params.get("formula2").is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs new file mode 100644 index 000000000..c1401d554 --- /dev/null +++ b/crates/ui-egui/src/dialogs/find.rs @@ -0,0 +1,151 @@ +use super::show::ok_cancel; +use super::{Dialog, Json, Key, SheetApp, json}; + +/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. +pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["Find", "Replace"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.into(); + } + } + }); + let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); + let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label("Find what:"); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); + // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. + if !d.values.contains_key("focused") { + r.request_focus(); + d.values.insert("focused".into(), json!(true)); + } + }); + if d.tab == "Replace" { + ui.horizontal(|ui| { + let label = ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); + }); + } + let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); + let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); + let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); + ui.horizontal(|ui| { + ui.checkbox(&mut case, "Match case"); + ui.checkbox(&mut whole, "Match entire cell contents"); + ui.checkbox(&mut wb, "Within workbook"); + }); + d.values.insert("what".into(), json!(what)); + d.values.insert("with".into(), json!(with)); + d.values.insert("matchCase".into(), json!(case)); + d.values.insert("wholeCell".into(), json!(whole)); + d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + ui.horizontal(|ui| { + if d.tab == "Replace" { + if ui.button("Replace All").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(true); + match app.run("edit.replace", p) { + Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), + Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), + } + } + if ui.button("Replace").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(false); + let _ = app.run("edit.replace", p); + let _ = app.run("edit.find", base.clone()); + } + } + if ui.button("Find All").clicked() { + let mut p = base.clone(); + p["all"] = json!(true); + d.result = app.run("edit.find", p).ok(); + } + if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + if let Err(e) = app.run("edit.find", base.clone()) { + app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); + } + app.grid.ensure_visible = true; + } + }); + if let Some(r) = &d.result + && let Some(list) = r["results"].as_array() + { + ui.separator(); + ui.label(format!("{} cell(s) found", list.len())); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for item in list { + let label = format!( + "{} {} {}", + item["sheet"].as_str().unwrap_or(""), + item["cell"].as_str().unwrap_or(""), + item["value"].as_str().unwrap_or("") + ); + if ui.selectable_label(false, label).clicked() { + if let Some(s) = item["sheet"].as_str() { + let _ = app.run("sheet.activate", json!({"sheet": s})); + } + let _ = app.run("selection.set", json!({"cell": item["cell"]})); + app.grid.ensure_visible = true; + } + } + }); + } +} + +/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to +/// before, newest first. +pub(super) fn go_to_entries(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| { + let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); + v.extend(doc.go_to_recent.iter().cloned()); + v + }) + .unwrap_or_default() +} + +/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. +pub(super) fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let entries: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { + for n in entries.get(rows).unwrap_or_default() { + let resp = ui.selectable_label(current == *n, n); + if resp.clicked() || resp.double_clicked() { + d.values.insert("reference".into(), json!(n)); + } + if resp.double_clicked() { + *confirm = true; + } + } + }); + let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label(lang.tr("Reference:")); + ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); + }); + d.values.insert("reference".into(), json!(r.clone())); + if ui.button("Special…").clicked() { + app.open_dialog("goToSpecial", json!({})); + return; + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); + app.grid.ensure_visible = true; + } + if !open { + *confirm = true; + } +} diff --git a/crates/ui-egui/src/dialogs/format_cells.rs b/crates/ui-egui/src/dialogs/format_cells.rs new file mode 100644 index 000000000..7eee57e9c --- /dev/null +++ b/crates/ui-egui/src/dialogs/format_cells.rs @@ -0,0 +1,227 @@ +use super::show::ok_cancel; +use super::{Dialog, SheetApp, json, theme}; + +/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. +pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; + ui.horizontal(|ui| { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); + match d.tab.as_str() { + "Number" => { + let mut code = st.num_fmt.as_str().to_string(); + ui.columns(2, |cols| { + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); + for name in + ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] + { + let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { + code = c; + } + } + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); + let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); + cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); + cols[1].add_space(8.0); + let label = cols[1].label(lang.tr("Type (custom format code):")); + cols[1].text_edit_singleline(&mut code).labelled_by(label.id); + for c in [ + "0", + "0.00", + "#,##0", + "#,##0.00", + "#,##0;[Red]-#,##0", + "0%", + "0.00%", + "0.00E+00", + "# ?/?", + "m/d/yyyy", + "d-mmm-yy", + "mmmm d, yyyy", + "h:mm AM/PM", + "h:mm:ss", + "[h]:mm:ss", + "@", + ] { + if cols[1].small_button(c).clicked() { + code = c.to_string(); + } + } + }); + st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); + } + "Alignment" => { + use gridcraft_engine::model::{HAlign, VAlign}; + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); + ui.horizontal_wrapped(|ui| { + for (h, n) in [ + (HAlign::General, "General"), + (HAlign::Left, "Left"), + (HAlign::Center, "Center"), + (HAlign::Right, "Right"), + (HAlign::Fill, "Fill"), + (HAlign::Justify, "Justify"), + (HAlign::CenterAcross, "Center Across Selection"), + (HAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.h, h, lang.tr(n)); + } + }); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); + ui.horizontal_wrapped(|ui| { + for (v, n) in [ + (VAlign::Top, "Top"), + (VAlign::Center, "Center"), + (VAlign::Bottom, "Bottom"), + (VAlign::Justify, "Justify"), + (VAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.v, v, lang.tr(n)); + } + }); + let mut indent = st.align.indent as f32; + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); + st.align.indent = indent as u8; + let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); + st.align.rotation = rot as i16; + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); + } + "Font" => { + craft_ui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { + for f in crate::fonts::families() { + ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); + } + }); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); + ui.horizontal(|ui| { + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); + }); + use gridcraft_engine::model::{Underline, VertAlign}; + ui.horizontal(|ui| { + for (u, n) in [ + (Underline::None, "None"), + (Underline::Single, "Single"), + (Underline::Double, "Double"), + (Underline::SingleAccounting, "Single Accounting"), + (Underline::DoubleAccounting, "Double Accounting"), + ] { + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); + } + }); + ui.horizontal(|ui| { + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); + }); + ui.label(lang.tr("Color:")); + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { + st.font.color = + if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; + } + } + "Border" => { + use gridcraft_engine::model::{BorderLine, BorderStyle}; + let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; + ui.horizontal(|ui| { + if ui.button(lang.tr("None")).clicked() { + st.borders = Default::default(); + } + if ui.button(lang.tr("Outline")).clicked() { + st.borders.top = line; + st.borders.bottom = line; + st.borders.left = line; + st.borders.right = line; + } + }); + for (n, slot) in [ + ("Top", &mut st.borders.top), + ("Bottom", &mut st.borders.bottom), + ("Left", &mut st.borders.left), + ("Right", &mut st.borders.right), + ("Diagonal ↘", &mut st.borders.diag_down), + ("Diagonal ↗", &mut st.borders.diag_up), + ] { + let n = lang.tr(n); + ui.horizontal(|ui| { + ui.label(format!("{n}:")); + for (s, label) in [ + (BorderStyle::None, "none"), + (BorderStyle::Thin, "thin"), + (BorderStyle::Medium, "medium"), + (BorderStyle::Thick, "thick"), + (BorderStyle::Dashed, "dashed"), + (BorderStyle::Dotted, "dotted"), + (BorderStyle::Double, "double"), + ] { + let label = lang.tr(label); + let response = ui.selectable_label(slot.style == s, label); + let selected = slot.style == s; + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); + if response.clicked() { + slot.style = s; + } + } + }); + } + let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); + crate::border_preview::sample(ui, &st.borders, &theme); + } + "Fill" => { + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { + st.fill = if c == "none" { + Default::default() + } else { + gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) + }; + } + } + "Protection" => { + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); + ui.label( + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), + ); + } + _ => {} + } + if let Ok(v) = serde_json::to_value(&st) { + d.values.insert("style".into(), v); + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm && let Some(style) = d.values.get("style").cloned() { + app.run_or_alert("home.formatCells", json!({"style": style})); + } + if !open { + app.dialog = None; + *confirm = true; // closes + } +} diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs new file mode 100644 index 000000000..b662d7197 --- /dev/null +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -0,0 +1,72 @@ +use super::show::ok_cancel; +use super::{Dialog, Json, SheetApp, 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). +pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + 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(); + }); + 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))); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + d.list_index = i; + *confirm = true; + } + } + }); + 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()); + } + 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); + let cur = app.editor.as_ref().map(|e| e.text.clone()); + let text = match cur { + Some(t) if t.starts_with('=') => format!("{t}{n}("), + _ => format!("={n}("), + }; + app.begin_edit(Some(text), false); + } + if !open { + *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/names.rs b/crates/ui-egui/src/dialogs/names.rs new file mode 100644 index 000000000..d892b2999 --- /dev/null +++ b/crates/ui-egui/src/dialogs/names.rs @@ -0,0 +1,134 @@ +use super::show::ok_cancel; +use super::{Dialog, Json, Key, SheetApp, json}; + +pub(super) fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); + let names = list.as_array().cloned().unwrap_or_default(); + let selected = names.get(d.list_index).cloned(); + let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + if ui.button("New…").clicked() { + app.open_dialog("defineName", json!({})); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() + && let Some(n) = &selected + { + app.open_dialog( + "defineName", + json!({ + "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), + "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), + }), + ); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() + && let Some(n) = &selected + { + app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); + } + }); + ui.add_space(4.0); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { + ui.strong("Name"); + ui.strong("Value"); + ui.strong("Refers To"); + ui.strong("Scope"); + ui.end_row(); + if names.is_empty() { + ui.label(egui::RichText::new("No names defined").italics()); + ui.end_row(); + } + for (i, n) in names.iter().enumerate() { + if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { + d.list_index = i; + } + ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); + ui.label(field(n, "refersTo")); + ui.label(field(n, "scope")); + ui.end_row(); + } + }); + }); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// The names Paste Names offers in the active workbook. +pub(super) fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +pub(super) fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + let step = row_height + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { + let top = ui.cursor().top() - rows.start as f32 * step; + for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { + let r = ui.selectable_label(i == d.list_index, n); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + // The arrow keys can move the selection onto a row that isn't laid out. + if down || up { + let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); + if !ui.clip_rect().contains_rect(rect) { + ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs new file mode 100644 index 000000000..9ac0bc579 --- /dev/null +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -0,0 +1,140 @@ +use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2}; + +/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip +/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options +/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. +pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = app.ui.language; + let t = theme::Tokens::get(ui.ctx()); + type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>); + let choices: Vec = d + .fields + .iter() + .filter_map(|f| match f { + Field::Choice { key, label, options } => Some((*key, *label, options.clone())), + _ => None, + }) + .collect(); + for (key, label, options) in choices { + ui.add_space(4.0); + ui.label(egui::RichText::new(lang.tr(label)).strong()); + egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + // Column by column, as Excel fills them. + let rows = options.len().div_ceil(2); + egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for r in 0..rows { + for c in 0..2 { + if let Some((value, text)) = options.get(c * rows + r) { + if ui.radio(cur == *value, lang.tr(text)).clicked() { + d.set_value(key, json!(value)); + } + } else { + ui.label(""); + } + } + ui.end_row(); + } + }); + }); + } + ui.add_space(8.0); + // In the same columns as the options above. + egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { + egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + ui.end_row(); + }); + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + // Excel's Paste Link pastes references to the copied cells straight away. + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + d.values.insert("what".into(), json!("all")); + d.values.insert("operation".into(), json!("none")); + d.values.insert("link".into(), json!(true)); + *confirm = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }); + }); +} + +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + let right_alt = ctx.input(|i| { + i.key_down(Key::AltRight) + || i.events.iter().any(|event| { + matches!(event, egui::Event::Key { key: Key::AltRight, .. }) + || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) + }) + }); + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); + let mnemonic = |key| match key { + Key::T => Some('t'), + Key::V => Some('v'), + Key::F => Some('f'), + Key::C => Some('c'), + Key::E => Some('e'), + _ => None, + }; + let mut text_to_consume = Vec::new(); + for event in ctx.input(|i| i.events.clone()) { + let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; + if !allowed(modifiers) { + continue; + } + let Some(letter) = mnemonic(key) else { continue }; + text_to_consume.push(letter); + if repeat { + continue; + } + if key == Key::E { + let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); + d.values.insert("transpose".into(), json!(!transpose)); + } else { + let what = match key { + Key::T => "formats", + Key::V => "values", + Key::F => "formulas", + _ => "comments", + }; + d.values.insert("what".into(), json!(what)); + } + } + // Browsers can deliver Text before or after Key. Act only on Key, then remove + // its matching text so one physical press cannot toggle Transpose twice. + ctx.input_mut(|i| { + i.events.retain(|event| match event { + egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), + egui::Event::Text(text) if text.len() == 1 => { + let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); + if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { + text_to_consume.remove(index); + false + } else { + true + } + } + _ => true, + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs new file mode 100644 index 000000000..071a94e5b --- /dev/null +++ b/crates/ui-egui/src/dialogs/show.rs @@ -0,0 +1,601 @@ +use super::about::about; +use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::charts::{recommended_charts, select_data}; +use super::command_search::command_search; +use super::cond_format_rules::manage_rules; +use super::data::{advanced_filter_form, data_validation_form, sort}; +use super::find::{find, go_to}; +use super::format_cells::format_cells; +use super::insert_function::insert_function; +use super::names::{name_manager, paste_name}; +use super::paste::{paste_special, paste_special_shortcuts}; +use super::spelling::spelling; +use super::zoom::zoom; +use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2}; + +/// Builds the command params from form values for dialogs that need translation. +/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. +/// Unchanged hyperlink display text is omitted so its number or formula stays intact. +pub(crate) fn build_params(d: &Dialog) -> Json { + let mut v = Json::Object(d.values.clone()); + match d.name.as_str() { + "insertLink" => { + if d.original_link_text.as_deref().is_some_and(|text| d.values.get("text").and_then(Json::as_str) == Some(text)) + && let Some(m) = v.as_object_mut() + { + m.remove("text"); + } + } + "advancedFilter" => { + if let Some(m) = v.as_object_mut() { + m.remove("action"); + if d.values.get("action").and_then(Json::as_str) != Some("copy") { + m.remove("copyTo"); + } + if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { + m.remove("criteria"); + } + } + } + "dataValidation" => { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + if let Some(m) = v.as_object_mut() { + if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { + m.remove("formula2"); + } + if matches!(kind, "any" | "list" | "custom") { + m.remove("operator"); + } + if kind == "any" { + m.remove("formula1"); + } + if kind != "list" { + m.remove("dropdown"); + } + } + } + "textToColumns" => { + let mut delims = Vec::new(); + for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { + if d.values.get(k).and_then(Json::as_bool) == Some(true) { + delims.push(json!(c)); + } + } + v["delimiters"] = Json::Array(delims); + } + "removeDuplicates" | "subtotal" => { + if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { + v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); + } + } + "allowEditRanges" => { + if let Some(m) = v.as_object_mut() { + // Blank fields mean "unchanged"; the check box clears a password. + m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); + if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { + m.insert("password".into(), json!("")); + } + } + } + "cfQuick" => { + let mut rule = Map::new(); + for (k, val) in &d.values { + rule.insert(k.clone(), val.clone()); + } + v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); + v["range"] = json!(pick.applies_to); + } + } + "series" => { + if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { + v["stop"] = json!(s); + } else if let Some(m) = v.as_object_mut() { + m.remove("stop"); + } + } + "defineName" => { + if let Some(m) = v.as_object_mut() { + for key in ["scope", "oldScope"] { + if m.get(key).and_then(Json::as_str) == Some("Workbook") { + m.remove(key); + } + } + } + } + _ => {} + } + v +} + +/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. +pub fn show(app: &mut SheetApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); + // The macro step editor is a large resizable window of its own. + if d.name == "macroEditor" { + let mut open = true; + crate::macro_editor::window(app, ctx, &mut d, &mut open); + if !open { + app.macros.editor = None; + app.grid.macro_overlay = None; + } else if app.ui_window() != home { + // Something run from the editor switched windows: the editor stays in its own. + app.return_dialog(home, d); + } else { + app.dialog = Some(d); + } + return; + } + if d.name == "pasteSpecial" { + paste_special_shortcuts(&mut d, ctx); + } + let mut open = true; + let mut win_open = true; + let mut confirm = false; + let width = match d.name.as_str() { + "formatCells" => 560.0, + "about" => 660.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "macros" => 480.0, + "recommendedCharts" => 600.0, + "analysisToolPak" => 420.0, + "solver" => 460.0, + "zoom" => 220.0, + "pasteSpecial" => 480.0, + _ => 380.0, + }; + let window = egui::Window::new(app.ui.language.tr(&d.title)) + .id(egui::Id::unique("dialog").with(&d.name)) + .collapsible(false) + .resizable(false) + .default_width(width) + // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) + .open(&mut win_open); + let window = match d.name.as_str() { + "insertPicture" => window.default_height(240.0), + "advancedFilter" => window.default_height(0.0), + _ => window, + }; + window.show(ctx, |ui| { + ui.set_min_width(width - 20.0); + match d.name.as_str() { + "formatCells" => format_cells(app, ui, &mut d, &mut confirm), + "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), + "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), + "find" => find(app, ui, &mut d), + "goTo" => go_to(app, ui, &mut d, &mut confirm), + "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), + "sort" => sort(app, ui, &mut d, &mut confirm), + "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), + "manageRules" => manage_rules(app, ui), + "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), + "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), + "commandSearch" => command_search(app, ui, &mut d, &mut confirm), + "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), + "solverResults" => solver_results(app, ui, &mut d, &mut open), + "spelling" => spelling(app, ui, &mut d, &mut open), + "agents" => { + ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); + ui.add_space(4.0); + ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); + ui.add_space(4.0); + ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); + } + "about" => about(ui, &mut d), + "journal" => { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (id, p) in app.session.journal.iter().rev().take(500) { + ui.monospace(format!("{id} {p}")); + } + }); + } + "macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open), + "recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open), + "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { + let r = if d.name == "comments" { + app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) + } else { + d.result.clone() + }; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); + }); + } + "insertPicture" => { + // A private inbox dies with this dialog; late browser results + // cannot affect a later picture choice or another workbook. + let arrived = d.picture_inbox.as_ref().and_then(|inbox| { + inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() + }); + if let Some((name, bytes)) = arrived { + d.picture_inbox = None; + if bytes.len() > 16 * 1024 * 1024 { + app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); + } else { + d.values.remove("path"); + d.values.insert("alt".into(), json!(name)); + d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); + } + } + if app.services.pick_picture_async.is_some() { + let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); + ui.label(name); + } else { + ui.label("Picture file path (PNG or JPEG):"); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut path).changed() { + d.values.remove("base64"); + d.values.insert("path".into(), json!(path)); + } + } + if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { + if let Some(start) = &app.services.pick_picture_async { + let inbox = crate::Inbox::default(); + start(inbox.clone()); + d.picture_inbox = Some(inbox); + } else if let Some(pick) = &app.services.pick_picture + && let Some(path) = pick() + { + d.values.remove("base64"); + let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); + d.values.insert("alt".into(), json!(alt)); + d.values.insert("path".into(), json!(path)); + } + } + ui.label("Description (optional):"); + let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut alt).changed() { + d.values.insert("alt".into(), json!(alt)); + } + if d.values.get("placement").and_then(Json::as_str) == Some("cell") { + ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); + } + ok_cancel(ui, &mut confirm, &mut open); + } + "pickList" => { + let items: Vec = app + .session + .active() + .and_then(|doc| { + let sh = doc.wb.active()?; + let a = doc.selection.active; + let mut seen = std::collections::BTreeSet::new(); + let mut r = a.row; + while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { + r -= 1; + seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); + } + Some(seen.into_iter().collect()) + }) + .unwrap_or_default(); + for it in items { + if ui.button(&it).clicked() { + app.run_or_alert("cell.set", json!({"input": it})); + open = false; + } + } + } + "saveCopy" => { + let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); + ui.label(format!("Save a copy as .{fmt}")); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + ui.text_edit_singleline(&mut path); + d.values.insert("path".into(), json!(path.clone())); + ok_cancel(ui, &mut confirm, &mut open); + if confirm { + // A bare name has no folder: written as typed it would land in the + // process's working directory (`/` from the Finder, the install folder on + // Windows), never where the user looks. The picker chooses the folder. + let pick = app.services.pick_save.as_ref(); + let target = if path.is_empty() { + pick.and_then(|f| f(&format!("Copy.{fmt}"))) + } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { + f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) + } else { + Some(path) + }; + if let Some(t) = target { + let id = match fmt.as_str() { + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => "file.saveAs", + }; + app.run_or_alert(id, json!({"path": t})); + } + } + } + "saveChanges" => { + ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); + ui.horizontal(|ui| { + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + open = false; + } + } + }); + } + "start" => { + ui.heading("Start a new workbook"); + ui.horizontal_wrapped(|ui| { + if ui.button("Blank workbook").clicked() { + app.run_or_alert("file.new", json!({})); + open = false; + } + for (n, t) in gridcraft_engine::sample::SAMPLES { + if ui.button(*t).clicked() { + app.run_or_alert("file.new", json!({"sample": n})); + open = false; + } + } + }); + ui.separator(); + ui.label(egui::RichText::new("Recent").strong()); + for path in app.ui.recent.clone() { + if ui.link(&path).clicked() { + app.open_path(&path); + open = false; + } + } + if ui.button("Open…").clicked() { + app.open_dialog("open", json!({})); + open = false; + } + } + _ => form(ui, &mut d, &mut confirm, &mut open), + } + }); + // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since + // the native shell has no browser integration of its own. + if let Some(open_url) = &app.services.open_url { + let urls: Vec = ctx.output_mut(|o| { + let mut urls = vec![]; + o.commands.retain(|c| match c { + egui::OutputCommand::OpenUrl(u) => { + urls.push(u.url.clone()); + false + } + _ => true, + }); + urls + }); + for u in urls { + open_url(&u); + } + } + if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { + 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())); + confirm = false; + } + if confirm && let Some(cmd) = d.command { + let params = build_params(&d); + match app.run(cmd, params) { + Ok(r) => { + open = false; + if d.name == "solver" { + app.open_dialog("solverResults", r); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &r); + } + } + // Data Analysis asks before replacing data in its output range. + Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { + d.values.insert("askOverwrite".into(), json!(true)); + } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } else if confirm { + open = false; + } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } + if let Some(child) = app.dialog.as_mut() { + // The body opened another dialog (Name Manager › New…). It takes over; this one waits + // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To + // hands over to Go To Special for good, as in Excel. + if open && d.name != "goTo" { + child.back = Some(Box::new(d)); + } else if child.back.is_none() { + child.back = d.back.take(); + } + return; + } + if open { + app.dialog = Some(d); + } else if let Some(back) = d.back.take() { + app.dialog = Some(*back); + } +} + +pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { + ui.add_space(8.0); + // A row as tall as the buttons: a right-to-left layout straight in the window takes all the + // height it is offered, which stretched every form dialog to the screen's height. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let lang = crate::i18n::current(ui.ctx()); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }) + }); +} + +fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +/// Gives keyboard focus to the first text or number field the first time a form is drawn. A +/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and +/// 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) { + if !d.focus_first_field { + return; + } + if !r.has_focus() { + r.request_focus(); + return; + } + // Focus has landed, and the field is now a text box: select its whole value so typing + // replaces it (the field's own select-on-focus fired before it was a text box). + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); + state.cursor.set_char_range(Some(all)); + state.store(&r.ctx, r.id); + d.focus_first_field = false; +} + +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + +/// Draws a dialog's `fields`, editing its values. +pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { + // Field labels, choices and notes are English source strings, translated as they are drawn. + let lang = crate::i18n::current(ui.ctx()); + egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for f in d.fields.clone() { + match f { + Field::Text { key, label } => { + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d + .values + .get(key) + .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) + .unwrap_or_default(); + let multiline = key == "text"; + let r = if multiline { + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) + } else { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) + }; + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(s)); + } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } + }); + } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() + { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, lang.tr("Show password")); + }); + } + Field::Number { key, label } => { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(label)); + let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); + // Typed values count straight away: otherwise Enter or OK in the same frame + // confirms the old value, because a number field only commits when it loses focus. + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(n)); + } + }); + } + Field::Choice { key, label, options } => { + ui.vertical(|ui| { + let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if d.name == "dataValidation" { + let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); + craft_ui::ComboBox::from_id_salt(("dataValidation", key)) + .selected_text(selected) + .width(300.0) + .show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + }) + .response + .labelled_by(field_label.id); + } else { + for (val, text) in options { + if ui.radio(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + } + }); + } + Field::Check { key, label } => { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + Field::Note(t) => { + // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a + // context separator, so such a note is shown as it is. + let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; + ui.label(egui::RichText::new(text).small()); + } + } + ui.end_row(); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/spelling.rs b/crates/ui-egui/src/dialogs/spelling.rs new file mode 100644 index 000000000..d9ca98c9e --- /dev/null +++ b/crates/ui-egui/src/dialogs/spelling.rs @@ -0,0 +1,54 @@ +use super::{Dialog, Json, SheetApp, json}; + +pub(super) fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let r = d.result.clone().unwrap_or(Json::Null); + if let Some(e) = r.get("error").and_then(Json::as_str) { + ui.label(app.ui.language.tr_error(e)); + return; + } + let issues = r["issues"].as_array().cloned().unwrap_or_default(); + let Some(it) = issues.get(d.list_index) else { + ui.label("The spelling check is complete for the entire sheet."); + if ui.button("OK").clicked() { + *open = false; + } + return; + }; + let word = it["word"].as_str().unwrap_or("").to_string(); + let cell = it["cell"].as_str().unwrap_or("").to_string(); + ui.label(format!("Not in Dictionary ({cell}):")); + ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); + let sugg: Vec = + it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); + ui.label("Suggestions:"); + let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + for s in &sugg { + if ui.selectable_label(pick == *s, s).clicked() { + pick = s.clone(); + } + } + if sugg.is_empty() { + ui.label(egui::RichText::new("(No suggestions)").italics()); + } + d.values.insert("pick".into(), json!(pick.clone())); + let _ = app.session.run("selection.set", json!({"cell": cell})); + ui.horizontal(|ui| { + if ui.button("Ignore Once").clicked() { + d.list_index += 1; + } + if ui.button("Add to Dictionary").clicked() { + let _ = app.session.run("review.addToDictionary", json!({"word": word})); + d.list_index += 1; + } + if !pick.is_empty() { + if ui.button("Change").clicked() { + app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); + d.list_index += 1; + } + if ui.button("Change All").clicked() { + app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); + d.list_index += 1; + } + } + }); +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs new file mode 100644 index 000000000..6ba57738e --- /dev/null +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -0,0 +1,39 @@ +use super::show::ok_cancel; +use super::{Dialog, Json, json}; + +/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. +const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; + +/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. +pub(super) fn zoom_preset(percent: f64) -> &'static str { + ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") +} + +/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and +/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage +/// selects Custom; Fit selection zooms so the selected cells fill the grid. +pub(super) fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); + ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); + for (val, text) in ZOOM_PRESETS { + if ui.radio(cur == val, text).clicked() { + d.set_value("preset", json!(val)); + } + } + if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { + d.set_value("preset", json!("fit")); + } + ui.horizontal(|ui| { + if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { + d.set_value("preset", json!("custom")); + } + // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. + let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; + if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { + d.set_value("percent", json!(percent)); + } + ui.label("%"); + }); + ok_cancel(ui, confirm, open); +} diff --git a/crates/ui-egui/src/dnd.rs b/crates/ui-egui/src/dnd.rs new file mode 100644 index 000000000..769e1b4a1 --- /dev/null +++ b/crates/ui-egui/src/dnd.rs @@ -0,0 +1,71 @@ +//! List drag and drop shared by the PivotTable Fields pane and the macro step editor: a drag +//! source that carries a payload, where a drop lands among a list's rows, the line that marks it, +//! and the label that follows the pointer. + +use egui::{DragAndDrop, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::theme::{self, Tokens}; + +/// Where a drop at `y` lands among rows laid out top to bottom. +pub fn insert_index(rows: &[Rect], y: f32) -> usize { + rows.iter().filter(|r| r.center().y < y).count() +} + +/// Starts dragging `payload` when `r` (a widget sensing drags) begins a drag, and shows the grab +/// cursor over it. +pub fn drag_source(ui: &Ui, r: &egui::Response, payload: T) { + if r.drag_started() { + DragAndDrop::set_payload(ui.ctx(), payload); + } + if r.hovered() && !DragAndDrop::has_any_payload(ui.ctx()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } +} + +/// A drag handle under a widget that only senses clicks (a checkbox): it covers where the widget +/// was last frame (stored under `id`) and is added first, so clicks still reach the widget on top. +pub fn drag_under(ui: &mut Ui, id: Id, payload: T) { + let Some(rect) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; + let r = ui.interact(rect, id, Sense::drag()); + drag_source(ui, &r, payload); +} + +/// While dragging: outlines `zone` and draws the line where a drop at `y` would land among `rows`. +pub fn drop_marker(ui: &Ui, zone: Rect, rows: &[Rect], y: f32) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("dnd_drop_marker"))); + painter.rect_stroke(zone, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let k = insert_index(rows, y); + let y = match (rows.get(k), k.checked_sub(1).and_then(|j| rows.get(j))) { + (Some(r), _) => r.top() - 2.0, + (None, Some(r)) => r.bottom() + 2.0, + (None, None) => zone.top() + 4.0, + }; + painter.line_segment([pos2(zone.left() + 4.0, y), pos2(zone.right() - 4.0, y)], Stroke::new(2.0, t.accent)); +} + +/// The dragged item's label following the pointer. +pub fn ghost(ui: &Ui, text: &str, at: Pos2) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("dnd_ghost"))); + let galley = painter.layout_no_wrap(text.to_string(), theme::ui_font(12.0), t.text); + let rect = Rect::from_min_size(at + vec2(12.0, 8.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(rect, 3.0, t.input_bg); + painter.rect_stroke(rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(rect.min + vec2(6.0, 3.0), galley, t.text); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drop_lands_before_the_first_row_below_it() { + let rows: Vec = (0..3).map(|i| Rect::from_min_size(pos2(0.0, i as f32 * 20.0), vec2(100.0, 20.0))).collect(); + assert_eq!(insert_index(&rows, -5.0), 0); + assert_eq!(insert_index(&rows, 15.0), 1); + assert_eq!(insert_index(&rows, 45.0), 2); + assert_eq!(insert_index(&rows, 500.0), 3); + assert_eq!(insert_index(&[], 10.0), 0); + } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 142084aec..3db0c3da4 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -296,127 +296,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); - let mut action: Option<(i64, i64, bool, bool)> = None; - let mut arrow = false; - let mut cancel = false; - // A message box over the editor (a data validation alert, a formula error) gets Enter and Esc; - // the editor used to take them, so the first Esc cancelled the edit behind the message. - // So does a dialog opened from the editor (Paste Name): its OK takes Enter. - if has_focus && mine && app.message.is_none() && app.dialog.is_none() { - let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { - let m = i.modifiers; - let alt_enter = - i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter); - let ctrl_shift_enter = i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::Enter); - let cmd_enter = i.consume_key(egui::Modifiers::COMMAND, Key::Enter); - let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter); - let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab); - let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape); - dismiss_completion(i, &mut ed.completion); - (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift) - }); - if alt_enter { - let b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len()); - ed.text.insert(b, '\n'); - ed.caret += 1; - ed.caret_to_end = false; - ed.sync_caret = true; - } - if !ed.autocomplete.is_empty() && tab { - ed.accept_autocomplete(); - } else if enter { - action = Some((if shift { -1 } else { 1 }, 0, false, false)); - } else if tab { - action = Some((0, if shift { -1 } else { 1 }, false, false)); - } - if cmd_enter { - action = Some((0, 0, false, true)); - } - if ctrl_shift_enter { - action = Some((0, 0, true, false)); - } - if esc { - if !ed.autocomplete.is_empty() { - ed.autocomplete.clear(); - } else if ed.completion.is_some() { - ed.completion = None; - } else { - cancel = true; - } - } - // Arrow keys: autocomplete navigation, point mode, or (Enter mode) commit and move. - let arrows = ui.input(|i| { - ( - i.key_pressed(Key::ArrowUp), - i.key_pressed(Key::ArrowDown), - i.key_pressed(Key::ArrowLeft), - i.key_pressed(Key::ArrowRight), - i.modifiers.shift, - ) - }); - let (up, down, left, right, ashift) = arrows; - if (up || down) && !ed.autocomplete.is_empty() { - ui.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, Key::ArrowUp); - i.consume_key(egui::Modifiers::NONE, Key::ArrowDown); - }); - let n = ed.autocomplete.len(); - ed.ac_index = if down { (ed.ac_index + 1) % n } else { (ed.ac_index + n - 1) % n }; - } else if ed.enter_mode && (up || down || left || right) && !ed.from_formula_bar { - let (dr, dc) = if up { - (-1, 0) - } else if down { - (1, 0) - } else if left { - (0, -1) - } else { - (0, 1) - }; - // Left/Right follow the screen on a right-to-left sheet. - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let dc = if rtl { -dc } else { dc }; - if ed.can_point() { - ui.input_mut(|i| { - for k in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] { - i.consume_key(egui::Modifiers::NONE, k); - i.consume_key(egui::Modifiers::SHIFT, k); - } - }); - crate::grid::point_move(app, &mut ed, dr, dc, ashift); - } else { - action = Some((dr, dc, false, false)); - arrow = true; - } - } - // F4 cycles $ anchors of the reference before the caret. - if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { - cycle_anchor(&mut ed); - } - // F3 opens Paste Name when the workbook has names; OK puts the name at the caret. - if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) { - app.open_dialog("pasteName", json!({"shortcut": true})); - } - // Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere, - // as in Excel, so it never falls through to the text's select-all. - if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { - let (text, caret) = (ed.text.clone(), ed.caret); - if ed.insert_argument_names(app.ui.language) - && let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) - { - // The text edit records an undo point only once typing pauses: mark the text - // before the shortcut so one Undo takes back just the inserted names. - let mut undoer = state.undoer(); - let cursor = state.cursor.char_range().unwrap_or(egui::text::CCursorRange::one(egui::text::CCursor::new(caret))); - undoer.add_undo(&(cursor, text)); - state.set_undoer(undoer); - state.store(ui.ctx(), id); - } - } - // Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too. - if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) { - app.run_or_alert("view.expandFormulaBar", json!({})); - } - } + let EditorKeyOutcome { action, arrow, cancel } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus); // The text widget. let refs = formula_refs(&ed.text); let t = Tokens::get(ui.ctx()); @@ -536,85 +416,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: gridcraft_engine::Mode::Edit }; } - // Column AutoComplete: the rest of the suggested entry, shown selected after the caret. - if let Some(full) = &ed.completion - && let Some(rest) = crate::editor::completion_rest(full, &ed.text) - { - let pos = resp.rect.left_top() + egui::vec2(output.galley.rect.width(), 0.0); - let g = ui.painter().layout_no_wrap( - rest.to_string(), - output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), - t.on_accent, - ); - let r = egui::Rect::from_min_size(pos, g.size()); - ui.painter().rect_filled(r, 0.0, t.accent); - ui.painter().galley(pos, g, t.on_accent); - } - // Autocomplete list and argument hint under the editor. Decide visibility from the area the - // editor was allotted, not from its response: egui 0.37 gives every text field a - // `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short - // cell and touch the visible quadrant even while the cell itself is hidden under a frozen row. - if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { - let below = resp.rect.left_bottom() + vec2(0.0, 4.0); - let language = app.ui.language; - // Areas have their own clip rect: keep cell helpers inside the worksheet. - let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; - egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { - if !ed.autocomplete.is_empty() { - let mut pick = None; - for (i, n) in ed.autocomplete.iter().enumerate() { - let sel = i == ed.ac_index; - let r = ui.add( - egui::Button::selectable(sel, egui::RichText::new(format!("ƒ {n}")).font(theme::ui_font(12.5))) - .min_size(vec2(220.0, 20.0)), - ); - if r.clicked() { - pick = Some(i); - } - if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) { - r.on_hover_text(desc); - } - } - if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) { - ui.separator(); - ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); - } - if let Some(i) = pick { - ed.ac_index = i; - ed.accept_autocomplete(); - ed.request_focus = true; - } - } else if let Some((name, arg)) = ed.current_function() - && let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language) - { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); - ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); - let inner = rest.trim_end_matches(')'); - let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); - for (i, part) in parts.iter().enumerate() { - let is_cur = i == arg.min(parts.len().saturating_sub(1)) || (part.contains("...") && arg >= i); - let txt = egui::RichText::new(*part).font(if is_cur { theme::ui_bold(12.0) } else { theme::ui_font(12.0) }); - ui.label(txt); - if i + 1 < parts.len() { - ui.label(egui::RichText::new(format!("{} ", ed.locale.list_separator())).font(theme::ui_font(12.0))); - } - } - ui.label(egui::RichText::new(")").font(theme::ui_font(12.0))); - }); - // The description too, so values an argument takes (DATEDIF's units, #363) show - // while it's typed; wrapped to the hint's width. - if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) { - ui.set_max_width(ui.min_rect().width().max(220.0)); - ui.separator(); - ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); - } - } - }); - }); - } + show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine }); let lines = output.galley.rows.len().max(1); let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); app.editor = Some(ed); @@ -630,6 +432,177 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: EditorInfo { lines, caret } } +type EditorAction = (i64, i64, bool, bool); + +struct EditorKeyOutcome { + action: Option, + arrow: bool, + cancel: bool, +} + +fn editor_shortcuts( + app: &mut SheetApp, + ui: &mut egui::Ui, + id: egui::Id, + ed: &mut crate::editor::EditState, + mine: bool, + has_focus: bool, +) -> EditorKeyOutcome { + let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false }; + // A message or dialog owns Enter and Escape until it closes. + if !has_focus || !mine || app.message.is_some() || app.dialog.is_some() { + return outcome; + } + let (action, cancel) = editor_confirm_keys(ui, ed); + outcome.action = action; + outcome.cancel = cancel; + let (action, arrow) = editor_arrow_keys(app, ui, ed, outcome.action); + outcome.action = action; + outcome.arrow = arrow; + editor_other_shortcuts(app, ui, id, ed); + outcome +} + +fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option, bool) { + let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { + let m = i.modifiers; + let alt_enter = i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter); + let ctrl_shift_enter = i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::Enter); + let cmd_enter = i.consume_key(egui::Modifiers::COMMAND, Key::Enter); + let enter = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::SHIFT, Key::Enter); + let tab = i.consume_key(egui::Modifiers::NONE, Key::Tab) || i.consume_key(egui::Modifiers::SHIFT, Key::Tab); + let esc = i.consume_key(egui::Modifiers::NONE, Key::Escape); + dismiss_completion(i, &mut ed.completion); + (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, m.shift) + }); + if alt_enter { + let b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len()); + ed.text.insert(b, '\n'); + ed.caret += 1; + ed.caret_to_end = false; + ed.sync_caret = true; + } + let mut action = if !ed.autocomplete.is_empty() && tab { + ed.accept_autocomplete(); + None + } else if enter { + Some((if shift { -1 } else { 1 }, 0, false, false)) + } else if tab { + Some((0, if shift { -1 } else { 1 }, false, false)) + } else { + None + }; + if cmd_enter { + action = Some((0, 0, false, true)); + } + if ctrl_shift_enter { + action = Some((0, 0, true, false)); + } + let cancel = if !esc { + false + } else if !ed.autocomplete.is_empty() { + ed.autocomplete.clear(); + false + } else if ed.completion.is_some() { + ed.completion = None; + false + } else { + true + }; + (action, cancel) +} + +fn editor_arrow_keys( + app: &mut SheetApp, + ui: &mut egui::Ui, + ed: &mut crate::editor::EditState, + mut action: Option, +) -> (Option, bool) { + // The modifiers come with the key event: an arrow pressed and released within one frame (a + // control client's or an automation tool's keystroke) has already lost them from `i.modifiers`. + let arrow = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => { + Some((*key, *modifiers)) + } + _ => None, + }) + }); + let (up, down, left, right) = ( + arrow.is_some_and(|(k, _)| k == Key::ArrowUp), + arrow.is_some_and(|(k, _)| k == Key::ArrowDown), + arrow.is_some_and(|(k, _)| k == Key::ArrowLeft), + arrow.is_some_and(|(k, _)| k == Key::ArrowRight), + ); + let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command)); + if (up || down) && !ed.autocomplete.is_empty() { + ui.input_mut(|i| { + i.consume_key(egui::Modifiers::NONE, Key::ArrowUp); + i.consume_key(egui::Modifiers::NONE, Key::ArrowDown); + }); + let n = ed.autocomplete.len(); + ed.ac_index = if down { (ed.ac_index + 1) % n } else { (ed.ac_index + n - 1) % n }; + return (action, false); + } + if !ed.enter_mode || (!up && !down && !left && !right) || ed.from_formula_bar { + return (action, false); + } + let (dr, dc) = if up { + (-1, 0) + } else if down { + (1, 0) + } else if left { + (0, -1) + } else { + (0, 1) + }; + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let dc = if rtl { -dc } else { dc }; + if ed.can_point() { + // Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow + // extends the pointed range there, as the same keys do on the selection. + ui.input_mut(|i| { + for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] { + for modifiers in + [egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT] + { + i.consume_key(modifiers, key); + } + } + }); + crate::grid::point_move(app, ed, dr, dc, shift, jump); + (action, false) + } else { + action = Some((dr, dc, false, false)); + (action, true) + } +} + +fn editor_other_shortcuts(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, ed: &mut crate::editor::EditState) { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) { + cycle_anchor(ed); + } + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) { + app.open_dialog("pasteName", json!({"shortcut": true})); + } + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) { + let (text, caret) = (ed.text.clone(), ed.caret); + if ed.insert_argument_names(app.ui.language) + && let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) + { + // Record the pre-shortcut text so one Undo removes the inserted argument names. + let mut undoer = state.undoer(); + let cursor = state.cursor.char_range().unwrap_or(egui::text::CCursorRange::one(egui::text::CCursor::new(caret))); + undoer.add_undo(&(cursor, text)); + state.set_undoer(undoer); + state.store(ui.ctx(), id); + } + } + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) { + app.run_or_alert("view.expandFormulaBar", json!({})); + } +} + fn cycle_anchor(ed: &mut crate::editor::EditState) { let caret_b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len()); for (a, b, ..) in formula_refs(&ed.text) { @@ -871,3 +844,96 @@ mod tests { } } } + +struct EditorSuggestion<'a> { + app: &'a SheetApp, + id: egui::Id, + resp: &'a egui::Response, + ed: &'a mut crate::editor::EditState, + galley: &'a egui::Galley, + tokens: &'a Tokens, + mine: bool, +} + +fn show_editor_suggestions(ui: &mut egui::Ui, suggestion: EditorSuggestion<'_>) { + let EditorSuggestion { app, id, resp, ed, galley, tokens: t, mine } = suggestion; + // Column AutoComplete: the rest of the suggested entry, shown selected after the caret. + if let Some(full) = &ed.completion + && let Some(rest) = crate::editor::completion_rest(full, &ed.text) + { + let pos = resp.rect.left_top() + egui::vec2(galley.rect.width(), 0.0); + let g = ui.painter().layout_no_wrap( + rest.to_string(), + galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), + t.on_accent, + ); + let r = egui::Rect::from_min_size(pos, g.size()); + ui.painter().rect_filled(r, 0.0, t.accent); + ui.painter().galley(pos, g, t.on_accent); + } + // Autocomplete list and argument hint under the editor. Decide visibility from the area the + // editor was allotted, not from its response: egui 0.37 gives every text field a + // `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short + // cell and touch the visible quadrant even while the cell itself is hidden under a frozen row. + if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { + let below = resp.rect.left_bottom() + vec2(0.0, 4.0); + let language = app.ui.language; + // Areas have their own clip rect: keep cell helpers inside the worksheet. + let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; + egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { + if !ed.autocomplete.is_empty() { + let mut pick = None; + for (i, n) in ed.autocomplete.iter().enumerate() { + let sel = i == ed.ac_index; + let r = ui.add( + egui::Button::selectable(sel, egui::RichText::new(format!("ƒ {n}")).font(theme::ui_font(12.5))) + .min_size(vec2(220.0, 20.0)), + ); + if r.clicked() { + pick = Some(i); + } + if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) { + r.on_hover_text(desc); + } + } + if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) { + ui.separator(); + ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); + } + if let Some(i) = pick { + ed.ac_index = i; + ed.accept_autocomplete(); + ed.request_focus = true; + } + } else if let Some((name, arg)) = ed.current_function() + && let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language) + { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); + ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); + let inner = rest.trim_end_matches(')'); + let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); + for (i, part) in parts.iter().enumerate() { + let is_cur = i == arg.min(parts.len().saturating_sub(1)) || (part.contains("...") && arg >= i); + let txt = egui::RichText::new(*part).font(if is_cur { theme::ui_bold(12.0) } else { theme::ui_font(12.0) }); + ui.label(txt); + if i + 1 < parts.len() { + ui.label(egui::RichText::new(format!("{} ", ed.locale.list_separator())).font(theme::ui_font(12.0))); + } + } + ui.label(egui::RichText::new(")").font(theme::ui_font(12.0))); + }); + // The description too, so values an argument takes (DATEDIF's units, #363) show + // while it's typed; wrapped to the hint's width. + if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) { + ui.set_max_width(ui.min_rect().width().max(220.0)); + ui.separator(); + ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); + } + } + }); + }); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 2f9a1ba6e..c5e1dd473 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -15,6 +15,44 @@ use crate::SheetApp; use crate::editor::EditState; use crate::theme::{self, Tokens}; +/// A painted pane: its row span, column span and screen rectangle. +pub(crate) type PaneQuad = ((u32, u32), (u32, u32), Rect); +/// Cached Page Break Preview ranges, keyed by (doc uid, revision, sheet). +type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>); +/// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot). +type DetailsCache = ((u64, u64, usize, usize), Option>); + +mod cell_paint; +mod cursor; +mod editor; +mod geo; +mod interact; +mod keyboard; +mod menus; +mod quadrant; +mod selection; +mod show; +mod tables; + +pub(crate) use cell_paint::paint_border; +#[cfg(test)] +pub(crate) use cell_paint::{cell_text_y, rotated_text_pos, wrapped_cell_text_y}; +#[cfg(test)] +use cell_paint::{center_across_end, display_text, look, numfmt_color, painted_halign}; +pub use editor::validation_arrow; +#[cfg(test)] +use editor::{editor_slack, editor_width, list_picker_height}; +pub use geo::Geo; +pub use interact::{follow_link, freeze_handles, point_move}; +pub(crate) use quadrant::{QuadrantPaint, paint_quadrant}; +pub use selection::selection_fill; +pub(crate) use selection::{SelectionPaintInput, paint_selection}; +pub(crate) use show::horizontal_scrollbar; +pub use show::show; +#[cfg(test)] +use tables::filter_table_range; +pub use tables::{TableHeading, filter_button_rect, heading_table, table_headings}; + pub const COL_HEADER_H: f32 = 21.0; /// Sheet units are 96-dpi pixels (as in XLSX); on screen one unit is drawn at this many points, /// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall). @@ -179,7 +217,7 @@ pub struct GridState { /// Ink stroke being drawn (sheet points). pub ink: Vec<[f32; 2]>, /// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet). - pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>, + pub pages: Option, /// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot): /// it scans the whole source list, far too slow to repeat every frame (#2113). pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>, @@ -187,7 +225,7 @@ pub struct GridState { /// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes /// the whole report. `None` when the report can't be computed or is out of date: every cell /// of it offers Show Details, and running it says why. - pub show_details_cells: Option<((u64, u64, usize, usize), Option>)>, + pub show_details_cells: Option, pub header_menu: Option<(Pos2, bool)>, /// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns). pub paper: Option, @@ -210,6 +248,8 @@ pub struct GridState { pub wheel_zoom: Option, /// A one-finger touch drag in progress: it pans the sheet instead of selecting. pub touch_pan: bool, + /// What the macro step editor shows on the grid (Check, debugging). + pub macro_overlay: Option, } /// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and @@ -238,3962 +278,6 @@ impl WheelZoom { } } -/// Geometry of the grid for one frame (handles zoom and frozen panes). -#[derive(Clone)] -pub struct Geo { - pub rect: Rect, - pub cells: Rect, - pub z: f32, - pub header_w: f32, - pub header_h: f32, - /// The outline bands (grouped rows / columns): left of the row headers and above the column - /// headers; 0 without an outline (see `outline`). - pub band_w: f32, - pub band_h: f32, - pub row_outline: Arc, - pub col_outline: Arc, - pub fr: u32, - pub fc: u32, - pub frozen_w: f32, - pub frozen_h: f32, - pub scroll: egui::Vec2, - /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" - /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s - /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by - /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). - pub rtl: bool, - /// Prefix sums over custom row heights / column widths, built once per frame. - ri: std::sync::Arc, - ci: std::sync::Arc, - /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). - pub paper: Option>, -} - -impl Geo { - pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - let z = zoom * DISPLAY_SCALE; - let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; - let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); - let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); - let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; - let digits = (last_row as f32).log10().floor() as i32 + 1; - let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - let frozen_w = (ci.start(fc) as f32) * z; - let frozen_h = (ri.start(fr) as f32) * z; - let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); - let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); - // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). - let symbols = sh.show_outline_symbols; - let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; - let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; - // Without headings the level buttons still need a corner to sit in. - if symbols && row_outline.levels > 0 && header_h == 0.0 { - band_h = band_h.max(crate::outline::lane(zoom) + 3.0); - } - if symbols && col_outline.levels > 0 && header_w == 0.0 { - band_w = band_w.max(crate::outline::lane(zoom) + 3.0); - } - let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); - Geo { - rect, - cells, - z, - header_w, - header_h, - band_w, - band_h, - row_outline, - col_outline, - fr, - fc, - frozen_w, - frozen_h, - scroll, - rtl: sh.right_to_left, - ri, - ci, - paper: None, - } - } - /// Left edge of the row headers (right of the outline band). - pub fn head_left(&self) -> f32 { - self.cells.left() - self.header_w - } - /// Top edge of the column headers (below the outline band). - pub fn head_top(&self) -> f32 { - self.cells.top() - self.header_h - } - /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale - /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). - pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { - let mut g = Geo::new(sh, rect, scroll); - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); - (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); - g.paper = Some(grid); - g - } - /// The point is over cells (in Page Layout view: on a page, inside its margins). - pub fn in_cells(&self, p: Pos2) -> bool { - if !self.cells.contains(p) { - return false; - } - match &self.paper { - Some(pg) => pg.on_cells( - self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, - self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, - ), - None => true, - } - } - /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. - pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { - match &self.paper { - Some(pg) => pg.row_y(row) as f32, - None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, - } - } - /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. - pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { - match &self.paper { - Some(pg) => pg.col_x(col) as f32, - None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, - } - } - /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. - pub fn mirror_x(&self, x: f32) -> f32 { - if self.rtl { self.rect.left() + self.rect.right() - x } else { x } - } - pub fn mirror_pos(&self, p: Pos2) -> Pos2 { - pos2(self.mirror_x(p.x), p.y) - } - pub fn mirror_rect(&self, r: Rect) -> Rect { - if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } - } - /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). - pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - self.mirror_rect(self.cell_rect(sh, c)) - } - pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; - } - if col < self.fc { - self.cells.left() + self.ci.start(col) as f32 * self.z - } else { - self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z - } - } - pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; - } - if row < self.fr { - self.cells.top() + self.ri.start(row) as f32 * self.z - } else { - self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z - } - } - pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) - } - pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { - let a = self.cell_rect(sh, r.start); - let b = self.cell_rect(sh, r.end); - Rect::from_min_max(a.min, b.max) - } - pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); - } - let dx = (x - self.cells.left()) / self.z; - if dx < self.frozen_w / self.z { - self.ci.at(dx.max(0.0) as f64) - } else { - self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) - } - } - pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); - } - let dy = (y - self.cells.top()) / self.z; - if dy < self.frozen_h / self.z { - self.ri.at(dy.max(0.0) as f64) - } else { - self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) - } - } - pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { - CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) - } - /// Visible scrolled rows (from the first visible row after the frozen ones). - fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { - let top = self.ri.start(self.fr) + self.scroll.y as f64; - let r0 = self.ri.at(top).max(self.fr); - let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); - (r0, r1) - } - fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { - let left = self.ci.start(self.fc) + self.scroll.x as f64; - let c0 = self.ci.at(left).max(self.fc); - let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); - (c0, c1) - } - /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. - /// The panes share one coordinate system, so a range that is not clipped to these is also drawn - /// over the frozen rows/columns when it is scrolled under them. - pub fn range_panes(&self, r: RangeRef) -> Vec { - let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); - let mut xs = Vec::new(); - if r.start.col < self.fc { - xs.push((self.cells.left(), split.x)); - } - if r.end.col >= self.fc { - xs.push((split.x, self.cells.right())); - } - let mut ys = Vec::new(); - if r.start.row < self.fr { - ys.push((self.cells.top(), split.y)); - } - if r.end.row >= self.fr { - ys.push((split.y, self.cells.bottom())); - } - ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() - } - /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. - pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { - let r = self.cell_rect(sh, c); - (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) - || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) - } -} - -/// Resolved look of a cell for painting. -struct Look { - style: Style, - fill: Option, - text_color: Color32, -} - -fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { - c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) -} - -fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) -} - -/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. -fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { - let mut end = start; - while end < limit && end + 1 < MAX_COLS && continues(end + 1) { - end += 1; - } - end -} - -fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { - let mut style = wb.styles.get(sh.style_id(c)).clone(); - if let Some(t) = sh.table_at(c) { - style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); - } - let cfl = cf.look(wb, si, c); - if let Some(l) = &cfl - && let Some(st) = &l.style - { - if st.fill.pattern != PatternType::None { - style.fill = st.fill; - } - if st.font.color != Color::Auto { - style.font.color = st.font.color; - } - if st.font.bold { - style.font.bold = true; - } - if st.font.italic { - style.font.italic = true; - } - if st.font.underline != Underline::None { - style.font.underline = st.font.underline; - } - if st.font.strike { - style.font.strike = true; - } - // In a differential style General means "not set" (#1163). - if st.num_fmt.as_str() != "General" { - style.num_fmt = st.num_fmt.clone(); - } - for (dst, src) in [ - (&mut style.borders.left, st.borders.left), - (&mut style.borders.right, st.borders.right), - (&mut style.borders.top, st.borders.top), - (&mut style.borders.bottom, st.borders.bottom), - ] { - if !src.is_none() { - *dst = src; - } - } - } - let mut fill = match style.fill.pattern { - PatternType::None => None, - PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), - _ => { - // Patterns: approximate with a blend of foreground and background. - let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); - let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); - Some(Color32::from_rgb( - ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, - ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, - ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, - )) - } - }; - if let Some(l) = &cfl - && let Some(rgb) = l.scale_fill - { - fill = Some(theme::color32(rgb)); - } - let text_color = col32(style.font.color, wb, Color32::BLACK); - (Look { style, fill, text_color }, cfl) -} - -/// Text for a value with General narrowing to the available width. -fn display_text( - wb: &Workbook, - sh: &Sheet, - c: CellRef, - v: &Value, - st: &Style, - width_px: f32, - char_w: f32, -) -> (String, Option, bool, Option<(char, usize)>) { - if sh.show_formulas - && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) - { - return (format!("={}", f.text), None, false, None); - } - if !sh.show_zeros && v.as_f64() == Some(0.0) { - return (String::new(), None, false, None); - } - let code = st.num_fmt.as_str(); - // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. - if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - return (String::new(), None, false, None); - } - if code == "General" - && let Value::Number(n) = v - { - let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; - return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { - Some(t) => (t, None, true, None), - None => ("#".repeat(max_chars.max(1)), None, true, None), - }; - } - let f = gridcraft_engine::display::format(v, code, wb); - (f.text, f.color.map(numfmt_color), f.numeric, f.fill) -} - -pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let available = ui.available_rect_before_wrap(); - // A right-to-left sheet has its vertical scrollbar on the left. - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); - let rect = if rtl { - Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) - } else { - Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) - }; - let resp = ui.allocate_rect(rect, Sense::click_and_drag()); - app.grid.rect = Some(rect); - let Some(d) = app.session.active() else { return }; - let wb: Arc = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let sel = d.selection.clone(); - let place = (d.uid, si); - let mut view = app.view(); - // Page Layout view: pages on a desk, rulers above and left of the headings. - let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; - let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. - let grid_rect = if rtl { - Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) - } else { - Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) - }; - let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { - if paper { - let probe = Geo::new(sh, grid_rect, scroll); - if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); - return Geo::paged(sh, grid_rect, scroll, g); - } - } - Geo::new(sh, rect, scroll) - }; - // Scrolling. - let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); - if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { - if zoom_delta != 1.0 { - // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. - let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); - let turned = zoom_delta.ln() / notch; - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if now - zooming.time > WHEEL_ZOOM_IDLE { - zooming.notches = 0.0; - } - if turned.is_finite() { - zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); - } - zooming.time = now; - // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to - // about 0. (Past half, so what's left after a step can't step back.) Steps count from - // where a glide is going, not from where it has got to. - let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); - let mut z = from; - while zooming.notches >= 0.6 { - z = wheel_zoom_step(z, true); - zooming.notches -= 1.0; - } - while zooming.notches <= -0.6 { - z = wheel_zoom_step(z, false); - zooming.notches += 1.0; - } - if z != from { - zooming.glide = Some((z, sh.zoom)); - } - app.grid.wheel_zoom = Some(zooming); - } else if scroll_delta != egui::Vec2::ZERO { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = scroll_delta; - // A horizontal swipe moves the content the way the fingers go, which is towards - // higher columns on a mirrored sheet. - if sh.right_to_left { - d.x = -d.x; - } - if modifiers.shift && d.x == 0.0 { - d = vec2(d.y, 0.0); - } - view.scroll -= d / z; - } - } - // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. - // (Two fingers pinch-zoom through `zoom_delta` above.) - if resp.drag_started() && ui.input(|i| i.any_touches()) { - app.grid.touch_pan = true; - } - if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = resp.drag_delta(); - if sh.right_to_left { - d.x = -d.x; - } - view.scroll -= d / z; - } - // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes - // there at once without animations (as for the selection outline below: View › Animations - // off, egui's off in the headless harness, or a screenshot on its way). - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if let Some((to, shown)) = zooming.glide { - let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); - let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); - let z = if glides { zoom_glide_step(shown, to, share) } else { to }; - let _ = app.session.run("view.zoom", json!({"percent": z})); - zooming.glide = (z != to).then_some((to, z)); - ui.ctx().request_repaint(); - } - // Store a glide's progress, or that it stopped. - if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { - app.grid.wheel_zoom = Some(zooming); - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); - if paper { - // Page layout units run past sheet units (margins and gaps); the end is known once the - // pages reach the last row / column. - let g = make_geo(app, view.scroll); - if let Some(pg) = &g.paper { - let (ex, ey) = pg.extent(); - max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; - max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; - } - } - view.scroll.x = view.scroll.x.min(max_x); - view.scroll.y = view.scroll.y.min(max_y); - let mut geo = make_geo(app, view.scroll); - // Keep the active cell (or the moving end of an extension) visible after keyboard moves. - if app.grid.ensure_visible { - app.grid.ensure_visible = false; - let moving = std::mem::take(&mut app.grid.reveal_moving_end); - let cell = if moving { sel.moving_end() } else { sel.active }; - let from = app.grid.reveal_from.take(); - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); - // A whole column's moving end sits on the sheet's last row (a whole row's on its last - // column): when an extension moves it only across, the view keeps its place the other way. - // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still - // follows it. - let extended = (moving && target == cell).then(|| sel.current()); - let before = view.scroll; - if paper { - crate::paper_view::ensure_covers(app, &wb, si, target); - geo = make_geo(app, view.scroll); - } - let cr = geo.cell_rect(sh, target); - let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); - if target.row >= geo.fr { - if cr.top() < area.top() { - view.scroll.y -= (area.top() - cr.top()) / geo.z; - } else if cr.bottom() > area.bottom() - 2.0 { - view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; - } - } else if from.is_some_and(|f| f.row >= geo.fr) { - // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen - // row 1) brings the scrolled rows back to their top; the rows it passed over would - // otherwise stay out of sight. - view.scroll.y = 0.0; - } - if target.col >= geo.fc { - if cr.left() < area.left() { - view.scroll.x -= (area.left() - cr.left()) / geo.z; - } else if cr.right() > area.right() - 2.0 { - view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); - } - } else if from.is_some_and(|f| f.col >= geo.fc) { - view.scroll.x = 0.0; - } - if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { - view.scroll.y = before.y; - } - if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { - view.scroll.x = before.x; - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - geo = make_geo(app, view.scroll); - } - // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). - if let Some(v) = app.view_mut() { - *v = view; - } - app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); - // The selection outline glides to a new selection. It stays on its cells when Animations is - // off, when egui's animations are (the headless harness), and while a screenshot is on its way. - // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: - // a gliding outline trails the pointer and reads as lag. - let pointer_selecting = ui.input(|i| i.pointer.any_down()); - let outline = app.grid.glide.frame(crate::selection_glide::Frame { - place, - range: sel.current(), - target: geo.range_rect(sh, sel.current()), - view: geo.cells, - now, - frame_nr: ui.ctx().cumulative_frame_nr(), - animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, - }); - if app.grid.glide.is_gliding() { - ui.ctx().request_repaint(); - } - app.grid.geo = Some(geo.clone()); - let painter = ui.painter_at(rect); - painter.rect_filled(rect, 0.0, t.grid_bg); - // Everything up to the headers is painted in logical (left-to-right) coordinates. - let mirror_from = painter.add(egui::Shape::Noop); - let mut cf = CfCache::new(); - let quads = if geo.paper.is_some() { - crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - } else { - paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - }; - // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act - // on something the user can't see. - if crate::chartview::objects_hidden(&wb) { - app.selected_chart = None; - } - let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); - paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); - if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { - paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); - } - if geo.paper.is_some() { - crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); - } - // Headers. - if sh.show_headings { - let headings = table_headings(&geo, sh, sel.active); - paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); - paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); - } - crate::outline::paint(&painter, &geo, sh, &t); - if geo.rtl { - let mirror_to = painter.add(egui::Shape::Noop); - mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); - } - if geo.paper.is_some() { - crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); - } - // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); - // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. - if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { - app.grid.touch_pan = false; - } - if app.grid.touch_pan { - // Skip the frames of a touch pan, its release included. - if !resp.dragged() { - app.grid.touch_pan = false; - } - } else { - crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); - interact(app, ui, &resp, &geo, sh, &wb); - } - update_cursor(app, ui, &resp, &geo, sh); - crate::text_box::show(app, ui, &geo, sh); - if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { - in_cell_editor(app, ui, &geo, sh, &wb); - } - filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); - list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); - header_menu(app, ui, previous_popups.2 == app.grid.header_menu); - context_menu(app, ui); - fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); - fill_options_tag(app, ui, &geo, sh); - object_menu(app, ui); - error_check_tag(app, ui, &geo, sh); - let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; - // The track starts below the column headings and frozen rows; the bar's column beside them - // takes its colour too instead of showing a light square in dark mode. - ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); - vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); - if app.session.clipboard.is_some() { - // By the clock, not per frame: a gliding outline repaints more often than the ants step. - app.grid.marching_phase = crate::selection_glide::ants_phase(now); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - } -} - -/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. -#[allow(clippy::too_many_arguments)] -fn paint_panes( - app: &SheetApp, - painter: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - cf: &mut CfCache, - t: &Tokens, -) -> Vec<((u32, u32), (u32, u32), Rect)> { - // Quadrants: (rows, cols, clip). - let (sr0, sr1) = geo.scroll_rows(sh); - let (sc0, sc1) = geo.scroll_cols(sh); - let fr_rows = (0, geo.fr.saturating_sub(1)); - let fr_cols = (0, geo.fc.saturating_sub(1)); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; - if geo.fr > 0 { - quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); - } - if geo.fc > 0 { - quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); - } - if geo.fr > 0 && geo.fc > 0 { - quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); - } - for (rows, cols, clip) in &quads { - if rows.0 > rows.1 || cols.0 > cols.1 { - continue; - } - let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); - paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); - } - // Freeze lines. - if geo.fr > 0 { - painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); - } - if geo.fc > 0 { - painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); - } - quads -} - -/// The worksheet is painted virtually, so it needs its own scrollbar rather than a -/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. -fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { - let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let visible = track.height() / geo.z; - let scroll = app.view().scroll.y; - let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); - let content = geo.scroll_top_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0).min(limit); - let id = ui.scope_id().with("vscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); - let travel = track.height() - thumb_h; - let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += resp.drag_delta().y / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.y < thumb.top() { - next -= visible; - } else if p.y > thumb.bottom() { - next += visible; - } - } - if resp.hovered() { - next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; - } - next = next.clamp(0.0, limit); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.y = next; - } - ui.ctx().request_repaint(); - } -} - -/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab -/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the -/// far end to the grid, which runs later in the same frame. -pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { - app.grid.hscroll = Some(track); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let Some(geo) = app.grid.geo.clone() else { return }; - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - let Some(sh) = wb.active() else { return }; - let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); - let scroll = app.view().scroll.x; - let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); - let content = geo.scroll_left_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0); - let id = ui.scope_id().with("hscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); - let travel = track.width() - thumb_w; - let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. - let dir = if geo.rtl { -1.0 } else { 1.0 }; - let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; - let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += dir * resp.drag_delta().x / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.x < thumb.left() { - next -= dir * visible; - } else if p.x > thumb.right() { - next += dir * visible; - } - } - if resp.hovered() { - let d = ui.input(|i| i.smooth_scroll_delta); - next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; - } - next = next.max(0.0); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.x = next; - } - ui.ctx().request_repaint(); - } -} - -pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let pad_v = 1.5 * z; - let y_top = rect.top() + pad_v; - // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways - // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. - let y_bottom = rect.bottom() - gh - pad_v; - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let top_inset = 1.0 * z; - let y_top = rect.top() - top_inset; - let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so -/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the -/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. -/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but -/// downward text at the top, where it starts reading. -pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { - let rot = egui::emath::Rot2::from_angle(angle); - let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); - let x = match h { - HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, - HAlign::Right => rect.right() - pad - indent - turned.width(), - _ => rect.left() + pad + indent, - }; - let y = match v { - VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, - VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), - _ => rect.top() + pad, - }; - pos2(x, y) - turned.min.to_vec2() -} - -/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left -/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction -/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index -/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture -/// isn't flipped. -fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range, geo: &Geo, objects: &[crate::chartview::PaintedObject]) { - let axis = geo.rect.left() + geo.rect.right(); - ctx.graphics_mut(|g| { - let list = g.entry(layer); - for i in range { - let object = objects.iter().find(|o| o.shapes.contains(&i)); - list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { - cs.clip_rect = geo.mirror_rect(cs.clip_rect); - match object { - Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), - None => mirror_shape(&mut cs.shape, axis), - } - }); - } - }); -} - -/// Reflect one shape across the vertical line `x = axis / 2`. -fn mirror_shape(shape: &mut egui::Shape, axis: f32) { - use egui::Shape; - let flip = |p: &mut Pos2| p.x = axis - p.x; - match shape { - Shape::Noop | Shape::Callback(_) => {} - Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), - Shape::Circle(c) => flip(&mut c.center), - Shape::Ellipse(e) => flip(&mut e.center), - Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), - Shape::Path(p) => { - p.points.iter_mut().for_each(flip); - // Keep the preferred (clockwise) winding for filled polygons. - p.points.reverse(); - } - Shape::Rect(r) => { - r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); - let c = r.corner_radius; - r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; - } - Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), - Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), - _ => { - let b = shape.visual_bounding_rect(); - if b.is_finite() { - shape.translate(vec2(axis - b.right() - b.left(), 0.0)); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_quadrant( - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, - t: &Tokens, -) { - let z = geo.z; - let (r0, r1) = rows; - let (c0, c1) = cols; - let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); - // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. - let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); - let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); - // Gridlines. - if sh.show_gridlines { - let stroke = Stroke::new(1.0, t.gridline); - let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); - let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; - p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); - } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; - p.line_segment([pos2(left, y), pos2(right, y)], stroke); - } - } - // Merged areas: blank them (no inner gridlines) and paint as one cell. - let mut cells: Vec = Vec::new(); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - for c in c0..=c1 { - if sh.col_width(c) == 0.0 { - continue; - } - cells.push(CellRef::new(r, c)); - } - } - let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() }; - // Backgrounds. - let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); - for &c in &cells { - let (rect, anchor) = match in_merge(c) { - Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, - Some(m) => (geo.range_rect(sh, m), m.start), - None => (geo.cell_rect(sh, c), c), - }; - let (lk, cfl) = look(wb, si, sh, anchor, cf); - if let Some(f) = lk.fill { - p.rect_filled(rect.expand(0.0), 0.0, f); - } else if in_merge(c).is_some() { - p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); - } - looks.push((anchor, rect, lk, cfl)); - } - // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty - // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). - let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - for n in [left, right].into_iter().flatten() { - if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { - let (lk, cfl) = look(wb, si, sh, n, cf); - outside.push((n, geo.cell_rect(sh, n), lk, cfl)); - } - } - } - // Text and per-cell decorations. - for (c, rect, lk, cfl) in looks.iter().chain(&outside) { - let c = *c; - if let Some(picture) = sh.cell_pictures.get(&c) { - crate::cell_pictures::paint(p, *rect, picture); - continue; - } - let v = sh.value(c); - if let Some(l) = cfl - && let Some((frac, rgb, gradient)) = l.bar - { - let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); - let col = theme::color32(rgb); - if gradient { - let mut mesh = egui::Mesh::default(); - let light = Color32::from_rgb( - ((rgb[0] as u16 + 255 * 3) / 4) as u8, - ((rgb[1] as u16 + 255 * 3) / 4) as u8, - ((rgb[2] as u16 + 255 * 3) / 4) as u8, - ); - let i = mesh.vertices.len() as u32; - for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { - mesh.colored_vertex(pt, cc); - } - mesh.add_triangle(i, i + 1, i + 2); - mesh.add_triangle(i, i + 2, i + 3); - p.add(mesh); - p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); - } else { - p.rect_filled(bar, 0.0, col); - } - } - let mut text_left = rect.left(); - if let Some(l) = cfl - && let Some((set, idx)) = &l.icon - { - let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); - paint_cf_icon(p, r, set, *idx); - text_left += 15.0 * z; - } - if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - let s = (11.0 * z * 1.2).clamp(8.0, 24.0); - let b = Rect::from_center_size(rect.center(), vec2(s, s)); - let on = v == Value::Bool(true); - p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }); - p.rect_stroke( - b, - 2.0, - Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }), - StrokeKind::Inside, - ); - if on { - crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); - } - continue; - } - if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { - continue; - } - // Align and clip header text inside the content area, away from the dropdown. - let filter_space = sh.filter_button_space(c) * z; - let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); - let rect = &text_rect; - let st = &lk.style; - let size = st.font.size * 96.0 / 72.0 * z; - let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); - let font = FontId::new(size, fam); - // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). - let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); - let avail_w = rect.right() - text_left; - let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); - if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { - text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); - } - if text.is_empty() { - continue; - } - let color = ncolor.unwrap_or(lk.text_color); - let halign = match st.align.h { - HAlign::General => match v { - Value::Number(_) => HAlign::Right, - Value::Bool(_) | Value::Error(_) => HAlign::Center, - _ if numeric => HAlign::Right, - _ => HAlign::Left, - }, - h => painted_halign(h, geo.rtl), - }; - let indent = st.align.indent as f32 * 9.0 * z; - let pad = 2.5 * z; - let rotated = st.align.rotation != 0 && st.align.rotation != 255; - // Rotated text wraps along its own direction (below), not across the column width. - if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { - let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); - let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); - job.halign = match halign { - HAlign::Center | HAlign::CenterAcross => egui::Align::Center, - HAlign::Right => egui::Align::RIGHT, - _ => egui::Align::LEFT, - }; - let galley = p.layout_job(job); - let x = match halign { - HAlign::Center | HAlign::CenterAcross => rect.center().x, - HAlign::Right => rect.right() - pad, - _ => text_left + pad + indent, - }; - let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); - p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); - continue; - } - // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. - let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); - // The fill position indexes `text`; move it back by the breaks removed before it. - let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); - // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the - // merged area): it neither spills, nor is clipped, nor turns into ####. - let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) - .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) - .flatten(); - let shrunk = small.is_some(); - let font = small.unwrap_or(font); - let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); - let mut w = galley.size().x; - // Numbers and dates never overflow: they show #### when too narrow. - let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; - if hashed { - // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. - let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); - let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; - galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); - w = galley.size().x; - } - // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps - // it: rebuilding it from the text would overflow and clip its leading digits. - if let Some((ch, pos)) = fill_char - && !hashed - && pos <= first_line.len() - { - let base_w = w; - let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); - let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; - let mut s = first_line.clone(); - if first_line.is_char_boundary(pos) { - s.insert_str(pos, &ch.to_string().repeat(n)); - } - galley = p.layout_no_wrap(s, font.clone(), color); - w = galley.size().x; - } - // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. - let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { - center_across_end(c.col, c1 + 20, |col| { - let n = CellRef::new(c.row, col); - empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross - }) - } else { - c.col - }; - // Overflow into empty neighbours for text (not upright text, whose width is its line height). - let mut clip = *rect; - let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; - if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { - let mut right = rect.right(); - let mut left = rect.left(); - if across_end > c.col { - right = geo.x(sh, across_end + 1); - } - if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { - let mut cc = c.col + 1; - while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); - cc += 1; - } - } - if matches!(halign, HAlign::Right | HAlign::Center) { - let mut cc = c.col; - while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { - cc -= 1; - left = geo.x(sh, cc); - } - } - clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); - // Paint over the gridlines the text passes through. - if lk.fill.is_none() { - let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); - let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); - let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); - if extra_l.width() > 0.0 { - p.rect_filled(extra_l, 0.0, t.grid_bg); - } - if extra_r.width() > 0.0 { - p.rect_filled(extra_r, 0.0, t.grid_bg); - } - } - } - let x = match halign { - HAlign::CenterAcross => clip.center().x - w / 2.0, - HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, - HAlign::Right => rect.right() - pad - w - indent, - _ => text_left + pad + indent, - }; - let gh = galley.size().y; - let y = cell_text_y(st.align.v, *rect, gh, z); - let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if rotated { - let angle = -(st.align.rotation as f32).to_radians(); - // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. - let galley = if st.align.wrap && st.align.rotation.abs() == 90 { - let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); - p.layout_job(job) - } else { - galley.clone() - }; - let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); - let mut shape = egui::epaint::TextShape::new(pos, galley, color); - shape.angle = angle; - cp.add(shape); - } else { - cp.galley(pos2(x, y), galley.clone(), color); - } - let baseline = y + gh * 0.82; - match st.font.underline { - Underline::None => {} - Underline::Double | Underline::DoubleAccounting => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color)); - } - _ => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - } - } - if st.font.strike { - cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color)); - } - } - // Borders (over fills and text). - for (c, rect, lk, _) in &looks { - let b = &lk.style.borders; - let merge = merges.iter().find(|m| m.start == *c); - let edges = match merge { - // A merged area's outline lives on the cells along each of its edges (Excel writes the - // right edge on the last column's cells and the bottom edge on the last row's), so each - // edge is drawn cell by cell from the cell it belongs to. - Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), - None => vec![ - (b.top, [rect.left_top(), rect.right_top()]), - (b.bottom, [rect.left_bottom(), rect.right_bottom()]), - (b.left, [rect.left_top(), rect.left_bottom()]), - (b.right, [rect.right_top(), rect.right_bottom()]), - ], - }; - for (line, pts) in edges { - if line.is_none() { - continue; - } - let col = col32(line.color, wb, Color32::BLACK); - paint_border(p, pts, line.style, col); - } - if !b.diag_down.is_none() { - paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); - } - if !b.diag_up.is_none() { - paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); - } - // Comment / note indicator. - if let Some(cm) = sh.comments.get(c) { - let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], - tri_col, - Stroke::NONE, - )); - } - // Background error checking: a number stored as text gets Excel's green corner (#1994). - if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], - t.error_check_green, - Stroke::NONE, - )); - } - } - // Sparklines. - for sp in &sh.sparklines { - if !range.contains(sp.cell) { - continue; - } - let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); - let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { - Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), - v => vec![v.as_f64()], - }; - let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); - crate::chartview::paint_prims(p, rect.min, &prims, 1.0); - } - // Filter buttons. - let mut buttons: Vec<(CellRef, bool)> = Vec::new(); - if let Some(af) = &sh.autofilter { - for col in af.range.start.col..=af.range.end.col { - let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); - buttons.push((CellRef::new(af.range.start.row, col), active)); - } - } - for tb in &sh.tables { - if tb.filter_button && tb.header_row { - for col in tb.range.start.col..=tb.range.end.col { - buttons.push((CellRef::new(tb.range.start.row, col), false)); - } - } - } - for (c, active) in buttons { - if !range.contains(c) { - continue; - } - paint_filter_button(p, filter_button_rect(geo, sh, c), active); - } -} - -pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { - let rect = geo.cell_rect(sh, c); - // Keep the button within the same header slot that AutoFit reserves. - let pad = 2.0 * geo.z; - let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); - Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) -} - -/// A Table column heading that shows the column's header name in place of its letters. -#[derive(Clone, Debug)] -pub struct TableHeading { - pub col: u32, - /// The table's header row, which the filter menu is keyed by. - pub header_row: u32, - pub name: String, - /// The heading slot over the column (logical coordinates). - pub rect: Rect, - /// The filter button, when the table shows filter buttons. - pub button: Option, - /// The column is filtered (the button shows the funnel). - pub filtered: bool, -} - -/// The Table whose header names replace the column letters: the one holding the active cell, -/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). -pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { - // Page Layout view keeps the letters. - if geo.paper.is_some() || geo.header_h <= 0.0 { - return None; - } - let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; - let header = tb.range.start.row; - // A header row in the frozen rows is always on screen. - if header < geo.fr { - return None; - } - let top = geo.cells.top() + geo.frozen_h; - let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; - (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) -} - -/// The headings of `heading_table`'s visible columns. -pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { - let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; - let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); - let mut out = Vec::new(); - for col in tb.range.start.col..=tb.range.end.col { - let w = sh.col_width(col) * geo.z; - let off = col - tb.range.start.col; - let Some(column) = tb.columns.get(off as usize) else { continue }; - if w <= 0.0 { - continue; - } - let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); - // Clear of the 4-point column resize edge, and never wider than the column. - let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); - let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); - let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); - out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); - } - out -} - -fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { - let t = Tokens::get(p.ctx()); - p.rect_filled(r, 2.0, t.sheet_control_bg); - p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); - crate::icons::paint_on_sheet( - p, - r.shrink(1.5), - if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, - t.sheet_control_text, - ); -} - -/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the -/// first row's and first column's cells, the bottom and right edges from the last row's and last -/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). -#[allow(clippy::too_many_arguments)] -fn merged_edges( - wb: &Workbook, - si: usize, - sh: &Sheet, - geo: &Geo, - m: RangeRef, - rect: Rect, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, -) -> Vec<(BorderLine, [Pos2; 2])> { - let mut out = Vec::new(); - let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); - for col in cs..=ce { - if sh.col_width(col) == 0.0 { - continue; - } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); - let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; - let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; - out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); - out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); - } - let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); - for row in rs..=re { - if sh.row_height(row) == 0.0 { - continue; - } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); - let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; - let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; - out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); - out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); - } - out -} - -/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` -/// when it already fits at its own size. -fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { - let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; - let room = room.max(1.0); - if natural.is_nan() || natural <= room { - return None; - } - // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. - let size = (font.size * room / natural * 0.97).max(1.0); - Some(FontId::new(size, font.family.clone())) -} - -pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { - let w = style.width(); - let (a, b) = (pts[0], pts[1]); - match style { - BorderStyle::Double => { - let horiz = (a.y - b.y).abs() < 0.5; - let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; - p.line_segment([a - off, b - off], Stroke::new(1.0, col)); - p.line_segment([a + off, b + off], Stroke::new(1.0, col)); - } - BorderStyle::Dashed - | BorderStyle::MediumDashed - | BorderStyle::DashDot - | BorderStyle::MediumDashDot - | BorderStyle::DashDotDot - | BorderStyle::MediumDashDotDot - | BorderStyle::SlantDashDot => { - p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); - } - BorderStyle::Dotted | BorderStyle::Hair => { - p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); - } - _ => { - p.line_segment([a, b], Stroke::new(w, col)); - } - } -} - -fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { - let n = gridcraft_engine::cf::icon_count(set); - let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; - let col = if n <= 3 { - palette3.get(idx).copied().unwrap_or(Color32::GRAY) - } else { - let t = idx as f32 / (n - 1).max(1) as f32; - Color32::from_rgb( - (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, - (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, - (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, - ) - }; - let lower = set.to_ascii_lowercase(); - if lower.contains("arrow") { - let c = r.center(); - let s = r.width() / 2.0; - let pts = if idx + 1 == n { - vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] - } else if idx == 0 { - vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] - } else { - vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] - }; - p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); - } else if lower.contains("rating") || lower.contains("quarter") { - p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); - let frac = idx as f32 / (n - 1).max(1) as f32; - p.rect_filled( - Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), - 1.0, - Color32::from_rgb(0x2B, 0x7C, 0xD3), - ); - } else { - p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); - } -} - -/// Where the active cell is on screen: the whole merged cell when it is part of one. -fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { - match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, sel.active), - } -} - -/// The rectangles shaded for a selection: every area except the active cell, which stays clear so -/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the -/// current area is drawn this frame: it glides to a new selection, and its fill with it. -pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { - selection_fill_where(geo, sh, sel, outline, |_| true) -} - -/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). -fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { - if sel.is_single_cell() { - return vec![]; - } - let active = active_rect(geo, sh, sel); - let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; - sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() -} - -/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The -/// selection fill is translucent, so the active cell can't be cleared by painting over it; the -/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, -/// are dropped. -fn rect_around(area: Rect, hole: Rect) -> impl Iterator { - // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. - let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; - let parts = if hole.is_positive() { - [ - Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), - Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), - Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), - Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), - ] - } else { - [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] - }; - parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) -} - -/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). -/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the -/// scrolling pane would otherwise be drawn over the frozen pane as well. -fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { - r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 -} - -/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). -/// -/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in -/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay -/// near the viewport do not. Sides wholly outside `clip` are left out. -pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { - if !rect.is_finite() || !clip.is_finite() { - return Vec::new(); - } - let h = width / 2.0; - let clip = clip.expand(width * 2.0); - let sides = [ - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), - ]; - sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() -} - -/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_selection( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - t: &Tokens, - app: &SheetApp, - rows: (u32, u32), - cols: (u32, u32), -) { - // Fill all areas except the active cell. - for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { - p.rect_filled(rect, 0.0, t.sel_fill); - } - let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); - let active = active_rect(geo, sh, sel); - let active_here = range_in_pane(active_range, rows, cols); - let cur_here = range_in_pane(sel.current(), rows, cols); - // Editing a formula: outline its references instead of the selection. - if let Some(ed) = &app.editor - && ed.is_formula() - && app.session.active().is_some_and(|d| d.uid == ed.doc) - { - let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); - for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { - if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { - continue; - } - if !range_in_pane(r, rows, cols) { - continue; - } - let rr = geo.range_rect(sh, r); - let col = Tokens::get(p.ctx()).ref_color(idx, true); - p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); - p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside); - for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] { - p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); - } - } - } - if cur_here { - for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { - p.rect_filled(side, 0.0, t.sel_border); - } - } - if active_here && sel.ranges.len() > 1 { - p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); - } - // Fill handle (bottom-right of the current area). - if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { - let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); - p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); - p.rect_filled(h, 0.0, t.sel_border); - } - // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag - && range_in_pane(*tr, rows, cols) - { - let rr = geo.range_rect(sh, *tr); - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(1.5, Color32::from_gray(90)), - 4.0, - 3.0, - )); - } - if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { - let r = sel.current(); - let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); - if range_in_pane(moved, rows, cols) { - let rr = geo.range_rect(sh, moved); - p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); - } - } - if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { - let x = geo.x(sh, *before); - p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); - } - // Marching ants around the copied range. - if let Some(clip) = &app.session.clipboard - && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) - && range_in_pane(clip.range, rows, cols) - { - // A multi-area copy marks each area as selected. - let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; - for area in areas { - let rr = geo.range_rect(sh, *area).expand(0.5); - let phase = app.grid.marching_phase; - let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; - p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); - } - } -} - -pub(crate) fn paint_headers( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - t: &Tokens, - quads: &[((u32, u32), (u32, u32), Rect)], - headings: &[TableHeading], -) { - let font = theme::ui_font(heading_font_size(geo.z)); - // The headers' own area: inside the outline bands. - let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); - let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); - let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); - p.rect_filled(top, 0.0, t.header_bg); - p.rect_filled(left, 0.0, t.header_bg); - let bounds = sel.bounds(); - let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); - let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); - let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); - let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); - let _ = bounds; - // Hidden columns and rows leave a double divider where they sit, as in Excel. - let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); - let mut done_cols = std::collections::HashSet::new(); - let mut done_rows = std::collections::HashSet::new(); - for (rows, cols, clip) in quads { - let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); - if cols.0 <= cols.1 { - for c in cols.0..=cols.1 { - if !done_cols.insert((c, clip.left() as i32)) { - continue; - } - let w = sh.col_width(c) * geo.z; - if w <= 0.0 { - continue; - } - let x = geo.x(sh, c); - let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); - if col_sel(c) { - pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); - pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); - } - pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(c, |i| sh.col_width(i)); - for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { - for dx in [-1.5, 1.5] { - pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); - } - } - let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - match headings.iter().find(|h| h.col == c) { - Some(h) => { - // The name is cut off at the button, as a cell's text is at its neighbour. - let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; - let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); - let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); - pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); - if let Some(b) = h.button { - paint_filter_button(&pc, b, h.filtered); - } - } - None => { - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); - } - } - } - } - let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); - if rows.0 <= rows.1 { - for rr in rows.0..=rows.1 { - if !done_rows.insert((rr, clip.top() as i32)) { - continue; - } - let h = sh.row_height(rr) * geo.z; - if h <= 0.0 { - continue; - } - let y = geo.y(sh, rr); - let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); - if row_sel(rr) { - pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); - pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); - } - pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(rr, |i| sh.row_height(i)); - for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { - for dy in [-1.5, 1.5] { - pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); - } - } - let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; - pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); - } - } - } - // Corner (select all) and edges. - let corner = Rect::from_min_max(r.min, geo.cells.min); - p.rect_filled(corner, 0.0, t.header_bg); - let tri = vec![ - pos2(corner.right() - 3.0, corner.top() + 5.0), - pos2(corner.right() - 3.0, corner.bottom() - 3.0), - pos2(corner.left() + 7.0, corner.bottom() - 3.0), - ]; - p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); - p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); - p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); -} - -/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges -/// get the hidden marker. Line 0 has nothing before it. -fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { - (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) -} - -// ------------------------------------------------------------------ interaction - -/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. -const OBJECT_RESIZE_RADIUS: f32 = 10.0; - -/// Decide after interaction so drag start and release use the new state in the same frame. -fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx(); - let pointer = ctx.input(|i| i.pointer.clone()); - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Hover bookkeeping also serves cell notes, independently of who owns the cursor. - if resp.hovered() - && let Some(p) = pos - && !freeze_cols.contains(p) - && !freeze_rows.contains(p) - && !matches!(app.grid.drag, Drag::Freeze { .. }) - { - app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); - } - // Draw tools own the cursor over cells, including while recording a stroke. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - return; - } - // A held drag owns the cursor even outside the grid; a press below egui's threshold still - // has Drag::None and uses hover. A click can briefly leave a drag state after release. - if resp.dragged() - && pointer.primary_down() - && let Some(cursor) = drag_cursor(&app.grid.drag) - { - ctx.set_cursor_icon(cursor); - return; - } - if !resp.hovered() { - return; - } - let Some(p) = pos else { return }; - hover_cursor(app, ctx, resp, &pointer, geo, sh, p); -} - -/// The cursor a held drag shows, or `None` when the drag leaves it to hover. -fn drag_cursor(drag: &Drag) -> Option { - match drag { - Drag::None => None, - Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), - Drag::Fill { .. } => Some(CursorIcon::Crosshair), - Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), - Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), - Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), - Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), - Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), - } -} - -/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. -fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { - let (freeze_cols, freeze_rows) = freeze_handles(geo); - if freeze_cols.contains(p) || freeze_rows.contains(p) { - freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); - return; - } - let in_cells = geo.in_cells(p); - let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); - let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - if app.grid.drag == Drag::None && near_corner { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { - header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); - } else if on_handle && app.editor.is_none() { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.grid.drag == Drag::None && !pointer.any_down() { - resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); - } - } else if in_cells && app.editor.is_none() && on_cur_border { - ctx.set_cursor_icon(CursorIcon::Move); - } else if in_cells { - ctx.set_cursor_icon(CursorIcon::Cell); - } else if in_col_header { - let col = geo.col_at(sh, p.x); - let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); - ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); - } -} - -/// The resize cursor for dragging a freeze bar. -fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); - } -} - -/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel -/// shows a split cursor: dragging or double-clicking there unhides it. -fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { - let hidden = - app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; - match pointer.hover_pos() { - Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), - _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), - } -} - -fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { - // Text selection and editing must not also move the object or edit worksheet cells. - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx().clone(); - // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. - let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); - let pointer = ctx.input(|i| i.pointer.clone()); - let mods = ctx.input(|i| i.modifiers); - // Hit-testing works in logical coordinates (see `Geo::rtl`). - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); - let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); - let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - // The fill handle and the move border only react in the panes where they are painted, not - // over the frozen rows/columns that a scrolled selection lies under. - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.session.draw_tool == "pen" { - if let Some(p) = pos - && resp.dragged() - { - // Through the cell under the pointer, so frozen panes and pages map right. - let c = geo.cell_at(sh, p); - let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; - let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; - app.grid.ink.push([sx, sy]); - } - if !app.grid.ink.is_empty() { - // Live preview. - let pts: Vec = app - .grid - .ink - .iter() - .map(|q| { - let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); - geo.mirror_pos(pos2( - geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, - geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, - )) - }) - .collect(); - ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); - } - if resp.drag_stopped() && app.grid.ink.len() >= 2 { - let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); - let _ = app.run("draw.stroke", json!({"points": pts})); - } - return; - } - if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { - if let Some(p) = pos - && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) - { - let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); - } - return; - } - } - // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear - // button resets it. Runs before the generic object select/drag so tiles stay clickable. - if primary_clicked - && !resp.drag_started() - && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) - { - app.selected_chart = Some(id); - match act { - crate::chartview::SlicerAction::Toggle(value) => { - let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); - } - crate::chartview::SlicerAction::Clear => { - let _ = app.run("slicer.clear", json!({"slicer": id})); - } - } - return; - } - // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves - // them: a right-click that wobbles into a drag still opens the object's menu (#2067). - let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); - if primary_drag || primary_clicked { - // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; - if let Some(p) = at - && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - if kind == "shape" - && resp.double_clicked() - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) - { - app.begin_text_box_edit(id); - return; - } - if kind == "chart" && resp.double_clicked() { - app.grid.pane = Some("formatChart".into()); - } - if primary_drag { - let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; - if let Some(a) = a { - app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; - } - } - return; - } - app.selected_chart = None; - } - // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's - // under it; a right press that wobbles into a drag counts too (#2067). - let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); - if (resp.secondary_clicked() || secondary_drag) - && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } - && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); - return; - } - - // A double-click is also a click: handle header AutoFit before resize setup returns. - if resp.double_clicked() - && let Some(p) = pos - { - let unhide = if in_col_header { - hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) - } else if in_row_header { - hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) - } else { - None - }; - let autofit = if unhide.is_some() { - unhide - } else if in_col_header { - col_edge(geo, sh, p.x).map(|(col, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); - ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) - }) - } else if in_row_header { - row_edge(geo, sh, p.y).map(|(row, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); - ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) - }) - } else { - None - }; - if let Some((command, params)) = autofit { - app.grid.drag = Drag::None; - app.grid.drag_select = false; - app.run_or_alert(command, params); - resp.request_focus(); - return; - } - } - - // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); - let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - app.grid.drag = Drag::None; - app.grid.drag_select = false; - resp.request_focus(); - return; - } - - // Press: decide the drag mode. - if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { - // Where the press began (a drag is only recognised after the pointer has moved). - let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; - // The outline band's − / + and level buttons act on release, like buttons. - if let Some(action) = crate::outline::hit(geo, sh, p) { - if primary_clicked { - let (command, params) = action.command(); - app.run_or_alert(command, params); - } - return; - } - // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). - if freeze_cols.contains(p) || freeze_rows.contains(p) { - app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; - return; - } - if in_corner { - let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); - return; - } - if in_col_header { - if let Some((c, x)) = col_edge(geo, sh, p.x) { - app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; - return; - } - let c = geo.col_at(sh, p.x); - // A Table heading's filter button, while the header names stand in for the letters. - if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { - app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); - return; - } - if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { - return; - } - // An armed Format Painter paints the columns dragged over instead of moving them. - if resp.drag_started() - && !mods.any() - && app.session.format_painter.is_none() - && sel.ranges.len() == 1 - && sel.current().is_full_cols() - && sel.current().contains(CellRef::new(0, c)) - { - app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; - return; - } - let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; - select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); - app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); - return; - } - if in_row_header { - if let Some((r, y)) = row_edge(geo, sh, p.y) { - app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; - return; - } - let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { - return; - } - let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; - select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); - app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); - return; - } - if !in_cells { - return; - } - let c = geo.cell_at(sh, p); - // Data validation list arrow next to the active cell. - if let Some(r) = validation_arrow(sh, geo, sel.active) - && r.contains(p) - { - app.grid.list_picker = Some(sel.active); - return; - } - // Filter dropdown buttons. - if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); - return; - } - // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the - // selection, which the dialog acts on, stays. - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(c, &sh.name, false); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - // Point mode while editing a formula: clicking inserts a reference. - if let Some(ed) = app.editor.as_mut() - && ed.can_point() - { - let anchor = if mods.shift && ed.point_at_caret() { - ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) - } else { - c - }; - let range = RangeRef::new(anchor, c); - let text = if range.is_single() { c.a1() } else { range.a1() }; - let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); - let r = match sheet_name { - Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), - None => text, - }; - if mods.command && !mods.shift { - ed.append_ref(&r); - } else { - ed.insert_ref(&r); - } - ed.request_focus = true; - ed.point_cell = Some(c); - ed.point_anchor = Some((wb.active_sheet(), anchor)); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { - return; - } - if on_handle(p) { - app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; - return; - } - if on_cur_border(p) && resp.drag_started() { - app.grid.drag = Drag::Move { grab: c, to: None }; - return; - } - // Double-click edits. - let now = crate::now_ms(); - if let Some((last, at)) = app.grid.last_click - && last == c - && now - at < 450.0 - && !mods.shift - { - app.grid.last_click = None; - // A PivotTable's value cell shows its source records on a new sheet (Excel's Show - // Details); its labels and headers still edit. - if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { - let not_a_value_cell = - gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); - match app.run("pivot.showDetails", json!({"cell": c.a1()})) { - Ok(_) => return, - Err(e) if e == not_a_value_cell => {} - Err(e) => { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - } - } - // Following a hyperlink requires a single click in Excel; double-click edits. - app.begin_edit(None, false); - return; - } - app.grid.last_click = Some((c, now)); - if mods.shift { - let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = sel.anchor; - } - } else if mods.command { - // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.alt - && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 - { - follow_link(app, &h.target); - } else { - select_ranges(app, RangeRef::cell(c), true, c); - } - } else { - let _ = app.session.run("selection.set", json!({"cell": c.a1()})); - if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { - let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); - } - // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.any() - { - let r = geo.cell_rect(sh, c); - if p.x < r.left() + r.width() * 0.8 { - follow_link(app, &h.target); - } - } - } - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - // The first recognised drag already includes movement away from the press. - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - - // Dragging. - if resp.dragged() - && let Some(p) = pos - { - // Auto-scroll near edges. - let edge = 24.0; - let mut auto = egui::Vec2::ZERO; - if p.y > geo.cells.bottom() - edge { - auto.y = 20.0; - } - if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { - auto.y = -20.0; - } - if p.x > geo.cells.right() - edge { - auto.x = 30.0; - } - if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { - auto.x = -30.0; - } - let pc = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - match app.grid.drag.clone() { - Drag::Select => { - select_to_pointer(app, geo, sh, wb, p); - scroll_by(app, auto); - } - Drag::Rows(anchor) => { - let r = geo.row_at(sh, p.y); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(0.0, auto.y)); - } - Drag::Cols(anchor) => { - let c = geo.col_at(sh, p.x); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::MoveCols { source, .. } => { - let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); - let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; - let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); - let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); - app.grid.drag = Drag::MoveCols { source, before }; - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::ResizeCol { col, start, orig } => { - let w = (orig + (p.x - start) / geo.z).max(0.0); - app.toast = - Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); - // Inside a selection of whole columns, every selected column takes the new width - // (Ctrl-selected areas included); otherwise only the dragged one. - let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") - } else { - RangeRef::cols(col, col).a1() - }; - let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); - coalesce_last_undo(app); - } - Drag::ResizeRow { row, start, orig } => { - let h = (orig + (p.y - start) / geo.z).max(0.0); - app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); - let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - sel.ranges - .iter() - .filter(|r| r.is_full_rows()) - .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) - .collect::>() - .join(",") - } else { - format!("{}:{}", row + 1, row + 1) - }; - let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); - coalesce_last_undo(app); - } - Drag::Freeze { cols, .. } => { - app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; - } - Drag::Fill { menu, .. } => { - let src = sel.current(); - let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target), menu }; - scroll_by(app, auto); - } - Drag::Move { grab, .. } => { - let src = sel.current(); - let dr = pc.row as i64 - grab.row as i64; - let dc = pc.col as i64 - grab.col as i64; - app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; - scroll_by(app, auto); - } - Drag::Object { kind, id, start, orig, resize } => { - let d = (p - start) / geo.z; - let (cell, dx, dy, w, h) = orig; - let params = if resize { - json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) - } else { - // Re-anchor at the cell under the new top-left. - let x0 = sh.col_left(cell.col) as f32 + dx + d.x; - let y0 = sh.row_top(cell.row) as f32 + dy + d.y; - let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); - json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) - }; - let _ = app.session.run("object.move", params); - coalesce_last_undo(app); - } - Drag::None => {} - } - } - - // Release. - if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { - match std::mem::take(&mut app.grid.drag) { - Drag::Select => { - if resp.drag_stopped() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - format_painter_apply(app); - } - // Whole rows or columns clicked or dragged over by their headers. - Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), - // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is - // spent on the drag so the grid's Esc doesn't also drop the copy. - Drag::Fill { target: Some(t), menu: true } => { - if let Some(p) = pos - && t != sel.current() - && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); - } - } - Drag::Fill { target: Some(t), menu: false } => { - let src = sel.current(); - if t != src { - // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. - let mode = if mods.command { "alternate" } else { "series" }; - match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, src, t), - Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), - } - } - } - Drag::Freeze { cols, to: Some(to) } => { - let (rows_now, cols_now) = (geo.fr, geo.fc); - let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; - if (rows, cols) != (rows_now, cols_now) { - // A1 (nothing left frozen) unfreezes. - app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); - } - } - Drag::Move { to: Some(to), .. } => { - let src = sel.current(); - if to != src.start { - let _ = app.session.run("edit.cut", json!({"range": src.a1()})); - let _ = app.run("edit.paste", json!({"at": to.a1()})); - } - } - Drag::MoveCols { source, before: Some(before) } => { - if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - _ => {} - } - app.grid.drag_select = false; - } - - // Right-click: context menu. - if resp.secondary_clicked() - && let Some(p) = pos - { - if in_cells && !sel.contains(geo.cell_at(sh, p)) { - let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); - } - if in_col_header || in_row_header { - let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; - let inside = sel.ranges.iter().any(|r| { - if rows { - r.is_full_rows() && idx >= r.start.row && idx <= r.end.row - } else { - r.is_full_cols() && idx >= r.start.col && idx <= r.end.col - } - }); - if !inside { - let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; - let _ = app.session.run("selection.set", json!({"range": range.a1()})); - } - app.grid.header_menu = Some((geo.mirror_pos(p), rows)); - } else { - app.grid.context_menu = Some(geo.mirror_pos(p)); - app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); - } - } - - if primary_clicked || resp.drag_started() { - resp.request_focus(); - } - keyboard(app, &ctx, resp, geo, sh); -} - -/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while -/// pointing a formula reference, which also drags a Select). -fn format_painter_apply(app: &mut SheetApp) { - if app.session.format_painter.is_some() - && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) - && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) - { - let dest = app.session.active().map(|d| d.selection.current().a1()); - if let Some(dest) = dest - && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) - { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } -} - -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. -fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { - let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; - let edit_sheet = ed.sheet; - let active_sheet = wb.active_sheet(); - let range = if extend { - ed.point - .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) - .and_then(|(_, _, previous, previous_sheet, _)| { - let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); - if previous_sheet != Some(active_sheet) { - return None; - } - if range.is_full_cols() && previous.is_full_cols() { - Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) - } else if range.is_full_rows() && previous.is_full_rows() { - Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) - } else { - None - } - }) - .unwrap_or(range) - } else { - range - }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; - if add { - ed.append_ref(&reference); - } else { - ed.insert_ref(&reference); - } - ed.point_anchor = None; - ed.point_cell = Some(range.start); - ed.request_focus = true; - true -} - -fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { - let cell = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(cell, &sh.name, true); - } else if let Some(ed) = app.editor.as_mut() - && ed.point.is_some() - { - if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { - let range = RangeRef::new(start, cell); - let text = if range.is_single() { range.start.a1() } else { range.a1() }; - let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; - ed.insert_ref(&text); - ed.point_cell = Some(cell); - ed.point_anchor = Some((wb.active_sheet(), start)); - } - } else if let Some(d) = app.session.active() { - // Read the current anchor: starting a drag may have just replaced the selection. - let anchor = d.selection.anchor; - let active = d.selection.active; - let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); - ranges.pop(); - ranges.push(RangeRef::new(anchor, cell).a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = anchor; - } - } -} - -fn coalesce_last_undo(app: &mut SheetApp) { - // Merge consecutive drag steps into one undo entry. - if let Some(d) = app.session.active_mut() - && d.undo.len() >= 2 - { - let n = d.undo.len(); - if d.undo[n - 1].label == d.undo[n - 2].label { - d.undo.remove(n - 1); - } - } -} - -fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { - if d != egui::Vec2::ZERO - && let Some(v) = app.view_mut() - { - v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); - } -} - -fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { - let below = pc.row as i64 - src.end.row as i64; - let above = src.start.row as i64 - pc.row as i64; - let right = pc.col as i64 - src.end.col as i64; - let left = src.start.col as i64 - pc.col as i64; - let vert = below.max(above); - let horiz = right.max(left); - if vert <= 0 && horiz <= 0 { - return src; - } - if vert >= horiz { - if below > 0 { - RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) - } else { - RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) - } - } else if right > 0 { - RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) - } else { - RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) - } -} - -fn on_border(r: Rect, p: Pos2) -> bool { - let outer = r.expand(3.0); - let inner = r.shrink(3.0); - outer.contains(p) && !inner.contains(p) -} - -/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip -/// on the frozen columns' right edge, and one in the left part of the row-header strip on the -/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the -/// header edge keeps resizing the column or row. -pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { - let x = geo.cells.left() + geo.frozen_w; - let y = geo.cells.top() + geo.frozen_h; - let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); - let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); - (cols, rows) -} - -/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest -/// the pointer, limited so the frozen pane leaves room for the scrolling one. -fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { - if cols { - if p.x <= geo.cells.left() { - return 0; - } - let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); - let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; - let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_COLS - 1) - } else { - if p.y <= geo.cells.top() { - return 0; - } - let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); - let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; - let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_ROWS - 1) - } -} - -/// Paints the freeze handles and, while one is dragged, where the freeze line will go. -fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { - let (cols, rows) = freeze_handles(geo); - let bar = Color32::from_gray(150); - p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); - p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); - if let Drag::Freeze { cols, to: Some(to) } = *drag { - let stroke = Stroke::new(2.0, t.accent); - if cols { - let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; - p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); - } else { - let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; - p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); - } - } -} - -fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { - let c = geo.col_at(sh, x); - for cc in [c, c.saturating_sub(1)] { - let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; - if (x - edge).abs() <= 4.0 { - return Some((cc, edge)); - } - } - None -} - -fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { - let r = geo.row_at(sh, y); - for rr in [r, r.saturating_sub(1)] { - let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; - if (y - edge).abs() <= 3.0 { - return Some((rr, edge)); - } - } - None -} - -/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the -/// edge just left of it belongs to the visible column there. -fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { - col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) -} - -fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { - row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) -} - -/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which -/// has no such shape. Black on a white outline, like the system cursors. -fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { - ctx.set_cursor_icon(CursorIcon::None); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); - // Drawn for columns (bars vertical); rows swap the axes. - let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; - let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; - for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { - let stroke = Stroke::new(width, color); - for bx in [-1.5, 1.5] { - p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); - } - p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); - for dir in [-1.0, 1.0] { - p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); - } - } -} - -fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { - let c = geo.cell_at(sh, p); - sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) -} - -fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { - match kind { - "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), - "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), - "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), - _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), - } -} - -fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { - let Some(id) = app.selected_chart else { return false }; - let kind = if sh.charts.iter().any(|object| object.id == id) { - "chart" - } else if sh.images.iter().any(|object| object.id == id) { - "image" - } else if sh.shapes.iter().any(|object| object.id == id) { - "shape" - } else { - return false; - }; - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - true -} - -/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's -/// OK runs `link.open`); every other kind is refused with a message. The decision is the -/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. -pub fn follow_link(app: &mut SheetApp, target: &str) { - use gridcraft_engine::links::{LinkKind, classify}; - match classify(target) { - LinkKind::Internal(r) => { - let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); - app.grid.ensure_visible = true; - } - LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), - LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), - } -} - -/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already -/// selected (unless it is the only one, so the selection never ends up empty). -fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { - if !add { - return vec![clicked]; - } - if ranges.len() > 1 && ranges.contains(&clicked) { - ranges.retain(|x| *x != clicked); - } else if !ranges.contains(&clicked) { - ranges.push(clicked); - } - ranges -} - -fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { - let current: Vec = - if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; - let clicked = r.a1(); - let ranges = toggled_ranges(current, clicked.clone(), add); - // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. - let params = - if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; - let _ = app.session.run("selection.set", params); -} - -/// Grid keyboard handling when no text field has focus. -fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; - let editing = app.editor.is_some() || app.text_box_editor.is_some(); - if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { - resp.request_focus(); - } - let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); - // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). - if editing - || app.dialog.is_some() - || other_focus - || app.message.is_some() - || app.grid.filter_menu.is_some() - || app.grid.fill_menu.is_some() - || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) - { - return; - } - if ctx.memory(|m| m.focused()).is_none() { - resp.request_focus(); - } - // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). - ctx.memory_mut(|m| { - m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); - let events = ctx.input(|i| i.events.clone()); - // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. - let shortcut_pressed = events.iter().any(|e| { - matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } - if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) - }); - let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; - // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once - // a shortcut ran, that text must not start an edit over what it just did. - let mut shortcut_ran = false; - for ev in events { - match ev { - egui::Event::Copy => { - app.copy_to_clipboard(ctx, "edit.copy"); - } - egui::Event::Cut => { - app.copy_to_clipboard(ctx, "edit.cut"); - } - egui::Event::Paste(text) => app.paste_external(text), - // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard - // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a - // floating picture without a chosen anchor/size would be a new feature, not parity. - // A text representation, when the clipboard has one, still arrives as `Paste` above. - egui::Event::PasteImage(_) => {} - // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to - // insert text at, so a middle click on a cell is deliberately not a paste. - egui::Event::MiddleClickPaste { .. } => {} - egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} - egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { - app.begin_edit(Some(text), false); - if let Some(ed) = app.editor.as_mut() { - ed.caret = ed.text.chars().count(); - } - return; - } - egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - use egui::Key; - if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { - shortcut_ran = true; - continue; - } - let cmd = m.command; - let shift = m.shift; - let mv = |app: &mut SheetApp, dr: i64, dc: i64| { - let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); - let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); - app.grid.ensure_visible = true; - app.grid.reveal_moving_end = shift; - app.grid.reveal_from = from; - }; - match key { - Key::ArrowUp => mv(app, -1, 0), - Key::ArrowDown => mv(app, 1, 0), - // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. - Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), - Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), - Key::PageDown if !cmd => mv(app, page, 0), - Key::PageUp if !cmd => mv(app, -page, 0), - Key::PageDown => app.run_or_alert("sheet.next", json!({})), - Key::PageUp => app.run_or_alert("sheet.previous", json!({})), - Key::Home if cmd => { - let r = sh.freeze.map(|f| f.0).unwrap_or(0); - let c = sh.freeze.map(|f| f.1).unwrap_or(0); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); - app.grid.ensure_visible = true; - } - Key::End if cmd => { - let end = sh.used_range().map(|u| u.end).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": end.a1()})); - app.grid.ensure_visible = true; - } - Key::Home => { - let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); - app.grid.ensure_visible = true; - } - // Excel: Enter while a copied range is marked pastes it and ends copy mode. - Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { - app.run_or_alert("edit.paste", json!({})); - app.session.clipboard = None; - app.grid.ensure_visible = true; - } - Key::Enter => { - if m.alt || m.ctrl { - app.begin_edit(None, false); - } else { - app.move_after_enter(if shift { -1 } else { 1 }, 0); - } - } - Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), - Key::F2 => app.begin_edit(None, false), - Key::Delete if delete_selected_object(app, sh) => {} - Key::Delete => app.run_or_alert("edit.clearContents", json!({})), - Key::Backspace if delete_selected_object(app, sh) => {} - Key::Backspace => { - // Excel for Mac: Delete clears the cell and starts editing it. - app.run_or_alert("edit.clearContents", json!({})); - if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { - app.begin_edit(Some(String::new()), false); - } - } - Key::Escape => { - app.session.clipboard = None; - app.session.format_painter = None; - } - Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), - Key::F8 if m.alt => { - app.open_dialog("macros", json!({})); - } - Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), - Key::F4 if !cmd => {} - _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { - crate::ribbon::shortcut(app, key, m) - } - _ => {} - } - } - _ => {} - } - } -} - -/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so -/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, -/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. -fn editor_slack(size: f32) -> f32 { - 14.0 + size -} - -/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the -/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. -fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { - if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } -} - -/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. -fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { - let Some(ed) = app.editor.as_ref() else { return }; - if app.view_key() != Some((ed.doc, ed.sheet)) { - return; - } - let cell = ed.cell; - let rect = match sh.merge_at(cell) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, cell), - }; - let st = wb.styles.get(sh.style_id(cell)).clone(); - let size = st.font.size * 96.0 / 72.0 * geo.z; - let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); - let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; - // Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar - // is the one that stops at five lines). - let shown = lines; - // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. - // Otherwise it widens to the text's one-line width. - let wrap = st.align.wrap; - let (w, h) = if wrap { - // The field wraps at the width it is given (the editor's less its 2 px inset a side): its - // frame is `Frame::NONE`, so no text margin is taken off. - let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); - (rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height())) - } else { - let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; - // Excel keeps the cell's height for one line and only grows for extra lines. - let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) - }; - // Grows to the left on a right-to-left sheet. - let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); - let fill = match st.fill.pattern { - PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), - _ => Color32::WHITE, - }; - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let quadrant = Rect::from_min_max( - pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), - pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), - ); - // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), - // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and - // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's - // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its - // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The - // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but - // Wrap Text can expand the editor below/right of it, which must not paint over the next line. - let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; - // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. - let screen_quadrant = geo.mirror_rect(quadrant); - let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); - let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; - let painter = ui.painter().with_clip_rect(clip); - painter.rect_filled(erect, 0.0, fill); - painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); - let id = egui::Id::unique("gridcraft.cell_editor"); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); - child.set_clip_rect(clip); - // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark - // theme, on the white paper): pick black or white against the fill, like Excel. - child.visuals_mut().text_cursor.stroke.color = ink_on(fill); - crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); -} - -/// Test the popup itself, not whether the pointer is over any part of the app. -/// A trigger can sit outside its popup, so its opening click must be ignored. -fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { - response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) -} - -/// Black or white, whichever reads on `fill` (the threshold chart labels use). -fn ink_on(fill: Color32) -> Color32 { - let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; - if l < 150.0 { Color32::WHITE } else { Color32::BLACK } -} - -/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. -fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { - sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) -} - -/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. -fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, row, at)) = app.grid.filter_menu else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let _ = geo; - // Tables get an autofilter on demand. - let table_range = filter_table_range(sh, col, row); - if sh.autofilter.is_none() - && let Some(r) = table_range - { - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) - && !sh2 - { - let d = app.session.active_mut(); - if let Some(d) = d { - let wbm = Arc::make_mut(&mut d.wb); - if let Some(s) = wbm.sheet_mut(si) { - s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); - } - } - } - } - let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); - let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); - let key = egui::Id::unique(("filter_sel", col)); - let search_key = egui::Id::unique(("filter_search", col)); - let buttons_key = egui::Id::unique(("filter_buttons", col)); - // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its - // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. - let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); - let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); - let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let mut close = false; - let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); - let response = area.show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_width(230.0); - let ascending = ui.button("↑ Sort Ascending"); - if ascending.clicked() { - let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); - close = true; - } - let descending = ui.button("↓ Sort Descending"); - if descending.clicked() { - let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); - close = true; - } - ui.separator(); - ui.label(egui::RichText::new("Filter").strong()); - let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); - // A search narrows the list; Select All and Apply then only cover the matches. - let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); - let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); - if !was_open { - field.request_focus(); - } - let needle = search.trim().to_lowercase(); - let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); - let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); - let mut all_new = all; - let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; - if ui.checkbox(&mut all_new, label).changed() { - for c in checks.iter_mut().filter(|(v, _)| matches(v)) { - c.1 = all_new; - } - } - // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only - // worked over the values themselves (#432). - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { - for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { - ui.checkbox(on, v.as_str()); - } - }); - ui.ctx().data_mut(|d| { - d.insert_temp(key, checks.clone()); - d.insert_temp(search_key, search.clone()); - }); - let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); - ui.separator(); - ui.horizontal(|ui| { - let clear = ui.button("Clear Filter"); - if clear.clicked() { - let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); - close = true; - } - // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. - let any = checks.iter().any(|(_, on)| *on); - let apply = ui.button("Apply"); - ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); - if apply.clicked() || (enter && any) { - let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); - let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); - let p = if checks.iter().all(|(_, b)| *b) { - json!({"column": col_to_letters(col), "clear": true}) - } else { - json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) - }; - let _ = app.run("data.filterBy", p); - close = true; - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - ui.ctx().data_mut(|d| { - d.remove::>(key); - d.remove::(search_key); - d.remove::>(buttons_key); - }); - app.grid.filter_menu = None; - } -} - -/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. -fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some(at) = app.grid.context_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("Paste Special…", "ui:pasteSpecial"), - ("-", ""), - ("Insert…", "ui:insertCells"), - ("Delete…", "ui:deleteCells"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Filter by Selected Cell's Value", "ui:filterValue"), - ("Sort A to Z", "data.sortAscending"), - ("Sort Z to A", "data.sortDescending"), - ("-", ""), - ("New Comment", "ui:newComment"), - ("New Note", "ui:newNote"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Pick From Drop-down List…", "ui:pickList"), - ("Define Name…", "ui:defineName"), - ("Link…", "ui:insertLink"), - ]; - if let Some(item) = insert_copied { - items.insert(6, item); - } - if show_details_offered(app) - && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") - { - items.insert(i + 1, ("-", "")); - items.insert(i + 2, ("Show Details", "pivot.showDetails")); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, id) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match id { - "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:insertCells" => app.open_dialog("insertCells", json!({})), - "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), - "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), - "ui:defineName" => app.open_dialog("defineName", json!({})), - "ui:insertLink" => app.open_dialog("insertLink", json!({})), - "ui:pickList" => app.open_dialog("pickList", json!({})), - "ui:filterValue" => { - if let Some(d) = app.session.active() { - let a = d.selection.active; - let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); - let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); - if !has { - let _ = app.run("data.filter", json!({})); - } - app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); - } - } - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - // A quick right-click presses and releases in one frame, and the menu's corner snaps to the - // nearest physical pixel, which can put the pointer just outside it: that press opened the - // menu, so it doesn't count as a click elsewhere. - let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (!opened_now - && ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.context_menu = None; - } -} - -/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell -/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of -/// date, where running the command says so. Labels and headers of a current report don't offer it, -/// and no cell does while the PivotTable's Enable show details option is off. -fn show_details_offered(app: &mut SheetApp) -> bool { - let Some(d) = app.session.active() else { return false }; - let (si, at) = (d.wb.active_sheet(), d.selection.active); - let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) - else { - return false; - }; - if !pt.enable_show_details { - return false; - } - let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); - let key = (d.uid, d.revision, si, pi); - if let Some((k, cells)) = &app.grid.show_details_cells - && *k == key - { - return cells.as_ref().is_none_or(|c| c.contains(&cell)); - } - let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); - let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); - app.grid.show_details_cells = Some((key, cells)); - on -} - -/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. -const FILL_MODES: [(&str, &str); 4] = - [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; - -/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts -/// with one. -const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; - -/// The fill modes the menus list for a fill from `src` on the active sheet. -fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { - let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src)); - FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() -} - -/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. -fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { - app.grid.fill_tag = - app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); -} - -/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill -/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only -/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or -/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. -/// The button goes away as soon as anything else changes the workbook or the selection moves. -fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { - let Some(tag) = app.grid.fill_tag.clone() else { return }; - let current = app.session.active().is_some_and(|d| { - d.uid == tag.uid - && d.wb.active_sheet() == tag.sheet - && d.revision == tag.revision - && d.undo.len() == tag.undo_len - && d.selection.ranges.len() == 1 - && d.selection.ranges.first().is_some_and(|r| *r == tag.target) - }); - if !current { - app.grid.fill_tag = None; - return; - } - if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { - return; - } - let size = vec2(18.0, 18.0); - let corner = geo.range_rect(sh, tag.target).right_bottom(); - let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); - // `button` is in screen space, so compare it with the mirrored cells area. - if !geo.mirror_rect(geo.cells).contains_rect(button) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // Two small cells and a drop-down arrow. - let c = button.center(); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], - t.text, - Stroke::NONE, - )); - let mut chosen = None; - let modes = fill_modes(app, tag.src); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(190.0); - for (label, mode) in modes { - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { - chosen = Some(mode); - ui.close(); - } - } - }); - if let Some(mode) = chosen { - if let Err(e) = app.run("edit.undo", json!({})) { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, tag.src, tag.target), - Err(e) => { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } -} - -/// The menu a right-drag of the fill handle opens on release, as in Excel. -fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { - let Some((src, target, at)) = app.grid.fill_menu else { return }; - let mut close = false; - let mut items = fill_modes(app, src); - items.extend([("-", ""), ("Series…", "ui:series")]); - let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - let lang = crate::i18n::current(ui.ctx()); - for (label, mode) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - if mode == "ui:series" { - let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); - let direction = if target.width() != src.width() { "rows" } else { "columns" }; - app.open_dialog("series", json!({"direction": direction})); - } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.fill_menu = None; - } -} - -/// The right-click menu of a chart, picture, shape or slicer. -fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some((at, kind, id)) = app.grid.object_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let mut items = vec![("Delete", "object.delete")]; - if kind == "chart" { - items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, action) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match action { - "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), - "ui:formatChart" => app.grid.pane = Some("formatChart".into()), - _ => { - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - } - } - } - } - }); - }); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.object_menu = None; - } -} - -/// The warning button beside an active cell holding a number stored as text, as in Excel: its -/// menu names the problem and offers Convert to Number for the selection (#1994). -fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { - if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { - return; - } - let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; - if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { - return; - } - let cell = geo.cell_rect(sh, active); - let size = vec2(18.0, 18.0); - // Left of the cell, or right of it when that would cover the row headings. - let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; - let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); - if !geo.cells.intersects(tag) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // A warning sign: an amber diamond with an exclamation mark. - let c = tag.center(); - let r = 6.0; - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], - t.warning_fill, - Stroke::NONE, - )); - p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); - egui::Popup::menu(&resp).show(|ui| { - ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); - ui.separator(); - if ui.button(lang.tr("Convert to Number")).clicked() { - app.run_or_alert("formulas.convertToNumber", json!({})); - ui.close(); - } - }); -} - -/// Used by `EditState` users to keep the active cell visible while pointing. -pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { - let Some(d) = app.session.active() else { return }; - let from = ed.point_cell.unwrap_or(ed.cell); - let to = from.offset_clamped(dr, dc); - let anchor = if extend { - // The anchor is the corner opposite the moving end: the written range is normalized, so - // after extending up or left its first cell is the moving end, not the anchor. - let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let inferred_anchor = pointed - .map(|r| { - CellRef::new( - if from.row == r.start.row { r.end.row } else { r.start.row }, - if from.col == r.start.col { r.end.col } else { r.start.col }, - ) - }) - .unwrap_or(from); - ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) - } else { - to - }; - let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; - let sheet = d.wb.active_sheet(); - ed.insert_ref(&text); - ed.point_cell = Some(to); - ed.point_anchor = Some((sheet, anchor)); - app.grid.ensure_visible = true; -} - -/// The dropdown arrow beside a cell with an in-cell list validation. -pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; - if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { - return None; - } - let r = geo.cell_rect(sh, c); - let s = r.height().clamp(14.0, 22.0); - Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) -} - -#[allow(clippy::too_many_arguments)] -/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. -pub(crate) fn paint_overlays( - app: &mut SheetApp, - ui: &egui::Ui, - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, -) { - // Validation dropdown arrow for the active cell. - if app.editor.is_none() - && let Some(r) = validation_arrow(sh, geo, sel.active) - { - let t = Tokens::get(p.ctx()); - p.rect_filled(r, 2.0, t.sheet_control_bg); - p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); - crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text); - } - // Input message of the active cell's validation, hidden while its list is open: it would cover the items. - if app.editor.is_none() - && app.grid.list_picker != Some(sel.active) - && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) - && dv.show_input - && !(dv.input_title.is_empty() && dv.input_message.is_empty()) - && !geo.hidden_under_frozen(sh, sel.active) - { - let r = geo.cell_rect(sh, sel.active); - let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { - ui.set_max_width(220.0); - if !dv.input_title.is_empty() { - ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); - } - ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); - }); - }, - ); - } - // Notes: shown on hover (or always when marked visible). - let hover = app.grid.hover_cell; - let all_notes = app.session.show_all_notes; - for (c, cm) in &sh.comments { - let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); - if !show || app.grid.drag != Drag::None { - continue; - } - let r = geo.cell_rect(sh, *c); - if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { - continue; - } - let at = pos2(r.right() + 10.0, r.top()); - p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); - let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { - ui.set_max_width(240.0); - ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); - ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); - for (a, t) in &cm.replies { - ui.separator(); - ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); - } - }); - }, - ); - } - // Hyperlink tooltip. - if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) - && app.editor.is_none() - { - let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { - ui.label(tip); - }); - } - // Trace precedents / dependents arrows. - if let Some(trace) = &app.grid.trace - && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) - && trace.sheet == si - && let Some(items) = trace.items.as_array() - { - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let active = geo.cell_rect(sh, trace.cell).center(); - for it in items { - if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { - continue; - } - let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); - let Some(t) = target else { continue }; - let tr = geo.range_rect(sh, t); - if !t.is_single() { - for pane in geo.range_panes(t) { - p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); - } - } - let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; - p.line_segment([from, to], Stroke::new(1.5, blue)); - p.circle_filled(from, 3.0, blue); - let d = (to - from).normalized(); - let n = vec2(-d.y, d.x); - p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); - } - } - let _ = (wb, si); -} - -/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). -const LIST_PICKER_MAX_ROWS: usize = 8; -const LIST_PICKER_ROW_H: f32 = 18.0; -/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. -const LIST_PICKER_CHROME: f32 = 8.0; - -/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. -fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { - let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; - rows * row_h + (rows - 1.0) * gap -} - -/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. -fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some(c) = app.grid.list_picker else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { - app.grid.list_picker = None; - return; - }; - let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); - let r = geo.cell_rect(sh, c); - // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. - let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); - // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). - let screen = ui.ctx().content_rect(); - let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); - let (at, pivot) = match (above, geo.rtl) { - (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), - (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), - (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), - (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), - }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { - ui.set_min_width(r.width().max(100.0)); - egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { - for it in &items { - if ui - .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) - .clicked() - { - app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); - close = true; - } - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.list_picker = None; - } -} - -/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. -fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { - let Some((at, rows)) = app.grid.header_menu else { return }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - let insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = if rows { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertRows"), - ("Delete", "home.deleteRows"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Row Height…", "ui:rowHeight"), - ("AutoFit Row Height", "home.autofitRowHeight"), - ("Hide", "home.hideRows"), - ("Unhide", "home.unhideRows"), - ("Group", "data.group"), - ] - } else { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertColumns"), - ("Delete", "home.deleteColumns"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Column Width…", "ui:columnWidth"), - ("AutoFit Column Width", "home.autofitColumnWidth"), - ("Hide", "home.hideColumns"), - ("Unhide", "home.unhideColumns"), - ("Sort A to Z", "data.sortAscending"), - ("Group", "data.group"), - ] - }; - if let Some(item) = insert_copied { - items.insert(5, item); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, id) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - match id { - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.header_menu = None; - } -} - -/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a -/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift -/// (Insert Paste). A cut block can only be pasted, so it gets no entry. -fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { - let clip = app.session.clipboard.as_ref()?; - let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); - let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); - match (cut, lines) { - (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), - (true, false) => None, - (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), - (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), - } -} - -fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { - let Some(d) = app.session.active() else { return false }; - d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" -} - -fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { - let Some(d) = app.session.active() else { return }; - let key = (d.uid, d.revision, d.wb.active_sheet()); - if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { - let pages: Vec<(RangeRef, u32)> = app - .session - .run("file.printPreview", json!({})) - .ok() - .and_then(|v| { - v["pageList"].as_array().map(|a| { - a.iter() - .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) - .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) - .collect() - }) - }) - .unwrap_or_default(); - app.grid.pages = Some((key, pages)); - } - let Some((_, pages)) = &app.grid.pages else { return }; - if pages.is_empty() { - return; - } - // Grey everything, then clear the printed pages. - let mut printed = pages[0].0; - for (r, _) in pages { - printed = printed.union(r); - } - let pr = geo.range_rect(sh, printed); - let grey = Color32::from_black_alpha(70); - let c = p.clip_rect(); - for r in [ - Rect::from_min_max(c.min, pos2(c.right(), pr.top())), - Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), - Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), - Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), - ] { - if r.is_positive() { - p.rect_filled(r, 0.0, grey); - } - } - let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); - for (r, n) in pages { - let rr = geo.range_rect(sh, *r); - if !rr.intersects(c) { - continue; - } - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(2.0, blue), - 6.0, - 3.0, - )); - p.text( - rr.center(), - Align2::CENTER_CENTER, - format!("Page {n}"), - theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), - Color32::from_black_alpha(45), - ); - } - p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); -} - -#[cfg(test)] -mod selection_fill_tests { - use super::*; - - #[test] - fn rect_around_leaves_the_hole_and_never_overlaps() { - let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); - let check = |hole: Rect, pieces: usize| { - let parts: Vec = rect_around(area, hole).collect(); - assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); - let cut = area.intersect(hole); - let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; - let shaded: f32 = parts.iter().map(Rect::area).sum(); - assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); - for (i, a) in parts.iter().enumerate() { - assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); - assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); - // The fill is translucent: a part shaded twice would show darker. - assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); - } - }; - let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); - // In the middle, in each corner (where a drag began), along an edge. - check(cell(200.0, 100.0), 4); - check(cell(100.0, 50.0), 2); - check(cell(336.0, 50.0), 2); - check(cell(100.0, 234.0), 2); - check(cell(336.0, 234.0), 2); - check(cell(100.0, 100.0), 3); - // A merged cell as wide, or as tall, as the area; one that is all of it, or more. - check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); - check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); - check(area, 0); - check(area.expand(30.0), 0); - // The active cell is in another area, touches this one, or is only partly inside it. - check(cell(500.0, 300.0), 1); - check(cell(400.0, 50.0), 1); - check(cell(380.0, 240.0), 2); - } - - #[test] - fn rect_around_survives_hostile_numbers() { - let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); - let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; - for a in bad { - for b in bad { - let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); - for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { - for part in rect_around(area, hole) { - assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); - } - } - // A hole that makes no sense leaves the whole area shaded. - if !weird.intersects(sane) { - assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); - } - } - } - } -} - -/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left -/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the -/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. -/// General alignment is resolved from the value before this and follows the sheet direction. -fn painted_halign(h: HAlign, rtl: bool) -> HAlign { - match h { - HAlign::Left if rtl => HAlign::Right, - HAlign::Right if rtl => HAlign::Left, - h => h, - } -} - /// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the /// labels are four times their 100% size), and never get smaller than a readable 8.5 points. pub(crate) fn heading_font_size(zoom: f32) -> f32 { @@ -4201,597 +285,4 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { } #[cfg(test)] -mod tests { - - #[test] - fn editor_matches_the_cell_while_the_text_fits() { - use super::editor_width; - assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); - } - - #[test] - fn editor_slack_fits_one_more_glyph_at_high_zoom() { - use super::editor_slack; - // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. - for size in [14.7_f32, 25.0, 60.0] { - assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); - } - } - - #[test] - fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { - use super::{HAlign, painted_halign}; - // Painted before the mirror: swapped, so they land where the user put them. - assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); - assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); - // Left-to-right sheets and the other alignments are painted as they are. - assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); - assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); - for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { - assert_eq!(painted_halign(h, true), h); - } - } - - #[test] - fn heading_labels_scale_with_the_zoom_without_a_cap() { - use super::{DISPLAY_SCALE, heading_font_size}; - let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); - assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); - assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); - assert_eq!(at(10.0), 8.5, "never below a readable size"); - } - use super::*; - - #[test] - fn list_picker_fits_up_to_eight_rows_then_scrolls() { - assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); - assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); - assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); - assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); - } - - #[test] - fn number_format_colors_resolve_named_and_indexed_rgb() { - use gridcraft_engine::numfmt::FormatColor as F; - assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); - assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); - assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); - assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); - assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); - for index in [0, 57, 255] { - assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); - } - } - - #[test] - fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { - for (from, zoom_in, to) in [ - (100, true, 120), - (120, false, 100), - (100, false, 85), - // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). - (112, true, 120), - (112, false, 100), - // A step less than 5% away is skipped… - (121, false, 100), - (86, false, 70), - (139, true, 170), - // …but never 100% or a limit. - (99, true, 100), - (101, false, 100), - (96, true, 100), - (385, true, 400), - (11, false, 10), - (400, true, 400), - (10, false, 10), - // Out-of-range zooms (from a file) come into range first. - (5, true, 12), - (1000, false, 340), - (0, false, 10), - (u16::MAX, true, 400), - ] { - assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); - } - // Every step on the ladder is 15–25% from the next, and a notch in then out returns. - // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) - for w in WHEEL_ZOOM_LADDER.windows(2) { - let ratio = f32::from(w[1]) / f32::from(w[0]); - assert!((1.15..=1.25).contains(&ratio), "{w:?}"); - assert_eq!(wheel_zoom_step(w[0], true), w[1]); - assert_eq!(wheel_zoom_step(w[1], false), w[0]); - } - } - - #[test] - fn zoom_glide_moves_by_ratio_and_always_arrives() { - // A third of the way per frame, as at 60 frames a second. - for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { - let (mut z, mut frames) = (from, 0); - while z != to { - let next = zoom_glide_step(z, to, 0.32); - assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); - z = next; - frames += 1; - assert!(frames < 40, "{from} → {to} arrives"); - } - assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); - } - assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); - assert_eq!(zoom_glide_step(120, 100, 0.32), 113); - // No progress asked or nonsense: still a percent closer, or straight there. - assert_eq!(zoom_glide_step(100, 120, 0.0), 101); - assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); - assert_eq!(zoom_glide_step(100, 120, 5.0), 120); - assert_eq!(zoom_glide_step(120, 120, 0.5), 120); - assert_eq!(zoom_glide_step(0, 10, 0.5), 3); - } - - #[test] - fn center_across_run_stops_at_first_non_continuing_column() { - assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); - assert_eq!(center_across_end(0, 20, |_| false), 0); - assert_eq!(center_across_end(0, 1, |_| true), 1); - } - - #[test] - fn conditional_number_format_reaches_the_cell_text() { - // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); - session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); - let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); - session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); - let wb = session.active().unwrap().wb.clone(); - let sh = wb.sheet(0).unwrap(); - let mut cf = CfCache::new(); - let text = |row: u32, cf: &mut CfCache| { - let c = CellRef::new(row, 0); - let (lk, _) = look(&wb, 0, sh, c, cf); - display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 - }; - assert_eq!(text(0, &mut cf), "1"); - assert_eq!(text(1, &mut cf), "3.00"); - assert_eq!(text(2, &mut cf), "4.00"); - } - - #[test] - fn filter_menu_uses_the_table_whose_header_was_clicked() { - use gridcraft_engine::model::Table; - let table = |id: u32, r0: u32, r1: u32| Table { - id, - name: format!("T{id}"), - range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), - header_row: true, - totals_row: false, - columns: vec![], - style: String::new(), - banded_rows: true, - banded_cols: false, - first_col: false, - last_col: false, - filter_button: true, - filter: vec![], - table_type: None, - connection_id: None, - }; - let mut sh = Sheet::new("S"); - sh.tables.push(table(1, 0, 2)); - sh.tables.push(table(2, 4, 7)); - assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); - assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); - assert_eq!(filter_table_range(&sh, 1, 4), None); - } - - #[test] - fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { - let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let gh = 18.5; - let z = 1.0; - - let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); - let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); - let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); - - assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); - assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); - - // Verify tall row (e.g. 40px) - let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); - let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); - let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); - - assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); - assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); - } - - #[test] - fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let gh = 30.0; - let z = 1.0; - - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - - assert!(y_top < y_center); - assert!(y_center < y_bottom); - - // Overflow case: gh exceeds rect height; Top still starts inside the cell. - let gh_overflow = 60.0; - let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); - assert!(y_top_ovf >= rect.top()); - } - - #[test] - fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { - // A default-height row holding three wrapped lines. - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let (gh, z) = (60.0, 1.0); - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). - assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); - assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); - assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); - } - - /// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui - /// draws a rotated `TextShape`. - fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { - let rot = egui::emath::Rot2::from_angle(angle); - let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); - Rect::from_points(&pts) - } - - #[test] - fn rotated_text_box_follows_the_cell_alignment() { - // A tall, narrow merged block (one 3-character column over four 60pt rows). - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: f32, b: f32| (a - b).abs() < 0.01; - for deg in [90i16, -90, 45, -45] { - let angle = -(deg as f32).to_radians(); - for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { - for h in [HAlign::Left, HAlign::Center, HAlign::Right] { - let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); - let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); - assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); - assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); - // A narrow column can't hold a 45° line across: only check where it fits. - let fits_x = bb.width() <= rect.width() - 2.0 * pad; - match h { - HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), - HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), - _ => assert!(near(bb.center().x, rect.center().x), "{what}"), - } - if fits_x { - assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); - } - match v { - VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), - VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), - // Bottom is the default: downward text keeps starting at the top, as before. - _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), - _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), - } - } - } - } - } - - #[test] - fn upright_text_with_default_alignment_keeps_its_place() { - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; - // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. - let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); - let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); - } - - #[test] - fn upward_text_with_middle_align_is_centred_in_a_merged_block() { - // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let angle = -std::f32::consts::FRAC_PI_2; - let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); - assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - } -} - -#[cfg(test)] -mod pane_tests { - use super::*; - - fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { - RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) - } - - #[test] - fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { - // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. - let frozen_cols = ((2, 20), (0, 1)); - let frozen_rows = ((0, 1), (2, 10)); - let corner = ((0, 1), (0, 1)); - let scrolled = ((2, 20), (2, 10)); - let sel = rng(5, 4, 7, 6); - assert!(range_in_pane(sel, scrolled.0, scrolled.1)); - assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); - assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); - assert!(!range_in_pane(sel, corner.0, corner.1)); - } - - #[test] - fn ctrl_click_on_a_selected_header_deselects_it() { - let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); - } - - #[test] - fn selection_across_the_freeze_line_is_drawn_in_both_panes() { - let sel = rng(5, 0, 7, 6); - assert!(range_in_pane(sel, (2, 20), (0, 1))); - assert!(range_in_pane(sel, (2, 20), (2, 10))); - } - - #[test] - fn hidden_outline_symbols_take_no_room() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let mut sh = Sheet::new("S"); - sh.rows.entry(3).or_default().outline = 1; - sh.cols.entry(2).or_default().outline = 2; - let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert!(shown.band_w > 0.0 && shown.band_h > 0.0); - sh.show_outline_symbols = false; - let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); - assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); - // Without headings too. - sh.show_headings = false; - let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); - // No button answers a click on the empty bands' edges (the grid's left and top), where the - // level buttons would sit with no headings to hold them. - for geo in [&hidden, &bare] { - for i in 0..400 { - let t = i as f32 * 0.5; - for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { - assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); - } - } - } - } - - /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. - fn frozen_geo(sh: &mut Sheet) -> Geo { - sh.freeze = Some((2, 2)); - let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); - Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) - } - - #[test] - fn the_edge_just_past_a_hidden_line_unhides_it() { - let mut sh = Sheet::new("S"); - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); - let x = geo.x(&sh, 12); // between K and M - assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); - assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge - assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); - let y = geo.y(&sh, 27); // between 26 and 28 - assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); - assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); - } - - #[test] - fn headers_mark_the_edges_next_to_hidden_lines() { - let mut sh = Sheet::new("S"); - sh.cols.entry(0).or_default().hidden = true; // A - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let col = |c| hidden_around(c, |i| sh.col_width(i)); - assert_eq!(col(1), (true, false)); // B, after hidden A - assert_eq!(col(10), (false, true)); // K - assert_eq!(col(12), (true, false)); // M - assert_eq!(col(5), (false, false)); - let row = |r| hidden_around(r, |i| sh.row_height(i)); - assert_eq!(row(25), (false, true)); - assert_eq!(row(27), (true, false)); - assert_eq!(row(0), (false, false)); - } - - #[test] - fn overlays_skip_cells_scrolled_under_the_frozen_columns() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); - let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); - assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); - } - - #[test] - fn ranges_are_clipped_to_the_panes_that_hold_them() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); - assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); - assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); - assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); - // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. - let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); - assert!(handle.x < split.x); - assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); - } -} - -#[cfg(test)] -mod outline_band_tests { - use super::*; - - #[test] - fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); - let sides = outline_bands(col, 2.0, clip); - // Left, right and top (in view); the bottom is 20 million points away. - assert_eq!(sides.len(), 3); - assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); - assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); - assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); - } - - #[test] - fn outline_outside_the_view_and_non_finite_draw_nothing() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); - assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); - } -} - -#[cfg(test)] -mod trace_arrow_tests { - use super::*; - - fn app() -> SheetApp { - let mut s = gridcraft_engine::Session::new(); - s.new_workbook(); - SheetApp::new(s, Default::default()) - } - - fn select(app: &mut SheetApp, cell: &str) { - app.run("selection.set", json!({"cell": cell})).unwrap(); - } - - /// What the ribbon's Trace Precedents / Trace Dependents buttons do. - fn trace(app: &mut SheetApp, id: &str) { - let r = app.run(id, json!({})).unwrap(); - app.grid.trace = Trace::new(app, r); - } - - /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. - fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { - let d = app.session.active().unwrap(); - let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); - let sh = wb.sheet(si).unwrap(); - let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let p = ui.painter().clone(); - paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel); - }); - // No renderer consumes the font atlas upload here. - out.textures_delta.clear(); - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let mut lines = Vec::new(); - for s in out.shapes { - if let egui::Shape::LineSegment { points, stroke } = s.shape - && stroke.color == blue - { - lines.push((points[0], points[1])); - } - } - (geo, lines) - } - - fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { - let d = app.session.active().unwrap(); - let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); - geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() - } - - #[test] - fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - select(&mut app, "H7"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); - let f7 = center(&app, &geo, "F7"); - assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); - } - - #[test] - fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "B2"); - trace(&mut app, "formulas.traceDependents"); - select(&mut app, "C9"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); - let b2 = center(&app, &geo, "B2"); - assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); - } - - #[test] - fn arrows_stay_on_the_sheet_of_the_traced_cell() { - let mut app = app(); - app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); - app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); - app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - // The precedent lives on Sheet1, but the arrow belongs to Two's F7. - app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); - } - - #[test] - fn inserting_a_row_removes_the_arrows() { - let mut app = app(); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.run("home.insertRows", json!({"rows": "4"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); - } - - #[test] - fn arrows_stay_in_their_workbook() { - let mut app = app(); - app.session.new_workbook(); - // Two books with the same cells and the same revision: only the document tells them apart. - for book in [1, 0] { - app.session.set_active(book); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.session.set_active(1); - let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); - assert_eq!(revisions[0], revisions[1]); - assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); - } -} +mod tests; diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs new file mode 100644 index 000000000..9c6a0b10b --- /dev/null +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -0,0 +1,312 @@ +use super::geo::Geo; +use super::{ + BorderLine, BorderStyle, CellRef, CfCache, CfLook, Color, Color32, FontId, HAlign, MAX_COLS, Painter, PatternType, Pos2, RangeRef, Rect, Sheet, + Stroke, Style, Underline, VAlign, Value, Workbook, pos2, theme, vec2, +}; + +/// Resolved look of a cell for painting. +pub(super) struct Look { + pub(super) style: Style, + pub(super) fill: Option, + pub(super) text_color: Color32, +} + +pub(super) fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { + c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) +} + +pub(super) fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) +} + +/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. +pub(super) fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { + let mut end = start; + while end < limit && end + 1 < MAX_COLS && continues(end + 1) { + end += 1; + } + end +} + +pub(super) fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { + let mut style = wb.styles.get(sh.style_id(c)).clone(); + if let Some(t) = sh.table_at(c) { + style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); + } + let cfl = cf.look(wb, si, c); + if let Some(l) = &cfl + && let Some(st) = &l.style + { + if st.fill.pattern != PatternType::None { + style.fill = st.fill; + } + if st.font.color != Color::Auto { + style.font.color = st.font.color; + } + if st.font.bold { + style.font.bold = true; + } + if st.font.italic { + style.font.italic = true; + } + if st.font.underline != Underline::None { + style.font.underline = st.font.underline; + } + if st.font.strike { + style.font.strike = true; + } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } + for (dst, src) in [ + (&mut style.borders.left, st.borders.left), + (&mut style.borders.right, st.borders.right), + (&mut style.borders.top, st.borders.top), + (&mut style.borders.bottom, st.borders.bottom), + ] { + if !src.is_none() { + *dst = src; + } + } + } + let mut fill = match style.fill.pattern { + PatternType::None => None, + PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), + _ => { + // Patterns: approximate with a blend of foreground and background. + let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); + let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); + Some(Color32::from_rgb( + ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, + ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, + ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, + )) + } + }; + if let Some(l) = &cfl + && let Some(rgb) = l.scale_fill + { + fill = Some(theme::color32(rgb)); + } + let text_color = col32(style.font.color, wb, Color32::BLACK); + (Look { style, fill, text_color }, cfl) +} + +/// Text for a value with General narrowing to the available width. +pub(super) fn display_text( + wb: &Workbook, + sh: &Sheet, + c: CellRef, + v: &Value, + st: &Style, + width_px: f32, + char_w: f32, +) -> (String, Option, bool, Option<(char, usize)>) { + if sh.show_formulas + && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) + { + return (format!("={}", f.text), None, false, None); + } + if !sh.show_zeros && v.as_f64() == Some(0.0) { + return (String::new(), None, false, None); + } + let code = st.num_fmt.as_str(); + // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. + if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + return (String::new(), None, false, None); + } + if code == "General" + && let Value::Number(n) = v + { + let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; + return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { + Some(t) => (t, None, true, None), + None => ("#".repeat(max_chars.max(1)), None, true, None), + }; + } + let f = gridcraft_engine::display::format(v, code, wb); + (f.text, f.color.map(numfmt_color), f.numeric, f.fill) +} + +pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let pad_v = 1.5 * z; + let y_top = rect.top() + pad_v; + // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways + // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. + let y_bottom = rect.bottom() - gh - pad_v; + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let top_inset = 1.0 * z; + let y_top = rect.top() - top_inset; + let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so +/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the +/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. +/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but +/// downward text at the top, where it starts reading. +pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { + let rot = egui::emath::Rot2::from_angle(angle); + let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); + let x = match h { + HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, + HAlign::Right => rect.right() - pad - indent - turned.width(), + _ => rect.left() + pad + indent, + }; + let y = match v { + VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, + VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), + _ => rect.top() + pad, + }; + pos2(x, y) - turned.min.to_vec2() +} + +/// A merged range, where it is drawn, and the visible rows and columns. +#[derive(Clone, Copy)] +pub(super) struct MergedArea { + pub(super) m: RangeRef, + pub(super) rect: Rect, + pub(super) rows: (u32, u32), + pub(super) cols: (u32, u32), +} + +/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the +/// first row's and first column's cells, the bottom and right edges from the last row's and last +/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). +pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area: MergedArea, cf: &mut CfCache) -> Vec<(BorderLine, [Pos2; 2])> { + let MergedArea { m, rect, rows, cols } = area; + let mut out = Vec::new(); + let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); + for col in cs..=ce { + if sh.col_width(col) == 0.0 { + continue; + } + let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; + let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; + out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); + out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); + } + let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); + for row in rs..=re { + if sh.row_height(row) == 0.0 { + continue; + } + let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; + let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; + out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); + out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); + } + out +} + +/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` +/// when it already fits at its own size. +pub(super) fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { + let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; + let room = room.max(1.0); + if natural.is_nan() || natural <= room { + return None; + } + // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. + let size = (font.size * room / natural * 0.97).max(1.0); + Some(FontId::new(size, font.family.clone())) +} + +pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { + let w = style.width(); + let (a, b) = (pts[0], pts[1]); + match style { + BorderStyle::Double => { + let horiz = (a.y - b.y).abs() < 0.5; + let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; + p.line_segment([a - off, b - off], Stroke::new(1.0, col)); + p.line_segment([a + off, b + off], Stroke::new(1.0, col)); + } + BorderStyle::Dashed + | BorderStyle::MediumDashed + | BorderStyle::DashDot + | BorderStyle::MediumDashDot + | BorderStyle::DashDotDot + | BorderStyle::MediumDashDotDot + | BorderStyle::SlantDashDot => { + p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); + } + BorderStyle::Dotted | BorderStyle::Hair => { + p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); + } + _ => { + p.line_segment([a, b], Stroke::new(w, col)); + } + } +} + +pub(super) fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { + let n = gridcraft_engine::cf::icon_count(set); + let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; + let col = if n <= 3 { + palette3.get(idx).copied().unwrap_or(Color32::GRAY) + } else { + let t = idx as f32 / (n - 1).max(1) as f32; + Color32::from_rgb( + (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, + (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, + (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, + ) + }; + let lower = set.to_ascii_lowercase(); + if lower.contains("arrow") { + let c = r.center(); + let s = r.width() / 2.0; + let pts = if idx + 1 == n { + vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] + } else if idx == 0 { + vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] + } else { + vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] + }; + p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); + } else if lower.contains("rating") || lower.contains("quarter") { + p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); + let frac = idx as f32 / (n - 1).max(1) as f32; + p.rect_filled( + Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), + 1.0, + Color32::from_rgb(0x2B, 0x7C, 0xD3), + ); + } else { + p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); + } +} + +/// Black or white, whichever reads on `fill` (the threshold chart labels use). +pub(super) fn ink_on(fill: Color32) -> Color32 { + let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; + if l < 150.0 { Color32::WHITE } else { Color32::BLACK } +} + +/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left +/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the +/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. +/// General alignment is resolved from the value before this and follows the sheet direction. +pub(super) fn painted_halign(h: HAlign, rtl: bool) -> HAlign { + match h { + HAlign::Left if rtl => HAlign::Right, + HAlign::Right if rtl => HAlign::Left, + h => h, + } +} diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs new file mode 100644 index 000000000..ae381c7ff --- /dev/null +++ b/crates/ui-egui/src/grid/cursor.rs @@ -0,0 +1,138 @@ +use super::geo::Geo; +use super::interact::{OBJECT_RESIZE_RADIUS, col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; +use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2}; + +/// Decide after interaction so drag start and release use the new state in the same frame. +pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx(); + let pointer = ctx.input(|i| i.pointer.clone()); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Hover bookkeeping also serves cell notes, independently of who owns the cursor. + if resp.hovered() + && let Some(p) = pos + && !freeze_cols.contains(p) + && !freeze_rows.contains(p) + && !matches!(app.grid.drag, Drag::Freeze { .. }) + { + app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); + } + // Draw tools own the cursor over cells, including while recording a stroke. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + return; + } + // A held drag owns the cursor even outside the grid; a press below egui's threshold still + // has Drag::None and uses hover. A click can briefly leave a drag state after release. + if resp.dragged() + && pointer.primary_down() + && let Some(cursor) = drag_cursor(&app.grid.drag) + { + ctx.set_cursor_icon(cursor); + return; + } + if !resp.hovered() { + return; + } + let Some(p) = pos else { return }; + hover_cursor(app, ctx, resp, &pointer, geo, sh, p); +} + +/// The cursor a held drag shows, or `None` when the drag leaves it to hover. +fn drag_cursor(drag: &Drag) -> Option { + match drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + } +} + +/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. +fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { + let (freeze_cols, freeze_rows) = freeze_handles(geo); + if freeze_cols.contains(p) || freeze_rows.contains(p) { + freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); + return; + } + let in_cells = geo.in_cells(p); + let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); + let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + if app.grid.drag == Drag::None && near_corner { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { + header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); + } else if on_handle && app.editor.is_none() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } + } else if in_cells && app.editor.is_none() && on_cur_border { + ctx.set_cursor_icon(CursorIcon::Move); + } else if in_cells { + ctx.set_cursor_icon(CursorIcon::Cell); + } else if in_col_header { + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); + } +} + +/// The resize cursor for dragging a freeze bar. +fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } +} + +/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel +/// shows a split cursor: dragging or double-clicking there unhides it. +fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { + let hidden = + app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), + } +} + +/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which +/// has no such shape. Black on a white outline, like the system cursors. +fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { + ctx.set_cursor_icon(CursorIcon::None); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); + // Drawn for columns (bars vertical); rows swap the axes. + let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; + let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; + for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { + let stroke = Stroke::new(width, color); + for bx in [-1.5, 1.5] { + p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); + } + p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); + for dir in [-1.0, 1.0] { + p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); + } + } +} diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs new file mode 100644 index 000000000..59f375e80 --- /dev/null +++ b/crates/ui-egui/src/grid/editor.rs @@ -0,0 +1,161 @@ +use super::cell_paint::ink_on; +use super::geo::Geo; +use super::{Align2, CellRef, Color32, FontId, PatternType, Rect, Sheet, SheetApp, Stroke, StrokeKind, Tokens, Workbook, json, pos2, theme, vec2}; + +/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so +/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, +/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. +pub(super) fn editor_slack(size: f32) -> f32 { + 14.0 + size +} + +/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the +/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. +pub(super) fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { + if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } +} + +/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. +pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { + let Some(ed) = app.editor.as_ref() else { return }; + if app.view_key() != Some((ed.doc, ed.sheet)) { + return; + } + let cell = ed.cell; + let rect = match sh.merge_at(cell) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, cell), + }; + let st = wb.styles.get(sh.style_id(cell)).clone(); + let size = st.font.size * 96.0 / 72.0 * geo.z; + let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); + let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; + // Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar + // is the one that stops at five lines). + let shown = lines; + // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. + // Otherwise it widens to the text's one-line width. + let wrap = st.align.wrap; + let (w, h) = if wrap { + // The field wraps at the width it is given (the editor's less its 2 px inset a side): its + // frame is `Frame::NONE`, so no text margin is taken off. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); + (rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height())) + } else { + let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; + // Excel keeps the cell's height for one line and only grows for extra lines. + let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; + (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) + }; + // Grows to the left on a right-to-left sheet. + let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); + let fill = match st.fill.pattern { + PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), + _ => Color32::WHITE, + }; + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), + // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and + // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's + // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its + // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The + // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but + // Wrap Text can expand the editor below/right of it, which must not paint over the next line. + let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; + // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. + let screen_quadrant = geo.mirror_rect(quadrant); + let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; + let painter = ui.painter().with_clip_rect(clip); + painter.rect_filled(erect, 0.0, fill); + painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); + let id = egui::Id::unique("gridcraft.cell_editor"); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); + child.set_clip_rect(clip); + // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark + // theme, on the white paper): pick black or white against the fill, like Excel. + child.visuals_mut().text_cursor.stroke.color = ink_on(fill); + crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); +} + +/// Test the popup itself, not whether the pointer is over any part of the app. +/// A trigger can sit outside its popup, so its opening click must be ignored. +pub(super) fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { + response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) +} + +/// The dropdown arrow beside a cell with an in-cell list validation. +pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; + if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { + return None; + } + let r = geo.cell_rect(sh, c); + let s = r.height().clamp(14.0, 22.0); + Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) +} + +/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). +const LIST_PICKER_MAX_ROWS: usize = 8; + +const LIST_PICKER_ROW_H: f32 = 18.0; + +/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. +const LIST_PICKER_CHROME: f32 = 8.0; + +/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. +pub(super) fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { + let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; + rows * row_h + (rows - 1.0) * gap +} + +/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. +pub(super) fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some(c) = app.grid.list_picker else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { + app.grid.list_picker = None; + return; + }; + let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); + let r = geo.cell_rect(sh, c); + // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. + let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); + // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). + let screen = ui.ctx().content_rect(); + let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); + let (at, pivot) = match (above, geo.rtl) { + (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), + (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), + (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), + (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), + }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { + ui.set_min_width(r.width().max(100.0)); + egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { + for it in &items { + if ui + .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) + .clicked() + { + app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); + close = true; + } + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.list_picker = None; + } +} diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs new file mode 100644 index 000000000..33b1b5461 --- /dev/null +++ b/crates/ui-egui/src/grid/geo.rs @@ -0,0 +1,237 @@ +use super::{Arc, COL_HEADER_H, CellRef, DISPLAY_SCALE, LineIndex, MAX_COLS, MAX_ROWS, Pos2, RangeRef, Rect, Sheet, pos2}; + +/// Geometry of the grid for one frame (handles zoom and frozen panes). +#[derive(Clone)] +pub struct Geo { + pub rect: Rect, + pub cells: Rect, + pub z: f32, + pub header_w: f32, + pub header_h: f32, + /// The outline bands (grouped rows / columns): left of the row headers and above the column + /// headers; 0 without an outline (see `outline`). + pub band_w: f32, + pub band_h: f32, + pub row_outline: Arc, + pub col_outline: Arc, + pub fr: u32, + pub fc: u32, + pub frozen_w: f32, + pub frozen_h: f32, + pub scroll: egui::Vec2, + /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" + /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s + /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by + /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). + pub rtl: bool, + /// Prefix sums over custom row heights / column widths, built once per frame. + pub(super) ri: std::sync::Arc, + pub(super) ci: std::sync::Arc, + /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). + pub paper: Option>, +} + +impl Geo { + pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + let z = zoom * DISPLAY_SCALE; + let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; + let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); + let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); + let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; + let digits = (last_row as f32).log10().floor() as i32 + 1; + let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let frozen_w = (ci.start(fc) as f32) * z; + let frozen_h = (ri.start(fr) as f32) * z; + let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); + let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); + // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). + let symbols = sh.show_outline_symbols; + let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; + // Without headings the level buttons still need a corner to sit in. + if symbols && row_outline.levels > 0 && header_h == 0.0 { + band_h = band_h.max(crate::outline::lane(zoom) + 3.0); + } + if symbols && col_outline.levels > 0 && header_w == 0.0 { + band_w = band_w.max(crate::outline::lane(zoom) + 3.0); + } + let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); + Geo { + rect, + cells, + z, + header_w, + header_h, + band_w, + band_h, + row_outline, + col_outline, + fr, + fc, + frozen_w, + frozen_h, + scroll, + rtl: sh.right_to_left, + ri, + ci, + paper: None, + } + } + /// Left edge of the row headers (right of the outline band). + pub fn head_left(&self) -> f32 { + self.cells.left() - self.header_w + } + /// Top edge of the column headers (below the outline band). + pub fn head_top(&self) -> f32 { + self.cells.top() - self.header_h + } + /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale + /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). + pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { + let mut g = Geo::new(sh, rect, scroll); + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); + (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); + g.paper = Some(grid); + g + } + /// The point is over cells (in Page Layout view: on a page, inside its margins). + pub fn in_cells(&self, p: Pos2) -> bool { + if !self.cells.contains(p) { + return false; + } + match &self.paper { + Some(pg) => pg.on_cells( + self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, + self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, + ), + None => true, + } + } + /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. + pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + match &self.paper { + Some(pg) => pg.row_y(row) as f32, + None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + } + } + /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. + pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + match &self.paper { + Some(pg) => pg.col_x(col) as f32, + None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + } + } + /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. + pub fn mirror_x(&self, x: f32) -> f32 { + if self.rtl { self.rect.left() + self.rect.right() - x } else { x } + } + pub fn mirror_pos(&self, p: Pos2) -> Pos2 { + pos2(self.mirror_x(p.x), p.y) + } + pub fn mirror_rect(&self, r: Rect) -> Rect { + if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } + } + /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). + pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + self.mirror_rect(self.cell_rect(sh, c)) + } + pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; + } + if col < self.fc { + self.cells.left() + self.ci.start(col) as f32 * self.z + } else { + self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z + } + } + pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; + } + if row < self.fr { + self.cells.top() + self.ri.start(row) as f32 * self.z + } else { + self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z + } + } + pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + Rect::from_min_max( + pos2(self.x(sh, c.col), self.y(sh, c.row)), + pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), + ) + } + pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { + let a = self.cell_rect(sh, r.start); + let b = self.cell_rect(sh, r.end); + Rect::from_min_max(a.min, b.max) + } + pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); + } + let dx = (x - self.cells.left()) / self.z; + if dx < self.frozen_w / self.z { + self.ci.at(dx.max(0.0) as f64) + } else { + self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) + } + } + pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); + } + let dy = (y - self.cells.top()) / self.z; + if dy < self.frozen_h / self.z { + self.ri.at(dy.max(0.0) as f64) + } else { + self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) + } + } + pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { + CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) + } + /// Visible scrolled rows (from the first visible row after the frozen ones). + pub(super) fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { + let top = self.ri.start(self.fr) + self.scroll.y as f64; + let r0 = self.ri.at(top).max(self.fr); + let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); + (r0, r1) + } + pub(super) fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { + let left = self.ci.start(self.fc) + self.scroll.x as f64; + let c0 = self.ci.at(left).max(self.fc); + let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); + (c0, c1) + } + /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. + /// The panes share one coordinate system, so a range that is not clipped to these is also drawn + /// over the frozen rows/columns when it is scrolled under them. + pub fn range_panes(&self, r: RangeRef) -> Vec { + let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); + let mut xs = Vec::new(); + if r.start.col < self.fc { + xs.push((self.cells.left(), split.x)); + } + if r.end.col >= self.fc { + xs.push((split.x, self.cells.right())); + } + let mut ys = Vec::new(); + if r.start.row < self.fr { + ys.push((self.cells.top(), split.y)); + } + if r.end.row >= self.fr { + ys.push((split.y, self.cells.bottom())); + } + ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() + } + /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. + pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { + let r = self.cell_rect(sh, c); + (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) + || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) + } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs new file mode 100644 index 000000000..4ffeaeb83 --- /dev/null +++ b/crates/ui-egui/src/grid/interact.rs @@ -0,0 +1,1046 @@ +use super::editor::validation_arrow; +use super::geo::Geo; +use super::keyboard::keyboard; +use super::menus::set_fill_tag; +use super::selection::select_ranges; +use super::tables::{is_filter_button, table_headings}; +use super::{ + CellRef, Color32, CursorIcon, Drag, EditState, MAX_COLS, MAX_ROWS, Painter, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, Tokens, Workbook, + json, pos2, vec2, +}; + +// ------------------------------------------------------------------ interaction + +/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. +pub(super) const OBJECT_RESIZE_RADIUS: f32 = 10.0; + +struct InteractionInput<'a> { + ui: &'a mut egui::Ui, + response: &'a egui::Response, + geo: &'a Geo, + sheet: &'a Sheet, + workbook: &'a Workbook, + context: egui::Context, + press_origin: Option, + any_pointer_down: bool, + modifiers: egui::Modifiers, + position: Option, + primary_clicked: bool, + in_cells: bool, + in_column_header: bool, + in_row_header: bool, + in_corner: bool, + selection: gridcraft_engine::Selection, + current_rect: Rect, + handle: Rect, + handle_panes: Vec, + border_panes: Vec, + freeze_columns: Rect, + freeze_rows: Rect, +} + +impl InteractionInput<'_> { + fn on_handle(&self, p: Pos2) -> bool { + self.handle.contains(p) && self.handle_panes.iter().any(|r| r.contains(p)) + } + + fn on_current_border(&self, p: Pos2) -> bool { + on_border(self.current_rect, p) && self.border_panes.iter().any(|r| r.contains(p)) + } +} + +pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Text selection and editing must not also move the object or edit worksheet cells. + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx().clone(); + // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. + let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); + let pointer = ctx.input(|i| i.pointer.clone()); + let mods = ctx.input(|i| i.modifiers); + // Hit-testing works in logical coordinates (see `Geo::rtl`). + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); + let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); + let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + // The fill handle and the move border only react in the panes where they are painted, not + // over the frozen rows/columns that a scrolled selection lies under. + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + let input = InteractionInput { + ui, + response: resp, + geo, + sheet: sh, + workbook: wb, + context: ctx.clone(), + press_origin: pointer.press_origin(), + any_pointer_down: pointer.any_down(), + modifiers: mods, + position: pos, + primary_clicked, + in_cells, + in_column_header: in_col_header, + in_row_header, + in_corner, + selection: sel.clone(), + current_rect: cur_rect, + handle, + handle_panes, + border_panes, + freeze_columns: freeze_cols, + freeze_rows, + }; + if handle_draw_tool(app, &input) { + return; + } + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves + // them: a right-click that wobbles into a drag still opens the object's menu (#2067). + let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); + if primary_drag || primary_clicked { + // A drag is recognised only after the pointer has moved, so grab the object where the press began. + let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; + if let Some(p) = at + && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + if kind == "shape" + && resp.double_clicked() + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + { + app.begin_text_box_edit(id); + return; + } + if kind == "chart" && resp.double_clicked() { + app.grid.pane = Some("formatChart".into()); + } + if primary_drag { + let a = object_anchor(sh, kind, id); + let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; + if let Some(a) = a { + app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; + } + } + return; + } + app.selected_chart = None; + } + // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's + // under it; a right press that wobbles into a drag counts too (#2067). + let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); + if (resp.secondary_clicked() || secondary_drag) + && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } + && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + return; + } + + if handle_header_autofit(app, &input) { + return; + } + + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && input.on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); + let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + + // Press: choose the interaction mode before pointer movement is applied. + if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { + handle_press(app, &input); + return; + } + + handle_dragging(app, &input); + + handle_release(app, &input); + + handle_context_menu_and_keyboard(app, &input); +} + +fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { + let (in_cells, pos, resp, geo, sh, primary_clicked) = + (input.in_cells, input.position, input.response, input.geo, input.sheet, input.primary_clicked); + let ctx = input.context.clone(); + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.session.draw_tool == "pen" { + if let Some(p) = pos + && resp.dragged() + { + // Through the cell under the pointer, so frozen panes and pages map right. + let c = geo.cell_at(sh, p); + let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; + let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; + app.grid.ink.push([sx, sy]); + } + if !app.grid.ink.is_empty() { + // Live preview. + let pts: Vec = app + .grid + .ink + .iter() + .map(|q| { + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); + geo.mirror_pos(pos2( + geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, + geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, + )) + }) + .collect(); + input + .ui + .painter() + .add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + } + if resp.drag_stopped() && app.grid.ink.len() >= 2 { + let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); + let _ = app.run("draw.stroke", json!({"points": pts})); + } + return true; + } + if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { + if let Some(p) = pos + && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) + { + let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); + } + return true; + } + } + false +} + +fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { + let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); + let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header); + let resp = input.response; + if !resp.double_clicked() { + return false; + } + let Some(p) = pos else { return false }; + + let unhide = if in_col_header { + hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) + } else if in_row_header { + hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) + } else { + None + }; + let autofit = if unhide.is_some() { + unhide + } else if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return true; + } + false +} + +fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: Pos2) { + let (geo, sh, wb, sel) = (input.geo, input.sheet, input.workbook, &input.selection); + let mods = input.modifiers; + let resp = input.response; + let in_col_header = input.in_column_header; + if in_col_header { + if let Some((c, x)) = col_edge(geo, sh, p.x) { + app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; + return; + } + let c = geo.col_at(sh, p.x); + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); + return; + } + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + return; + } + // An armed Format Painter paints the columns dragged over instead of moving them. + if resp.drag_started() + && !mods.any() + && app.session.format_painter.is_none() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } + let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; + select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); + app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); + } +} + +fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: Pos2) { + let (geo, sh, wb, sel) = (input.geo, input.sheet, input.workbook, &input.selection); + let mods = input.modifiers; + let in_row_header = input.in_row_header; + if in_row_header { + if let Some((r, y)) = row_edge(geo, sh, p.y) { + app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; + return; + } + let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + return; + } + let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; + select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); + app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); + } +} + +fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) { + let (geo, sh, wb, resp) = (input.geo, input.sheet, input.workbook, input.response); + let (pos, mods, sel) = (input.position, input.modifiers, &input.selection); + let (primary_clicked, in_cells, in_col_header, in_row_header, in_corner) = + (input.primary_clicked, input.in_cells, input.in_column_header, input.in_row_header, input.in_corner); + let (freeze_cols, freeze_rows) = (input.freeze_columns, input.freeze_rows); + let on_handle = |p| input.on_handle(p); + let on_cur_border = |p| input.on_current_border(p); + let pointer = input.press_origin; + + // Where the press began (a drag is only recognised after the pointer has moved). + let Some(p) = pointer.map(|p| geo.mirror_pos(p)).or(pos) else { return }; + // The outline band's − / + and level buttons act on release, like buttons. + if let Some(action) = crate::outline::hit(geo, sh, p) { + if primary_clicked { + let (command, params) = action.command(); + app.run_or_alert(command, params); + } + return; + } + // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). + if freeze_cols.contains(p) || freeze_rows.contains(p) { + app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; + return; + } + if in_corner { + let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); + return; + } + if in_col_header { + handle_column_header_press(app, input, p); + return; + } + if in_row_header { + handle_row_header_press(app, input, p); + return; + } + if !in_cells { + return; + } + let c = geo.cell_at(sh, p); + // Data validation list arrow next to the active cell. + if let Some(r) = validation_arrow(sh, geo, sel.active) + && r.contains(p) + { + app.grid.list_picker = Some(sel.active); + return; + } + // Filter dropdown buttons. + if is_filter_button(sh, geo, p) { + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); + return; + } + // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the + // selection, which the dialog acts on, stays. + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(c, &sh.name, false); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + // Point mode while editing a formula: clicking inserts a reference. + if let Some(ed) = app.editor.as_mut() + && ed.can_point() + { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; + let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); + let r = match sheet_name { + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, + }; + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } + ed.request_focus = true; + ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet(), anchor)); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; + } + if on_handle(p) { + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; + return; + } + if on_cur_border(p) && resp.drag_started() { + app.grid.drag = Drag::Move { grab: c, to: None }; + return; + } + let now = crate::now_ms(); + if handle_cell_double_click(app, input, c, now) { + return; + } + app.grid.last_click = Some((c, now)); + if mods.shift { + let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = sel.anchor; + } + } else if mods.command { + // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.alt + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } + } else { + let _ = app.session.run("selection.set", json!({"cell": c.a1()})); + if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { + let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); + } + // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.any() + { + let r = geo.cell_rect(sh, c); + if p.x < r.left() + r.width() * 0.8 { + follow_link(app, &h.target); + } + } + } + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + // The first recognised drag already includes movement away from the press. + select_to_pointer(app, geo, sh, wb, p); + } +} + +fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c: CellRef, now: f64) -> bool { + let (sh, mods) = (input.sheet, input.modifiers); + if let Some((last, at)) = app.grid.last_click + && last == c + && now - at < 450.0 + && !mods.shift + { + app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { + let not_a_value_cell = + gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); + match app.run("pivot.showDetails", json!({"cell": c.a1()})) { + Ok(_) => return true, + Err(e) if e == not_a_value_cell => {} + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return true; + } + } + } + // Following a hyperlink requires a single click in Excel; double-click edits. + app.begin_edit(None, false); + return true; + } + false +} + +fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) { + let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); + let resp = input.response; + let primary_clicked = input.primary_clicked; + let (in_cells, in_col_header, in_row_header) = (input.in_cells, input.in_column_header, input.in_row_header); + let ctx = input.context.clone(); + // Right-click: context menu. + if resp.secondary_clicked() + && let Some(p) = pos + { + if in_cells && !sel.contains(geo.cell_at(sh, p)) { + let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); + } + if in_col_header || in_row_header { + let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; + let inside = sel.ranges.iter().any(|r| { + if rows { + r.is_full_rows() && idx >= r.start.row && idx <= r.end.row + } else { + r.is_full_cols() && idx >= r.start.col && idx <= r.end.col + } + }); + if !inside { + let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; + let _ = app.session.run("selection.set", json!({"range": range.a1()})); + } + app.grid.header_menu = Some((geo.mirror_pos(p), rows)); + } else { + app.grid.context_menu = Some(geo.mirror_pos(p)); + app.grid.context_menu_frame = input.ui.ctx().cumulative_frame_nr(); + } + } + + if primary_clicked || resp.drag_started() { + resp.request_focus(); + } + keyboard(app, &ctx, resp, geo, sh); +} + +fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { + let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position); + let resp = input.response; + if resp.dragged() + && let Some(p) = pos + { + // Auto-scroll near edges. + let edge = 24.0; + let mut auto = egui::Vec2::ZERO; + if p.y > geo.cells.bottom() - edge { + auto.y = 20.0; + } + if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { + auto.y = -20.0; + } + if p.x > geo.cells.right() - edge { + auto.x = 30.0; + } + if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { + auto.x = -30.0; + } + let pc = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + match app.grid.drag.clone() { + Drag::Select => { + select_to_pointer(app, geo, sh, wb, p); + scroll_by(app, auto); + } + Drag::Rows(anchor) => { + let r = geo.row_at(sh, p.y); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(0.0, auto.y)); + } + Drag::Cols(anchor) => { + let c = geo.col_at(sh, p.x); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::ResizeCol { col, start, orig } => { + let w = (orig + (p.x - start) / geo.z).max(0.0); + app.toast = + Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); + // Inside a selection of whole columns, every selected column takes the new width + // (Ctrl-selected areas included); otherwise only the dragged one. + let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { + sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") + } else { + RangeRef::cols(col, col).a1() + }; + let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); + coalesce_last_undo(app); + } + Drag::ResizeRow { row, start, orig } => { + let h = (orig + (p.y - start) / geo.z).max(0.0); + app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); + let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { + sel.ranges + .iter() + .filter(|r| r.is_full_rows()) + .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) + .collect::>() + .join(",") + } else { + format!("{}:{}", row + 1, row + 1) + }; + let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); + coalesce_last_undo(app); + } + Drag::Freeze { cols, .. } => { + app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; + } + Drag::Fill { menu, .. } => { + let src = sel.current(); + let target = fill_target(src, pc); + app.grid.drag = Drag::Fill { target: Some(target), menu }; + scroll_by(app, auto); + } + Drag::Move { grab, .. } => { + let src = sel.current(); + let dr = pc.row as i64 - grab.row as i64; + let dc = pc.col as i64 - grab.col as i64; + app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; + scroll_by(app, auto); + } + Drag::Object { kind, id, start, orig, resize } => { + let d = (p - start) / geo.z; + let (cell, dx, dy, w, h) = orig; + let params = if resize { + json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) + } else { + // Re-anchor at the cell under the new top-left. + let x0 = sh.col_left(cell.col) as f32 + dx + d.x; + let y0 = sh.row_top(cell.row) as f32 + dy + d.y; + let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); + json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) + }; + let _ = app.session.run("object.move", params); + coalesce_last_undo(app); + } + Drag::None => {} + } + } +} + +fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { + let (geo, sh, wb, sel, pos, mods, resp) = + (input.geo, input.sheet, input.workbook, &input.selection, input.position, input.modifiers, input.response); + let ctx = input.context.clone(); + if resp.drag_stopped() || (!input.any_pointer_down && app.grid.drag != Drag::None && !resp.dragged()) { + match std::mem::take(&mut app.grid.drag) { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + format_painter_apply(app); + } + // Whole rows or columns clicked or dragged over by their headers. + Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is + // spent on the drag so the grid's Esc doesn't also drop the copy. + Drag::Fill { target: Some(t), menu: true } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { + let src = sel.current(); + if t != src { + // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. + let mode = if mods.command { "alternate" } else { "series" }; + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), + } + } + } + Drag::Freeze { cols, to: Some(to) } => { + let (rows_now, cols_now) = (geo.fr, geo.fc); + let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; + if (rows, cols) != (rows_now, cols_now) { + // A1 (nothing left frozen) unfreezes. + app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); + } + } + Drag::Move { to: Some(to), .. } => { + let src = sel.current(); + if to != src.start { + let _ = app.session.run("edit.cut", json!({"range": src.a1()})); + let _ = app.run("edit.paste", json!({"at": to.a1()})); + } + } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + _ => {} + } + app.grid.drag_select = false; + } +} + +/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while +/// pointing a formula reference, which also drags a Select). +fn format_painter_apply(app: &mut SheetApp) { + if app.session.format_painter.is_some() + && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) + && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) + { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } +} + +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let edit_sheet = ed.sheet; + let active_sheet = wb.active_sheet(); + let range = if extend { + ed.point + .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) + .and_then(|(_, _, previous, previous_sheet, _)| { + let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); + if previous_sheet != Some(active_sheet) { + return None; + } + if range.is_full_cols() && previous.is_full_cols() { + Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) + } else if range.is_full_rows() && previous.is_full_rows() { + Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) + } else { + None + } + }) + .unwrap_or(range) + } else { + range + }; + let reference = range.a1(); + let reference = + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + if add { + ed.append_ref(&reference); + } else { + ed.insert_ref(&reference); + } + ed.point_anchor = None; + ed.point_cell = Some(range.start); + ed.request_focus = true; + true +} + +fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { + let cell = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(cell, &sh.name, true); + } else if let Some(ed) = app.editor.as_mut() + && ed.point.is_some() + { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { + let range = RangeRef::new(start, cell); + let text = if range.is_single() { range.start.a1() } else { range.a1() }; + let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + ed.insert_ref(&text); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet(), start)); + } + } else if let Some(d) = app.session.active() { + // Read the current anchor: starting a drag may have just replaced the selection. + let anchor = d.selection.anchor; + let active = d.selection.active; + let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + ranges.pop(); + ranges.push(RangeRef::new(anchor, cell).a1()); + let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = anchor; + } + } +} + +fn coalesce_last_undo(app: &mut SheetApp) { + // Merge consecutive drag steps into one undo entry. + if let Some(d) = app.session.active_mut() + && d.undo.len() >= 2 + { + let n = d.undo.len(); + if d.undo[n - 1].label == d.undo[n - 2].label { + d.undo.remove(n - 1); + } + } +} + +fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { + if d != egui::Vec2::ZERO + && let Some(v) = app.view_mut() + { + v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); + } +} + +fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { + let below = pc.row as i64 - src.end.row as i64; + let above = src.start.row as i64 - pc.row as i64; + let right = pc.col as i64 - src.end.col as i64; + let left = src.start.col as i64 - pc.col as i64; + let vert = below.max(above); + let horiz = right.max(left); + if vert <= 0 && horiz <= 0 { + return src; + } + if vert >= horiz { + if below > 0 { + RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) + } else { + RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) + } + } else if right > 0 { + RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) + } else { + RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) + } +} + +pub(super) fn on_border(r: Rect, p: Pos2) -> bool { + let outer = r.expand(3.0); + let inner = r.shrink(3.0); + outer.contains(p) && !inner.contains(p) +} + +/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip +/// on the frozen columns' right edge, and one in the left part of the row-header strip on the +/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the +/// header edge keeps resizing the column or row. +pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { + let x = geo.cells.left() + geo.frozen_w; + let y = geo.cells.top() + geo.frozen_h; + let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); + let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); + (cols, rows) +} + +/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest +/// the pointer, limited so the frozen pane leaves room for the scrolling one. +fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { + if cols { + if p.x <= geo.cells.left() { + return 0; + } + let c = geo.col_at(sh, p.x); + let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; + let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_COLS - 1) + } else { + if p.y <= geo.cells.top() { + return 0; + } + let rr = geo.row_at(sh, p.y); + let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; + let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_ROWS - 1) + } +} + +/// Paints the freeze handles and, while one is dragged, where the freeze line will go. +pub(super) fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { + let (cols, rows) = freeze_handles(geo); + let bar = Color32::from_gray(150); + p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); + p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); + if let Drag::Freeze { cols, to: Some(to) } = *drag { + let stroke = Stroke::new(2.0, t.accent); + if cols { + let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; + p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); + } else { + let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; + p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); + } + } +} + +pub(super) fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { + let c = geo.col_at(sh, x); + for cc in [c, c.saturating_sub(1)] { + let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; + if (x - edge).abs() <= 4.0 { + return Some((cc, edge)); + } + } + None +} + +pub(super) fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { + let r = geo.row_at(sh, y); + for rr in [r, r.saturating_sub(1)] { + let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; + if (y - edge).abs() <= 3.0 { + return Some((rr, edge)); + } + } + None +} + +/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the +/// edge just left of it belongs to the visible column there. +pub(super) fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { + col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) +} + +pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { + row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) +} + +fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { + match kind { + "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), + "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), + _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), + } +} + +pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { + let Some(id) = app.selected_chart else { return false }; + let kind = if sh.charts.iter().any(|object| object.id == id) { + "chart" + } else if sh.images.iter().any(|object| object.id == id) { + "image" + } else if sh.shapes.iter().any(|object| object.id == id) { + "shape" + } else { + return false; + }; + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + true +} + +/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's +/// OK runs `link.open`); every other kind is refused with a message. The decision is the +/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. +pub fn follow_link(app: &mut SheetApp, target: &str) { + use gridcraft_engine::links::{LinkKind, classify}; + match classify(target) { + LinkKind::Internal(r) => { + let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); + app.grid.ensure_visible = true; + } + LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), + LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), + } +} + +/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region; +/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible. +pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) { + let Some(d) = app.session.active() else { return }; + let Some(sheet) = d.wb.active() else { return }; + let from = ed.point_cell.unwrap_or(ed.cell); + let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) }; + let anchor = if extend { + // The anchor is the corner opposite the moving end: the written range is normalized, so + // after extending up or left its first cell is the moving end, not the anchor. + let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); + let inferred_anchor = pointed + .map(|r| { + CellRef::new( + if from.row == r.start.row { r.end.row } else { r.start.row }, + if from.col == r.start.col { r.end.col } else { r.start.col }, + ) + }) + .unwrap_or(from); + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) + } else { + to + }; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet(); + ed.insert_ref(&text); + ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); + app.grid.ensure_visible = true; +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs new file mode 100644 index 000000000..c55e20df1 --- /dev/null +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -0,0 +1,158 @@ +use super::geo::Geo; +use super::interact::delete_selected_object; +use super::menus::is_checkbox; +use super::{CellRef, Drag, Sheet, SheetApp, json}; + +/// Grid keyboard handling when no text field has focus. +pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + // Focus left on an editor that no longer exists goes back to the grid. + let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let editing = app.editor.is_some() || app.text_box_editor.is_some(); + if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { + resp.request_focus(); + } + let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); + // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from), and a macro's question. + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + || app.macros.question.is_some() + { + return; + } + if ctx.memory(|m| m.focused()).is_none() { + resp.request_focus(); + } + // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). + ctx.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + let events = ctx.input(|i| i.events.clone()); + // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. + let shortcut_pressed = events.iter().any(|e| { + matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } + if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) + }); + let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; + // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once + // a shortcut ran, that text must not start an edit over what it just did. + let mut shortcut_ran = false; + for ev in events { + match ev { + egui::Event::Copy => { + app.copy_to_clipboard(ctx, "edit.copy"); + } + egui::Event::Cut => { + app.copy_to_clipboard(ctx, "edit.cut"); + } + egui::Event::Paste(text) => app.paste_external(text), + // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard + // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a + // floating picture without a chosen anchor/size would be a new feature, not parity. + // A text representation, when the clipboard has one, still arrives as `Paste` above. + egui::Event::PasteImage(_) => {} + // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to + // insert text at, so a middle click on a cell is deliberately not a paste. + egui::Event::MiddleClickPaste { .. } => {} + egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} + egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { + app.begin_edit(Some(text), false); + if let Some(ed) = app.editor.as_mut() { + ed.caret = ed.text.chars().count(); + } + return; + } + egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + use egui::Key; + if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { + shortcut_ran = true; + continue; + } + let cmd = m.command; + let shift = m.shift; + let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); + let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); + app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; + }; + match key { + Key::ArrowUp => mv(app, -1, 0), + Key::ArrowDown => mv(app, 1, 0), + // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. + Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), + Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), + Key::PageDown if !cmd => mv(app, page, 0), + Key::PageUp if !cmd => mv(app, -page, 0), + Key::PageDown => app.run_or_alert("sheet.next", json!({})), + Key::PageUp => app.run_or_alert("sheet.previous", json!({})), + Key::Home if cmd => { + let r = sh.freeze.map(|f| f.0).unwrap_or(0); + let c = sh.freeze.map(|f| f.1).unwrap_or(0); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); + app.grid.ensure_visible = true; + } + Key::End if cmd => { + let end = sh.used_range().map(|u| u.end).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": end.a1()})); + app.grid.ensure_visible = true; + } + Key::Home => { + let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); + app.grid.ensure_visible = true; + } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } + Key::Enter => { + if m.alt || m.ctrl { + app.begin_edit(None, false); + } else { + app.move_after_enter(if shift { -1 } else { 1 }, 0); + } + } + Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), + Key::F2 => app.begin_edit(None, false), + Key::Delete if delete_selected_object(app, sh) => {} + Key::Delete => app.run_or_alert("edit.clearContents", json!({})), + Key::Backspace if delete_selected_object(app, sh) => {} + Key::Backspace => { + // Excel for Mac: Delete clears the cell and starts editing it. + app.run_or_alert("edit.clearContents", json!({})); + if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { + app.begin_edit(Some(String::new()), false); + } + } + Key::Escape => { + app.session.clipboard = None; + app.session.format_painter = None; + } + Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } + Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), + Key::F4 if !cmd => {} + // Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it. + _ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {} + _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { + crate::ribbon::shortcut(app, key, m) + } + _ => {} + } + } + _ => {} + } + } +} diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs new file mode 100644 index 000000000..dbe34af8d --- /dev/null +++ b/crates/ui-egui/src/grid/menus.rs @@ -0,0 +1,551 @@ +use super::editor::popup_dismissed; +use super::geo::Geo; +use super::tables::filter_table_range; +use super::{Arc, Drag, FillTag, RangeRef, Rect, Sense, Sheet, SheetApp, Stroke, Tokens, col_to_letters, json, pos2, vec2}; + +/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. +pub(super) fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some((col, row, at)) = app.grid.filter_menu else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let _ = geo; + // Tables get an autofilter on demand. + let table_range = filter_table_range(sh, col, row); + if sh.autofilter.is_none() + && let Some(r) = table_range + { + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) + && !sh2 + { + let d = app.session.active_mut(); + if let Some(d) = d { + let wbm = Arc::make_mut(&mut d.wb); + if let Some(s) = wbm.sheet_mut(si) { + s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); + } + } + } + } + let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); + let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); + let key = egui::Id::unique(("filter_sel", col)); + let search_key = egui::Id::unique(("filter_search", col)); + let buttons_key = egui::Id::unique(("filter_buttons", col)); + // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its + // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. + let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); + let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); + let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let mut close = false; + let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); + let response = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(230.0); + let ascending = ui.button("↑ Sort Ascending"); + if ascending.clicked() { + let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); + close = true; + } + let descending = ui.button("↓ Sort Descending"); + if descending.clicked() { + let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); + close = true; + } + ui.separator(); + ui.label(egui::RichText::new("Filter").strong()); + let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); + // A search narrows the list; Select All and Apply then only cover the matches. + let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); + if !was_open { + field.request_focus(); + } + let needle = search.trim().to_lowercase(); + let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); + let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); + let mut all_new = all; + let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; + if ui.checkbox(&mut all_new, label).changed() { + for c in checks.iter_mut().filter(|(v, _)| matches(v)) { + c.1 = all_new; + } + } + // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only + // worked over the values themselves (#432). + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { + ui.checkbox(on, v.as_str()); + } + }); + ui.ctx().data_mut(|d| { + d.insert_temp(key, checks.clone()); + d.insert_temp(search_key, search.clone()); + }); + let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); + ui.separator(); + ui.horizontal(|ui| { + let clear = ui.button("Clear Filter"); + if clear.clicked() { + let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); + close = true; + } + // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. + let any = checks.iter().any(|(_, on)| *on); + let apply = ui.button("Apply"); + ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); + if apply.clicked() || (enter && any) { + let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); + let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); + let p = if checks.iter().all(|(_, b)| *b) { + json!({"column": col_to_letters(col), "clear": true}) + } else { + json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) + }; + let _ = app.run("data.filterBy", p); + close = true; + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + ui.ctx().data_mut(|d| { + d.remove::>(key); + d.remove::(search_key); + d.remove::>(buttons_key); + }); + app.grid.filter_menu = None; + } +} + +/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. +pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some(at) = app.grid.context_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("Paste Special…", "ui:pasteSpecial"), + ("-", ""), + ("Insert…", "ui:insertCells"), + ("Delete…", "ui:deleteCells"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Filter by Selected Cell's Value", "ui:filterValue"), + ("Sort A to Z", "data.sortAscending"), + ("Sort Z to A", "data.sortDescending"), + ("-", ""), + ("New Comment", "ui:newComment"), + ("New Note", "ui:newNote"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Pick From Drop-down List…", "ui:pickList"), + ("Define Name…", "ui:defineName"), + ("Link…", "ui:insertLink"), + ]; + if let Some(item) = insert_copied { + items.insert(6, item); + } + if show_details_offered(app) + && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") + { + items.insert(i + 1, ("-", "")); + items.insert(i + 2, ("Show Details", "pivot.showDetails")); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, id) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match id { + "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:insertCells" => app.open_dialog("insertCells", json!({})), + "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), + "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), + "ui:defineName" => app.open_dialog("defineName", json!({})), + "ui:insertLink" => app.open_dialog("insertLink", json!({})), + "ui:pickList" => app.open_dialog("pickList", json!({})), + "ui:filterValue" => { + if let Some(d) = app.session.active() { + let a = d.selection.active; + let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); + let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); + if !has { + let _ = app.run("data.filter", json!({})); + } + app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); + } + } + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + // A quick right-click presses and releases in one frame, and the menu's corner snaps to the + // nearest physical pixel, which can put the pointer just outside it: that press opened the + // menu, so it doesn't count as a click elsewhere. + let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (!opened_now + && ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.context_menu = None; + } +} + +/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell +/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of +/// date, where running the command says so. Labels and headers of a current report don't offer it, +/// and no cell does while the PivotTable's Enable show details option is off. +fn show_details_offered(app: &mut SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + let (si, at) = (d.wb.active_sheet(), d.selection.active); + let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) + else { + return false; + }; + if !pt.enable_show_details { + return false; + } + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + let key = (d.uid, d.revision, si, pi); + if let Some((k, cells)) = &app.grid.show_details_cells + && *k == key + { + return cells.as_ref().is_none_or(|c| c.contains(&cell)); + } + let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); + let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); + app.grid.show_details_cells = Some((key, cells)); + on +} + +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts +/// with one. +const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; + +/// The fill modes the menus list for a fill from `src` on the active sheet. +fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { + let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src)); + FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() +} + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +pub(super) fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or +/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +pub(super) fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet() == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + // `button` is in screen space, so compare it with the mirrored cells area. + if !geo.mirror_rect(geo.cells).contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + let modes = fill_modes(app, tag.src); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in modes { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + +/// The menu a right-drag of the fill handle opens on release, as in Excel. +pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((src, target, at)) = app.grid.fill_menu else { return }; + let mut close = false; + let mut items = fill_modes(app, src); + items.extend([("-", ""), ("Series…", "ui:series")]); + let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + +/// The right-click menu of a chart, picture, shape or slicer. +pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some((at, kind, id)) = app.grid.object_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let mut items = vec![("Delete", "object.delete")]; + if kind == "chart" { + items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, action) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match action { + "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), + "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + _ => { + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + } + } + } + } + }); + }); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.object_menu = None; + } +} + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +pub(super) fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + t.warning_fill, + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + +/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. +pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((at, rows)) = app.grid.header_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertRows"), + ("Delete", "home.deleteRows"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Row Height…", "ui:rowHeight"), + ("AutoFit Row Height", "home.autofitRowHeight"), + ("Hide", "home.hideRows"), + ("Unhide", "home.unhideRows"), + ("Group", "data.group"), + ] + } else { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertColumns"), + ("Delete", "home.deleteColumns"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Column Width…", "ui:columnWidth"), + ("AutoFit Column Width", "home.autofitColumnWidth"), + ("Hide", "home.hideColumns"), + ("Unhide", "home.unhideColumns"), + ("Sort A to Z", "data.sortAscending"), + ("Group", "data.group"), + ] + }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, id) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.header_menu = None; + } +} + +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + +pub(super) fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs new file mode 100644 index 000000000..554bdcfdd --- /dev/null +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -0,0 +1,652 @@ +use super::cell_paint::{ + Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, + rotated_text_pos, shrink_font, wrapped_cell_text_y, +}; +use super::geo::Geo; +use super::tables::{filter_button_rect, paint_filter_button}; +use super::{ + CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign, + Value, Workbook, pos2, theme, vec2, +}; + +/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left +/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction +/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index +/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture +/// isn't flipped. +pub(super) fn mirror_painted( + ctx: &egui::Context, + layer: egui::LayerId, + range: std::ops::Range, + geo: &Geo, + objects: &[crate::chartview::PaintedObject], +) { + let axis = geo.rect.left() + geo.rect.right(); + ctx.graphics_mut(|g| { + let list = g.entry(layer); + for i in range { + let object = objects.iter().find(|o| o.shapes.contains(&i)); + list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { + cs.clip_rect = geo.mirror_rect(cs.clip_rect); + match object { + Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), + None => mirror_shape(&mut cs.shape, axis), + } + }); + } + }); +} + +/// Reflect one shape across the vertical line `x = axis / 2`. +fn mirror_shape(shape: &mut egui::Shape, axis: f32) { + use egui::Shape; + let flip = |p: &mut Pos2| p.x = axis - p.x; + match shape { + Shape::Noop | Shape::Callback(_) => {} + Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), + Shape::Circle(c) => flip(&mut c.center), + Shape::Ellipse(e) => flip(&mut e.center), + Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), + Shape::Path(p) => { + p.points.iter_mut().for_each(flip); + // Keep the preferred (clockwise) winding for filled polygons. + p.points.reverse(); + } + Shape::Rect(r) => { + r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); + let c = r.corner_radius; + r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; + } + Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), + Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), + _ => { + let b = shape.visual_bounding_rect(); + if b.is_finite() { + shape.translate(vec2(axis - b.right() - b.left(), 0.0)); + } + } + } +} + +/// Restrict top-aligned wrapped text to complete lines inside the cell. +/// +/// Preserve the cell and viewport clip when every line fits. Otherwise, +/// exclude overflowing lines while retaining the visible fitting lines. +fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect { + let mut clip = rect.intersect(p.clip_rect()); + if valign != VAlign::Top { + return clip; + } + + let Some(last_row) = galley.rows.last() else { + return clip; + }; + + if y + last_row.rect().bottom() <= rect.bottom() + 0.01 { + return clip; + } + + let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01); + + clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top()); + + clip +} +pub(crate) struct QuadrantPaint<'a> { + pub(crate) painter: &'a Painter, + pub(crate) geo: &'a Geo, + pub(crate) workbook: &'a Workbook, + pub(crate) sheet_index: usize, + pub(crate) sheet: &'a Sheet, + pub(crate) rows: (u32, u32), + pub(crate) cols: (u32, u32), + pub(crate) cf: &'a mut CfCache, + pub(crate) tokens: &'a Tokens, +} + +/// Paint a worksheet quadrant, including backgrounds, borders, and text. +/// +/// For top-aligned wrapped text, painting is limited to complete lines +/// fitting within the cell height while respecting viewport clipping. +/// Other vertical alignment modes retain their existing behavior. +pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) { + let (r0, r1) = quadrant.rows; + let (c0, c1) = quadrant.cols; + let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); + let merges = visible_merges(&quadrant, range); + paint_gridlines(&quadrant); + let cells = visible_cells(&quadrant); + let looks = paint_backgrounds(&mut quadrant, &cells, &merges); + let outside = outside_text_cells(&mut quadrant, &merges); + paint_cells(&mut quadrant, &looks, &outside, &merges); + paint_borders(&mut quadrant, &looks, &merges); + paint_sparklines(&quadrant, range); + paint_filter_buttons(&quadrant, range); +} + +fn visible_merges(q: &QuadrantPaint<'_>, range: RangeRef) -> Vec { + let (r0, r1) = (range.start.row, range.end.row); + let (c0, c1) = (range.start.col, range.end.col); + let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); + q.sheet.merges.iter().filter(|m| m.intersects(&near)).copied().collect() +} + +fn paint_gridlines(q: &QuadrantPaint<'_>) { + let p = q.painter; + let geo = q.geo; + let sh = q.sheet; + let t = q.tokens; + let (r0, r1) = q.rows; + let (c0, c1) = q.cols; + // Gridlines. + if sh.show_gridlines { + let stroke = Stroke::new(1.0, t.gridline); + let top = geo.y(sh, r0); + let bottom = geo.y(sh, r1 + 1); + let left = geo.x(sh, c0); + let right = geo.x(sh, c1 + 1); + for c in c0..=c1 + 1 { + if c <= c1 && sh.col_width(c) == 0.0 { + continue; + } + let x = geo.x(sh, c).round() - 0.5; + p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); + } + for r in r0..=r1 + 1 { + if r <= r1 && sh.row_height(r) == 0.0 { + continue; + } + let y = geo.y(sh, r).round() - 0.5; + p.line_segment([pos2(left, y), pos2(right, y)], stroke); + } + } +} + +fn visible_cells(q: &QuadrantPaint<'_>) -> Vec { + let sh = q.sheet; + let (r0, r1) = q.rows; + let (c0, c1) = q.cols; + // Merged areas: blank them (no inner gridlines) and paint as one cell. + let mut cells: Vec = Vec::new(); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + for c in c0..=c1 { + if sh.col_width(c) == 0.0 { + continue; + } + cells.push(CellRef::new(r, c)); + } + } + cells +} + +fn paint_backgrounds(q: &mut QuadrantPaint<'_>, cells: &[CellRef], merges: &[RangeRef]) -> Vec<(CellRef, Rect, Look, Option)> { + let p = q.painter; + let geo = q.geo; + let wb = q.workbook; + let si = q.sheet_index; + let sh = q.sheet; + let cf = &mut *q.cf; + let (r0, _) = q.rows; + let c0 = q.cols.0; + let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); + // Backgrounds. + let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); + for &c in cells { + let (rect, anchor) = match in_merge(c) { + Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, + Some(m) => (geo.range_rect(sh, m), m.start), + None => (geo.cell_rect(sh, c), c), + }; + let (lk, cfl) = look(wb, si, sh, anchor, cf); + if let Some(f) = lk.fill { + p.rect_filled(rect.expand(0.0), 0.0, f); + } else if in_merge(c).is_some() { + p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); + } + looks.push((anchor, rect, lk, cfl)); + } + looks +} + +fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(CellRef, Rect, Look, Option)> { + let geo = q.geo; + let wb = q.workbook; + let si = q.sheet_index; + let sh = q.sheet; + let cf = &mut *q.cf; + let (r0, r1) = q.rows; + let (c0, c1) = q.cols; + let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); + let empty_ok = |c: CellRef| sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none(); + // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty + // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). + let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); + let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + for n in [left, right].into_iter().flatten() { + if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + let (lk, cfl) = look(wb, si, sh, n, cf); + outside.push((n, geo.cell_rect(sh, n), lk, cfl)); + } + } + } + outside +} + +fn paint_cells( + q: &mut QuadrantPaint<'_>, + looks: &[(CellRef, Rect, Look, Option)], + outside: &[(CellRef, Rect, Look, Option)], + merges: &[RangeRef], +) { + for cell in looks.iter().chain(outside) { + paint_cell(q, cell, merges); + } +} + +fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Look, cfl: Option<&CfLook>) -> bool { + let v = q.sheet.value(c); + let p = q.painter; + let z = q.geo.z; + if let Some(picture) = q.sheet.cell_pictures.get(&c) { + crate::cell_pictures::paint(p, rect, picture); + return true; + } + if let Some(l) = cfl + && let Some((frac, rgb, gradient)) = l.bar + { + paint_data_bar(p, rect, frac, rgb, gradient); + } + if let Some(l) = cfl + && let Some((set, idx)) = &l.icon + { + let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); + paint_cf_icon(p, r, set, *idx); + } + if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + paint_checkbox(p, rect, z, v == Value::Bool(true)); + return true; + } + false +} + +fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) { + let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); + let col = theme::color32(rgb); + if gradient { + let mut mesh = egui::Mesh::default(); + let light = + Color32::from_rgb(((rgb[0] as u16 + 255 * 3) / 4) as u8, ((rgb[1] as u16 + 255 * 3) / 4) as u8, ((rgb[2] as u16 + 255 * 3) / 4) as u8); + let i = mesh.vertices.len() as u32; + for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { + mesh.colored_vertex(pt, cc); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + p.add(mesh); + p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } else { + p.rect_filled(bar, 0.0, col); + } +} + +fn paint_checkbox(p: &egui::Painter, rect: Rect, z: f32, on: bool) { + let s = (11.0 * z * 1.2).clamp(8.0, 24.0); + let b = Rect::from_center_size(rect.center(), vec2(s, s)); + let color = if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }; + p.rect_filled(b, 2.0, color); + p.rect_stroke(b, 2.0, Stroke::new(1.0, if on { color } else { Color32::from_gray(110) }), StrokeKind::Inside); + if on { + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + } +} + +fn text_overflows_cell(numeric: bool, value: &Value, rotation: i16, width: f32, rect_width: f32, pad: f32) -> bool { + !numeric && !matches!(value, Value::Number(_)) && rotation.abs() != 90 && width > rect_width - 2.0 * pad +} + +fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col: u32, in_merge: bool) -> bool { + filter_space == 0.0 && (overflows || across_end > col) && !in_merge +} + +/// Paint one worksheet cell using its computed appearance and visible geometry. +/// +/// Handles text layout, wrapping, alignment, overflow into neighbouring cells, +/// and clipping, while preserving cell decoration and merged-cell constraints. +fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option), merges: &[RangeRef]) { + let (cell, rect, lk, cfl) = painted; + let c = *cell; + let p = q.painter; + let geo = q.geo; + let wb = q.workbook; + let si = q.sheet_index; + let sh = q.sheet; + let t = q.tokens; + let z = geo.z; + let (c0, c1) = q.cols; + let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied(); + let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none(); + if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) { + return; + } + let v = sh.value(c); + let mut text_left = rect.left(); + if let Some(l) = cfl + && let Some((set, idx)) = &l.icon + { + let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); + paint_cf_icon(p, r, set, *idx); + text_left += 15.0 * z; + } + if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { + return; + } + // Align and clip header text inside the content area, away from the dropdown. + let filter_space = sh.filter_button_space(c) * z; + let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); + let rect = &text_rect; + let st = &lk.style; + let size = st.font.size * 96.0 / 72.0 * z; + let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); + let font = FontId::new(size, fam); + // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). + let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); + let avail_w = rect.right() - text_left; + let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { + text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); + } + if text.is_empty() { + return; + } + let color = ncolor.unwrap_or(lk.text_color); + let halign = match st.align.h { + HAlign::General => match v { + Value::Number(_) => HAlign::Right, + Value::Bool(_) | Value::Error(_) => HAlign::Center, + _ if numeric => HAlign::Right, + _ => HAlign::Left, + }, + h => painted_halign(h, geo.rtl), + }; + let indent = st.align.indent as f32 * 9.0 * z; + let pad = 2.5 * z; + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { + let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); + let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); + job.halign = match halign { + HAlign::Center | HAlign::CenterAcross => egui::Align::Center, + HAlign::Right => egui::Align::RIGHT, + _ => egui::Align::LEFT, + }; + let galley = p.layout_job(job); + let x = match halign { + HAlign::Center | HAlign::CenterAcross => rect.center().x, + HAlign::Right => rect.right() - pad, + _ => text_left + pad + indent, + }; + let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); + let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v); + p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color); + return; + } + // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. + let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); + // The fill position indexes `text`; move it back by the breaks removed before it. + let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); + // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the + // merged area): it neither spills, nor is clipped, nor turns into ####. + let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) + .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) + .flatten(); + let shrunk = small.is_some(); + let font = small.unwrap_or(font); + let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); + let mut w = galley.size().x; + // Numbers and dates never overflow: they show #### when too narrow. + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; + galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); + w = galley.size().x; + } + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. + if let Some((ch, pos)) = fill_char + && !hashed + && pos <= first_line.len() + { + let base_w = w; + let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); + let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; + let mut s = first_line.clone(); + if first_line.is_char_boundary(pos) { + s.insert_str(pos, &ch.to_string().repeat(n)); + } + galley = p.layout_no_wrap(s, font.clone(), color); + w = galley.size().x; + } + // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. + let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { + center_across_end(c.col, c1 + 20, |col| { + let n = CellRef::new(c.row, col); + empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross + }) + } else { + c.col + }; + // Overflow into empty neighbours for text (not upright text, whose width is its line height). + let mut clip = *rect; + let overflows = text_overflows_cell(numeric, &v, st.align.rotation, w, rect.width(), pad); + if should_expand_text(filter_space, overflows, across_end, c.col, in_merge(c).is_some()) { + let mut right = rect.right(); + let mut left = rect.left(); + if across_end > c.col { + right = geo.x(sh, across_end + 1); + } + if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { + let mut cc = c.col + 1; + while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { + right = geo.x(sh, cc + 1); + cc += 1; + } + } + if matches!(halign, HAlign::Right | HAlign::Center) { + let mut cc = c.col; + while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { + cc -= 1; + left = geo.x(sh, cc); + } + } + clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); + // Paint over the gridlines the text passes through. + if lk.fill.is_none() { + let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); + let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); + let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); + if extra_l.width() > 0.0 { + p.rect_filled(extra_l, 0.0, t.grid_bg); + } + if extra_r.width() > 0.0 { + p.rect_filled(extra_r, 0.0, t.grid_bg); + } + } + } + let x = match halign { + HAlign::CenterAcross => clip.center().x - w / 2.0, + HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, + HAlign::Right => rect.right() - pad - w - indent, + _ => text_left + pad + indent, + }; + let gh = galley.size().y; + let y = cell_text_y(st.align.v, *rect, gh, z); + let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); + if rotated { + let angle = -(st.align.rotation as f32).to_radians(); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); + let mut shape = egui::epaint::TextShape::new(pos, galley, color); + shape.angle = angle; + cp.add(shape); + } else { + cp.galley(pos2(x, y), galley.clone(), color); + } + paint_text_decorations(&cp, TextDecoration { x, y, width: w, height: gh, color, underline: st.font.underline, strike: st.font.strike }); +} + +struct TextDecoration { + x: f32, + y: f32, + width: f32, + height: f32, + color: Color32, + underline: Underline, + strike: bool, +} + +fn paint_text_decorations(p: &Painter, decoration: TextDecoration) { + let TextDecoration { x, y, width, height, color, underline, strike } = decoration; + let baseline = y + height * 0.82; + match underline { + Underline::None => {} + Underline::Double | Underline::DoubleAccounting => { + p.line_segment([pos2(x, baseline + 1.0), pos2(x + width, baseline + 1.0)], Stroke::new(1.0, color)); + p.line_segment([pos2(x, baseline + 3.0), pos2(x + width, baseline + 3.0)], Stroke::new(1.0, color)); + } + _ => { + p.line_segment([pos2(x, baseline + 1.0), pos2(x + width, baseline + 1.0)], Stroke::new(1.0, color)); + } + } + if strike { + p.line_segment([pos2(x, y + height * 0.55), pos2(x + width, y + height * 0.55)], Stroke::new(1.0, color)); + } +} + +fn paint_borders(q: &mut QuadrantPaint<'_>, looks: &[(CellRef, Rect, Look, Option)], merges: &[RangeRef]) { + let p = q.painter; + let geo = q.geo; + let wb = q.workbook; + let si = q.sheet_index; + let sh = q.sheet; + let cf = &mut *q.cf; + let t = q.tokens; + let z = geo.z; + let (r0, r1) = q.rows; + let (c0, c1) = q.cols; + // Borders (over fills and text). + for (c, rect, lk, _) in looks { + let b = &lk.style.borders; + let merge = merges.iter().find(|m| m.start == *c); + let edges = match merge { + // A merged area's outline lives on the cells along each of its edges (Excel writes the + // right edge on the last column's cells and the bottom edge on the last row's), so each + // edge is drawn cell by cell from the cell it belongs to. + Some(m) => merged_edges(wb, si, sh, geo, MergedArea { m: *m, rect: *rect, rows: (r0, r1), cols: (c0, c1) }, cf), + None => vec![ + (b.top, [rect.left_top(), rect.right_top()]), + (b.bottom, [rect.left_bottom(), rect.right_bottom()]), + (b.left, [rect.left_top(), rect.left_bottom()]), + (b.right, [rect.right_top(), rect.right_bottom()]), + ], + }; + for (line, pts) in edges { + if line.is_none() { + continue; + } + let col = col32(line.color, wb, Color32::BLACK); + paint_border(p, pts, line.style, col); + } + if !b.diag_down.is_none() { + paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); + } + if !b.diag_up.is_none() { + paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); + } + // Comment / note indicator. + if let Some(cm) = sh.comments.get(c) { + let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], + tri_col, + Stroke::NONE, + )); + } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + t.error_check_green, + Stroke::NONE, + )); + } + } +} + +fn paint_sparklines(q: &QuadrantPaint<'_>, range: RangeRef) { + let p = q.painter; + let geo = q.geo; + let wb = q.workbook; + let si = q.sheet_index; + let sh = q.sheet; + // Sparklines. + for sp in &sh.sparklines { + if !range.contains(sp.cell) { + continue; + } + let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); + let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { + Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), + v => vec![v.as_f64()], + }; + let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); + let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); + crate::chartview::paint_prims(p, rect.min, &prims, 1.0); + } +} + +fn paint_filter_buttons(q: &QuadrantPaint<'_>, range: RangeRef) { + let p = q.painter; + let geo = q.geo; + let sh = q.sheet; + // Filter buttons. + let mut buttons: Vec<(CellRef, bool)> = Vec::new(); + if let Some(af) = &sh.autofilter { + for col in af.range.start.col..=af.range.end.col { + let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); + buttons.push((CellRef::new(af.range.start.row, col), active)); + } + } + for tb in &sh.tables { + if tb.filter_button && tb.header_row { + for col in tb.range.start.col..=tb.range.end.col { + buttons.push((CellRef::new(tb.range.start.row, col), false)); + } + } + } + for (c, active) in buttons { + if !range.contains(c) { + continue; + } + paint_filter_button(p, filter_button_rect(geo, sh, c), active); + } +} diff --git a/crates/ui-egui/src/grid/selection.rs b/crates/ui-egui/src/grid/selection.rs new file mode 100644 index 000000000..3fbd081d6 --- /dev/null +++ b/crates/ui-egui/src/grid/selection.rs @@ -0,0 +1,421 @@ +use super::geo::Geo; +#[cfg(test)] +use super::interact::{hidden_col_edge, hidden_row_edge}; +#[cfg(test)] +use super::show::hidden_around; +use super::{CellRef, Color32, Painter, RangeRef, Rect, Sheet, SheetApp, Stroke, StrokeKind, Tokens, json, pos2, vec2}; + +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { + selection_fill_where(geo, sh, sel, outline, |_| true) +} + +/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). +fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + +/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). +/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the +/// scrolling pane would otherwise be drawn over the frozen pane as well. +fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { + r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 +} + +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). +pub(crate) struct SelectionPaintInput<'a> { + pub(crate) painter: &'a Painter, + pub(crate) geo: &'a Geo, + pub(crate) sheet: &'a Sheet, + pub(crate) selection: &'a gridcraft_engine::Selection, + pub(crate) outline: Rect, + pub(crate) tokens: &'a Tokens, + pub(crate) app: &'a SheetApp, + pub(crate) rows: (u32, u32), + pub(crate) cols: (u32, u32), +} + +pub(crate) fn paint_selection(input: SelectionPaintInput<'_>) { + let SelectionPaintInput { painter: p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows, cols } = input; + // Fill all areas except the active cell. + for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { + p.rect_filled(rect, 0.0, t.sel_fill); + } + let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); + let active = active_rect(geo, sh, sel); + let active_here = range_in_pane(active_range, rows, cols); + let cur_here = range_in_pane(sel.current(), rows, cols); + // Editing a formula: outline its references instead of the selection. + if let Some(ed) = &app.editor + && ed.is_formula() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); + for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { + if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { + continue; + } + if !range_in_pane(r, rows, cols) { + continue; + } + let rr = geo.range_rect(sh, r); + let col = Tokens::get(p.ctx()).ref_color(idx, true); + p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); + p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside); + for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] { + p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); + } + } + } + if cur_here { + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } + } + if active_here && sel.ranges.len() > 1 { + p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); + } + // Fill handle (bottom-right of the current area). + if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { + let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); + p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); + p.rect_filled(h, 0.0, t.sel_border); + } + // Fill-handle drag preview. + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag + && range_in_pane(*tr, rows, cols) + { + let rr = geo.range_rect(sh, *tr); + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(1.5, Color32::from_gray(90)), + 4.0, + 3.0, + )); + } + if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { + let r = sel.current(); + let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); + if range_in_pane(moved, rows, cols) { + let rr = geo.range_rect(sh, moved); + p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + } + } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } + // Marching ants around the copied range. + if let Some(clip) = &app.session.clipboard + && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) + && range_in_pane(clip.range, rows, cols) + { + // A multi-area copy marks each area as selected. + let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; + for area in areas { + let rr = geo.range_rect(sh, *area).expand(0.5); + let phase = app.grid.marching_phase; + let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; + p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + } + } +} + +/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already +/// selected (unless it is the only one, so the selection never ends up empty). +fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { + if !add { + return vec![clicked]; + } + if ranges.len() > 1 && ranges.contains(&clicked) { + ranges.retain(|x| *x != clicked); + } else if !ranges.contains(&clicked) { + ranges.push(clicked); + } + ranges +} + +pub(super) fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { + let current: Vec = + if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; + let clicked = r.a1(); + let ranges = toggled_ranges(current, clicked.clone(), add); + // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. + let params = + if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; + let _ = app.session.run("selection.set", params); +} + +#[cfg(test)] +mod selection_fill_tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} + +#[cfg(test)] +mod pane_tests { + use super::*; + + fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { + RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) + } + + #[test] + fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { + // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. + let frozen_cols = ((2, 20), (0, 1)); + let frozen_rows = ((0, 1), (2, 10)); + let corner = ((0, 1), (0, 1)); + let scrolled = ((2, 20), (2, 10)); + let sel = rng(5, 4, 7, 6); + assert!(range_in_pane(sel, scrolled.0, scrolled.1)); + assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); + assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); + assert!(!range_in_pane(sel, corner.0, corner.1)); + } + + #[test] + fn ctrl_click_on_a_selected_header_deselects_it() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); + } + + #[test] + fn selection_across_the_freeze_line_is_drawn_in_both_panes() { + let sel = rng(5, 0, 7, 6); + assert!(range_in_pane(sel, (2, 20), (0, 1))); + assert!(range_in_pane(sel, (2, 20), (2, 10))); + } + + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } + } + + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. + fn frozen_geo(sh: &mut Sheet) -> Geo { + sh.freeze = Some((2, 2)); + let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) + } + + #[test] + fn the_edge_just_past_a_hidden_line_unhides_it() { + let mut sh = Sheet::new("S"); + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); + let x = geo.x(&sh, 12); // between K and M + assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); + assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge + assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); + let y = geo.y(&sh, 27); // between 26 and 28 + assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); + assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); + } + + #[test] + fn headers_mark_the_edges_next_to_hidden_lines() { + let mut sh = Sheet::new("S"); + sh.cols.entry(0).or_default().hidden = true; // A + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let col = |c| hidden_around(c, |i| sh.col_width(i)); + assert_eq!(col(1), (true, false)); // B, after hidden A + assert_eq!(col(10), (false, true)); // K + assert_eq!(col(12), (true, false)); // M + assert_eq!(col(5), (false, false)); + let row = |r| hidden_around(r, |i| sh.row_height(i)); + assert_eq!(row(25), (false, true)); + assert_eq!(row(27), (true, false)); + assert_eq!(row(0), (false, false)); + } + + #[test] + fn overlays_skip_cells_scrolled_under_the_frozen_columns() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); + let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); + assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); + } + + #[test] + fn ranges_are_clipped_to_the_panes_that_hold_them() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); + assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); + assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); + assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); + // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. + let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); + assert!(handle.x < split.x); + assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); + } +} + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs new file mode 100644 index 000000000..2e79ee165 --- /dev/null +++ b/crates/ui-egui/src/grid/show.rs @@ -0,0 +1,948 @@ +#[cfg(test)] +use super::Trace; +use super::cursor::update_cursor; +use super::editor::{in_cell_editor, list_picker, validation_arrow}; +use super::geo::Geo; +use super::interact::{interact, paint_freeze_handles}; +use super::menus::{context_menu, error_check_tag, fill_menu, fill_options_tag, filter_menu, header_menu, object_menu}; +use super::quadrant::{QuadrantPaint, mirror_painted, paint_quadrant}; +use super::selection::{SelectionPaintInput, paint_selection}; +use super::tables::{TableHeading, paint_filter_button, table_headings}; +use super::{ + Align2, Arc, CfCache, Color32, DISPLAY_SCALE, Drag, MAX_COLS, MAX_ROWS, Painter, PaneQuad, RangeRef, Rect, Sense, Sheet, SheetApp, Stroke, + StrokeKind, Tokens, WHEEL_ZOOM_IDLE, WheelZoom, Workbook, col_to_letters, heading_font_size, json, pos2, theme, vec2, wheel_zoom_step, + zoom_glide_step, +}; +#[cfg(test)] +use super::{CellRef, Pos2}; + +pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let available = ui.available_rect_before_wrap(); + // A right-to-left sheet has its vertical scrollbar on the left. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let bar = 14.0_f32.min(available.width()); + let rect = if rtl { + Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) + } else { + Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) + }; + let resp = ui.allocate_rect(rect, Sense::click_and_drag()); + app.grid.rect = Some(rect); + let Some(d) = app.session.active() else { return }; + let wb: Arc = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let sel = d.selection.clone(); + let place = (d.uid, si); + let mut view = app.view(); + // Page Layout view: pages on a desk, rulers above and left of the headings. + let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; + let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; + let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { + if paper { + let probe = Geo::new(sh, grid_rect, scroll); + if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); + return Geo::paged(sh, grid_rect, scroll, g); + } + } + Geo::new(sh, rect, scroll) + }; + // Scrolling. + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); + if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { + if zoom_delta != 1.0 { + // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. + let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); + let turned = zoom_delta.ln() / notch; + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if now - zooming.time > WHEEL_ZOOM_IDLE { + zooming.notches = 0.0; + } + if turned.is_finite() { + zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); + } + zooming.time = now; + // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to + // about 0. (Past half, so what's left after a step can't step back.) Steps count from + // where a glide is going, not from where it has got to. + let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); + let mut z = from; + while zooming.notches >= 0.6 { + z = wheel_zoom_step(z, true); + zooming.notches -= 1.0; + } + while zooming.notches <= -0.6 { + z = wheel_zoom_step(z, false); + zooming.notches += 1.0; + } + if z != from { + zooming.glide = Some((z, sh.zoom)); + } + app.grid.wheel_zoom = Some(zooming); + } else if scroll_delta != egui::Vec2::ZERO { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = scroll_delta; + // A horizontal swipe moves the content the way the fingers go, which is towards + // higher columns on a mirrored sheet. + if sh.right_to_left { + d.x = -d.x; + } + if modifiers.shift && d.x == 0.0 { + d = vec2(d.y, 0.0); + } + view.scroll -= d / z; + } + } + // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. + // (Two fingers pinch-zoom through `zoom_delta` above.) + if resp.drag_started() && ui.input(|i| i.any_touches()) { + app.grid.touch_pan = true; + } + if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = resp.drag_delta(); + if sh.right_to_left { + d.x = -d.x; + } + view.scroll -= d / z; + } + update_zoom_glide(app, ui, place, sh.zoom, now); + view.scroll = view.scroll.max(egui::Vec2::ZERO); + let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + if paper { + // Page layout units run past sheet units (margins and gaps); the end is known once the + // pages reach the last row / column. + let g = make_geo(app, view.scroll); + if let Some(pg) = &g.paper { + let (ex, ey) = pg.extent(); + max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; + max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; + } + } + view.scroll.x = view.scroll.x.min(max_x); + view.scroll.y = view.scroll.y.min(max_y); + let mut geo = make_geo(app, view.scroll); + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. + ActiveCellVisibility { app, wb: &wb, si, sh, selection: &sel, paper, make_geo: &make_geo }.update(&mut view, &mut geo); + // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). + if let Some(v) = app.view_mut() { + *v = view; + } + app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: + // a gliding outline trails the pointer and reads as lag. + let outline = selection_outline(app, ui, &geo, sh, &sel, place, now); + app.grid.geo = Some(geo.clone()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, t.grid_bg); + // Everything up to the headers is painted in logical (left-to-right) coordinates. + let mirror_from = painter.add(egui::Shape::Noop); + let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages( + crate::paper_view::PagePaintInput { + app, + painter: &painter, + geo: &geo, + workbook: &wb, + sheet_index: si, + sheet: sh, + selection: &sel, + outline, + tokens: &t, + }, + &mut cf, + ) + } else { + paint_panes( + PanesInput { app, painter: &painter, geo: &geo, workbook: &wb, sheet_index: si, sheet: sh, selection: &sel, outline, tokens: &t }, + &mut cf, + ) + }; + // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act + // on something the user can't see. + if crate::chartview::objects_hidden(&wb) { + app.selected_chart = None; + } + let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); + paint_overlays(OverlayInput { + app, + ui, + painter: &painter.with_clip_rect(geo.cells), + geo: &geo, + workbook: &wb, + sheet_index: si, + sheet: sh, + selection: &sel, + }); + if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { + paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); + } + if geo.paper.is_some() { + crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); + } + // Headers. + if sh.show_headings { + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); + paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); + } + crate::outline::paint(&painter, &geo, sh, &t); + if geo.rtl { + let mirror_to = painter.add(egui::Shape::Noop); + mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); + } + if geo.paper.is_some() { + crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); + } + show_grid_interactions(app, ui, &resp, &geo, sh, &wb); + let track_x = vertical_scroll_track(available, rect, rtl); + // The track starts below the column headings and frozen rows; the bar's column beside them + // takes its colour too instead of showing a light square in dark mode. + ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); + vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); + update_clipboard_repaint(app, ui, now); +} + +fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) { + if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) } +} + +fn update_clipboard_repaint(app: &mut SheetApp, ui: &egui::Ui, now: f64) { + if app.session.clipboard.is_some() { + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } +} + +struct ActiveCellVisibility<'a, F> { + app: &'a mut SheetApp, + wb: &'a Workbook, + si: usize, + sh: &'a Sheet, + selection: &'a gridcraft_engine::Selection, + paper: bool, + make_geo: &'a F, +} + +fn selection_outline( + app: &mut SheetApp, + ui: &egui::Ui, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + place: (u64, usize), + now: f64, +) -> Rect { + let pointer_selecting = ui.input(|i| i.pointer.any_down()); + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } + outline +} + +impl Geo> ActiveCellVisibility<'_, F> { + fn update(&mut self, view: &mut crate::SheetView, geo: &mut Geo) { + if !self.app.grid.ensure_visible { + return; + } + let app = &mut *self.app; + let wb = self.wb; + let si = self.si; + let sh = self.sh; + let sel = self.selection; + let paper = self.paper; + + app.grid.ensure_visible = false; + let moving = std::mem::take(&mut app.grid.reveal_moving_end); + let cell = if moving { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); + // A whole column's moving end sits on the sheet's last row (a whole row's on its last + // column): when an extension moves it only across, the view keeps its place the other way. + // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still + // follows it. + let extended = (moving && target == cell).then(|| sel.current()); + let before = view.scroll; + if paper { + crate::paper_view::ensure_covers(app, wb, si, target); + *geo = (self.make_geo)(app, view.scroll); + } + let cr = geo.cell_rect(sh, target); + let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); + if target.row >= geo.fr { + if cr.top() < area.top() { + view.scroll.y -= (area.top() - cr.top()) / geo.z; + } else if cr.bottom() > area.bottom() - 2.0 { + view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; + } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; + } + if target.col >= geo.fc { + if cr.left() < area.left() { + view.scroll.x -= (area.left() - cr.left()) / geo.z; + } else if cr.right() > area.right() - 2.0 { + view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); + } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; + } + if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { + view.scroll.y = before.y; + } + if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { + view.scroll.x = before.x; + } + view.scroll = view.scroll.max(egui::Vec2::ZERO); + *geo = (self.make_geo)(app, view.scroll); + } +} + +fn show_grid_interactions(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); + // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. + if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { + app.grid.touch_pan = false; + } + if app.grid.touch_pan { + // Skip the frames of a touch pan, its release included. + if !resp.dragged() { + app.grid.touch_pan = false; + } + } else { + crate::paper_view::interact(app, ui, resp, geo, sh, wb); + interact(app, ui, resp, geo, sh, wb); + } + update_cursor(app, ui, resp, geo, sh); + crate::text_box::show(app, ui, geo, sh); + if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { + in_cell_editor(app, ui, geo, sh, wb); + } + filter_menu(app, ui, geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); + context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, geo, sh); + object_menu(app, ui); + error_check_tag(app, ui, geo, sh); +} + +fn update_zoom_glide(app: &mut SheetApp, ui: &egui::Ui, place: (u64, usize), zoom: u16, now: f64) { + let mut zooming = WheelZoom::at(app, place, zoom, now); + if let Some((to, shown)) = zooming.glide { + let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); + let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); + let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let _ = app.session.run("view.zoom", json!({"percent": z})); + zooming.glide = (z != to).then_some((to, z)); + ui.ctx().request_repaint(); + } + if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { + app.grid.wheel_zoom = Some(zooming); + } +} + +/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. +struct PanesInput<'a> { + app: &'a SheetApp, + painter: &'a Painter, + geo: &'a Geo, + workbook: &'a Workbook, + sheet_index: usize, + sheet: &'a Sheet, + selection: &'a gridcraft_engine::Selection, + outline: Rect, + tokens: &'a Tokens, +} + +fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { + let PanesInput { app, painter, geo, workbook: wb, sheet_index: si, sheet: sh, selection: sel, outline, tokens: t } = input; + // Quadrants: (rows, cols, clip). + let (sr0, sr1) = geo.scroll_rows(sh); + let (sc0, sc1) = geo.scroll_cols(sh); + let fr_rows = (0, geo.fr.saturating_sub(1)); + let fr_cols = (0, geo.fc.saturating_sub(1)); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; + if geo.fr > 0 { + quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); + } + if geo.fc > 0 { + quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); + } + if geo.fr > 0 && geo.fc > 0 { + quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); + } + for (rows, cols, clip) in &quads { + if rows.0 > rows.1 || cols.0 > cols.1 { + continue; + } + let p = painter.with_clip_rect(*clip); + paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t }); + paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols }); + } + // Freeze lines. + if geo.fr > 0 { + painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); + } + if geo.fc > 0 { + painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); + } + quads +} + +/// The worksheet is painted virtually, so it needs its own scrollbar rather than a +/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = geo.scroll_top_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.scope_id().with("vscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); + let travel = track.height() - thumb_h; + let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + +/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab +/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the +/// far end to the grid, which runs later in the same frame. +pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { + app.grid.hscroll = Some(track); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let Some(geo) = app.grid.geo.clone() else { return }; + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let Some(sh) = wb.active() else { return }; + let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); + let scroll = app.view().scroll.x; + let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let content = geo.scroll_left_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0); + let id = ui.scope_id().with("hscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); + let travel = track.width() - thumb_w; + let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. + let dir = if geo.rtl { -1.0 } else { 1.0 }; + let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; + let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += dir * resp.drag_delta().x / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.x < thumb.left() { + next -= dir * visible; + } else if p.x > thumb.right() { + next += dir * visible; + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; + } + next = next.max(0.0); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.x = next; + } + ui.ctx().request_repaint(); + } +} + +pub(crate) fn paint_headers( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + quads: &[PaneQuad], + headings: &[TableHeading], +) { + let font = theme::ui_font(heading_font_size(geo.z)); + // The headers' own area: inside the outline bands. + let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); + let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); + let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); + p.rect_filled(top, 0.0, t.header_bg); + p.rect_filled(left, 0.0, t.header_bg); + let bounds = sel.bounds(); + let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); + let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); + let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); + let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); + let _ = bounds; + // Hidden columns and rows leave a double divider where they sit, as in Excel. + let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); + let mut done_cols = std::collections::HashSet::new(); + let mut done_rows = std::collections::HashSet::new(); + for (rows, cols, clip) in quads { + let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); + if cols.0 <= cols.1 { + for c in cols.0..=cols.1 { + if !done_cols.insert((c, clip.left() as i32)) { + continue; + } + let w = sh.col_width(c) * geo.z; + if w <= 0.0 { + continue; + } + let x = geo.x(sh, c); + let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); + if col_sel(c) { + pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); + pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); + } + pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(c, |i| sh.col_width(i)); + for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { + for dx in [-1.5, 1.5] { + pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); + } + } + let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } + } + } + let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); + if rows.0 <= rows.1 { + for rr in rows.0..=rows.1 { + if !done_rows.insert((rr, clip.top() as i32)) { + continue; + } + let h = sh.row_height(rr) * geo.z; + if h <= 0.0 { + continue; + } + let y = geo.y(sh, rr); + let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); + if row_sel(rr) { + pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); + pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); + } + pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(rr, |i| sh.row_height(i)); + for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { + for dy in [-1.5, 1.5] { + pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); + } + } + let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; + pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); + } + } + } + // Corner (select all) and edges. + let corner = Rect::from_min_max(r.min, geo.cells.min); + p.rect_filled(corner, 0.0, t.header_bg); + let tri = vec![ + pos2(corner.right() - 3.0, corner.top() + 5.0), + pos2(corner.right() - 3.0, corner.bottom() - 3.0), + pos2(corner.left() + 7.0, corner.bottom() - 3.0), + ]; + p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); + p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); + p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); +} + +/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges +/// get the hidden marker. Line 0 has nothing before it. +pub(super) fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { + (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) +} + +/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. +pub(crate) struct OverlayInput<'a> { + app: &'a mut SheetApp, + ui: &'a egui::Ui, + painter: &'a Painter, + geo: &'a Geo, + workbook: &'a Workbook, + sheet_index: usize, + sheet: &'a Sheet, + selection: &'a gridcraft_engine::Selection, +} + +pub(crate) fn paint_overlays(input: OverlayInput<'_>) { + let OverlayInput { app, ui, painter: p, geo, workbook: wb, sheet_index: si, sheet: sh, selection: sel } = input; + // Validation dropdown arrow for the active cell. + if app.editor.is_none() + && let Some(r) = validation_arrow(sh, geo, sel.active) + { + let t = Tokens::get(p.ctx()); + p.rect_filled(r, 2.0, t.sheet_control_bg); + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text); + } + // Input message of the active cell's validation, hidden while its list is open: it would cover the items. + if app.editor.is_none() + && app.grid.list_picker != Some(sel.active) + && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) + && dv.show_input + && !(dv.input_title.is_empty() && dv.input_message.is_empty()) + && !geo.hidden_under_frozen(sh, sel.active) + { + let r = geo.cell_rect(sh, sel.active); + let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { + ui.set_max_width(220.0); + if !dv.input_title.is_empty() { + ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); + } + ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); + }); + }, + ); + } + // Notes: shown on hover (or always when marked visible). + let hover = app.grid.hover_cell; + let all_notes = app.session.show_all_notes; + for (c, cm) in &sh.comments { + let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); + if !show || app.grid.drag != Drag::None { + continue; + } + let r = geo.cell_rect(sh, *c); + if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { + continue; + } + let at = pos2(r.right() + 10.0, r.top()); + p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); + let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { + ui.set_max_width(240.0); + ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); + ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); + for (a, t) in &cm.replies { + ui.separator(); + ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); + } + }); + }, + ); + } + // Hyperlink tooltip. + if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) + && app.editor.is_none() + { + let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tip); + }); + } + if let Some(o) = &app.grid.macro_overlay { + crate::macro_editor::paint_overlay(p, geo, sh, o); + } + // Trace precedents / dependents arrows. + if let Some(trace) = &app.grid.trace + && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) + && trace.sheet == si + && let Some(items) = trace.items.as_array() + { + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let active = geo.cell_rect(sh, trace.cell).center(); + for it in items { + if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { + continue; + } + let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); + let Some(t) = target else { continue }; + let tr = geo.range_rect(sh, t); + if !t.is_single() { + for pane in geo.range_panes(t) { + p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + } + } + let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; + p.line_segment([from, to], Stroke::new(1.5, blue)); + p.circle_filled(from, 3.0, blue); + let d = (to - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); + } + } + let _ = (wb, si); +} + +fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { + let Some(d) = app.session.active() else { return }; + let key = (d.uid, d.revision, d.wb.active_sheet()); + if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { + let pages: Vec<(RangeRef, u32)> = app + .session + .run("file.printPreview", json!({})) + .ok() + .and_then(|v| { + v["pageList"].as_array().map(|a| { + a.iter() + .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) + .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) + .collect() + }) + }) + .unwrap_or_default(); + app.grid.pages = Some((key, pages)); + } + let Some((_, pages)) = &app.grid.pages else { return }; + if pages.is_empty() { + return; + } + // Grey everything, then clear the printed pages. + let mut printed = pages[0].0; + for (r, _) in pages { + printed = printed.union(r); + } + let pr = geo.range_rect(sh, printed); + let grey = Color32::from_black_alpha(70); + let c = p.clip_rect(); + for r in [ + Rect::from_min_max(c.min, pos2(c.right(), pr.top())), + Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), + Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), + Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), + ] { + if r.is_positive() { + p.rect_filled(r, 0.0, grey); + } + } + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + for (r, n) in pages { + let rr = geo.range_rect(sh, *r); + if !rr.intersects(c) { + continue; + } + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(2.0, blue), + 6.0, + 3.0, + )); + p.text( + rr.center(), + Align2::CENTER_CENTER, + format!("Page {n}"), + theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), + Color32::from_black_alpha(45), + ); + } + p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); +} + +#[cfg(test)] +mod trace_arrow_tests { + use super::*; + + fn app() -> SheetApp { + let mut s = gridcraft_engine::Session::new(); + s.new_workbook(); + SheetApp::new(s, Default::default()) + } + + fn select(app: &mut SheetApp, cell: &str) { + app.run("selection.set", json!({"cell": cell})).unwrap(); + } + + /// What the ribbon's Trace Precedents / Trace Dependents buttons do. + fn trace(app: &mut SheetApp, id: &str) { + let r = app.run(id, json!({})).unwrap(); + app.grid.trace = Trace::new(app, r); + } + + /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. + fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + let d = app.session.active().unwrap(); + let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); + let sh = wb.sheet(si).unwrap(); + let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let p = ui.painter().clone(); + paint_overlays(OverlayInput { app, ui, painter: &p, geo: &geo, workbook: &wb, sheet_index: si, sheet: sh, selection: &sel }); + }); + // No renderer consumes the font atlas upload here. + out.textures_delta.clear(); + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let mut lines = Vec::new(); + for s in out.shapes { + if let egui::Shape::LineSegment { points, stroke } = s.shape + && stroke.color == blue + { + lines.push((points[0], points[1])); + } + } + (geo, lines) + } + + fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { + let d = app.session.active().unwrap(); + let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); + geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() + } + + #[test] + fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + select(&mut app, "H7"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); + let f7 = center(&app, &geo, "F7"); + assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); + } + + #[test] + fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "B2"); + trace(&mut app, "formulas.traceDependents"); + select(&mut app, "C9"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); + let b2 = center(&app, &geo, "B2"); + assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); + } + + #[test] + fn arrows_stay_on_the_sheet_of_the_traced_cell() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + // The precedent lives on Sheet1, but the arrow belongs to Two's F7. + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); + } + + #[test] + fn inserting_a_row_removes_the_arrows() { + let mut app = app(); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.run("home.insertRows", json!({"rows": "4"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); + } + + #[test] + fn arrows_stay_in_their_workbook() { + let mut app = app(); + app.session.new_workbook(); + // Two books with the same cells and the same revision: only the document tells them apart. + for book in [1, 0] { + app.session.set_active(book); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.session.set_active(1); + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + assert_eq!(revisions[0], revisions[1]); + assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); + } +} diff --git a/crates/ui-egui/src/grid/tables.rs b/crates/ui-egui/src/grid/tables.rs new file mode 100644 index 000000000..7fca436a4 --- /dev/null +++ b/crates/ui-egui/src/grid/tables.rs @@ -0,0 +1,87 @@ +use super::geo::Geo; +use super::{CellRef, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, pos2, vec2}; + +pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { + let rect = geo.cell_rect(sh, c); + // Keep the button within the same header slot that AutoFit reserves. + let pad = 2.0 * geo.z; + let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); + Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) +} + +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +pub(super) fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + let t = Tokens::get(p.ctx()); + p.rect_filled(r, 2.0, t.sheet_control_bg); + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + crate::icons::paint_on_sheet( + p, + r.shrink(1.5), + if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, + t.sheet_control_text, + ); +} + +pub(super) fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { + let c = geo.cell_at(sh, p); + sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) +} + +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +pub(super) fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} diff --git a/crates/ui-egui/src/grid/tests.rs b/crates/ui-egui/src/grid/tests.rs new file mode 100644 index 000000000..74abfc861 --- /dev/null +++ b/crates/ui-egui/src/grid/tests.rs @@ -0,0 +1,306 @@ +#[test] +fn editor_matches_the_cell_while_the_text_fits() { + use super::editor_width; + assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); +} + +#[test] +fn editor_slack_fits_one_more_glyph_at_high_zoom() { + use super::editor_slack; + // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. + for size in [14.7_f32, 25.0, 60.0] { + assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); + } +} + +#[test] +fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { + use super::{HAlign, painted_halign}; + // Painted before the mirror: swapped, so they land where the user put them. + assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); + assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); + // Left-to-right sheets and the other alignments are painted as they are. + assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); + assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); + for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { + assert_eq!(painted_halign(h, true), h); + } +} + +#[test] +fn heading_labels_scale_with_the_zoom_without_a_cap() { + use super::{DISPLAY_SCALE, heading_font_size}; + let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); + assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); + assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); + assert_eq!(at(10.0), 8.5, "never below a readable size"); +} +use super::*; + +#[test] +fn list_picker_fits_up_to_eight_rows_then_scrolls() { + assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); + assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); + assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); + assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); +} + +#[test] +fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } +} + +#[test] +fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { + for (from, zoom_in, to) in [ + (100, true, 120), + (120, false, 100), + (100, false, 85), + // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). + (112, true, 120), + (112, false, 100), + // A step less than 5% away is skipped… + (121, false, 100), + (86, false, 70), + (139, true, 170), + // …but never 100% or a limit. + (99, true, 100), + (101, false, 100), + (96, true, 100), + (385, true, 400), + (11, false, 10), + (400, true, 400), + (10, false, 10), + // Out-of-range zooms (from a file) come into range first. + (5, true, 12), + (1000, false, 340), + (0, false, 10), + (u16::MAX, true, 400), + ] { + assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); + } + // Every step on the ladder is 15–25% from the next, and a notch in then out returns. + // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) + for w in WHEEL_ZOOM_LADDER.windows(2) { + let ratio = f32::from(w[1]) / f32::from(w[0]); + assert!((1.15..=1.25).contains(&ratio), "{w:?}"); + assert_eq!(wheel_zoom_step(w[0], true), w[1]); + assert_eq!(wheel_zoom_step(w[1], false), w[0]); + } +} + +#[test] +fn zoom_glide_moves_by_ratio_and_always_arrives() { + // A third of the way per frame, as at 60 frames a second. + for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { + let (mut z, mut frames) = (from, 0); + while z != to { + let next = zoom_glide_step(z, to, 0.32); + assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); + z = next; + frames += 1; + assert!(frames < 40, "{from} → {to} arrives"); + } + assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); + } + assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); + assert_eq!(zoom_glide_step(120, 100, 0.32), 113); + // No progress asked or nonsense: still a percent closer, or straight there. + assert_eq!(zoom_glide_step(100, 120, 0.0), 101); + assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); + assert_eq!(zoom_glide_step(100, 120, 5.0), 120); + assert_eq!(zoom_glide_step(120, 120, 0.5), 120); + assert_eq!(zoom_glide_step(0, 10, 0.5), 3); +} + +#[test] +fn center_across_run_stops_at_first_non_continuing_column() { + assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); + assert_eq!(center_across_end(0, 20, |_| false), 0); + assert_eq!(center_across_end(0, 1, |_| true), 1); +} + +#[test] +fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); +} + +#[test] +fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); +} + +#[test] +fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { + let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let gh = 18.5; + let z = 1.0; + + let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); + let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); + let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); + + assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); + assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); + + // Verify tall row (e.g. 40px) + let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); + let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); + let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); + + assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); + assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); +} + +#[test] +fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let gh = 30.0; + let z = 1.0; + + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + + assert!(y_top < y_center); + assert!(y_center < y_bottom); + + // Overflow case: gh exceeds rect height; Top still starts inside the cell. + let gh_overflow = 60.0; + let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); + assert!(y_top_ovf >= rect.top()); +} + +#[test] +fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { + // A default-height row holding three wrapped lines. + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let (gh, z) = (60.0, 1.0); + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). + assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); + assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); + assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); +} + +/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui +/// draws a rotated `TextShape`. +fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { + let rot = egui::emath::Rot2::from_angle(angle); + let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); + Rect::from_points(&pts) +} + +#[test] +fn rotated_text_box_follows_the_cell_alignment() { + // A tall, narrow merged block (one 3-character column over four 60pt rows). + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: f32, b: f32| (a - b).abs() < 0.01; + for deg in [90i16, -90, 45, -45] { + let angle = -(deg as f32).to_radians(); + for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { + for h in [HAlign::Left, HAlign::Center, HAlign::Right] { + let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); + let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); + assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); + assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); + // A narrow column can't hold a 45° line across: only check where it fits. + let fits_x = bb.width() <= rect.width() - 2.0 * pad; + match h { + HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), + HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), + _ => assert!(near(bb.center().x, rect.center().x), "{what}"), + } + if fits_x { + assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); + } + match v { + VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), + VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), + // Bottom is the default: downward text keeps starting at the top, as before. + _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), + _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), + } + } + } + } +} + +#[test] +fn upright_text_with_default_alignment_keeps_its_place() { + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; + // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. + let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); + let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); +} + +#[test] +fn upward_text_with_middle_align_is_centred_in_a_merged_block() { + // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let angle = -std::f32::consts::FRAC_PI_2; + let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); + assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); + assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ff863263..45c336f87 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -418,7 +418,7 @@ mod tests { } #[test] - fn tags_reduce_to_a_supported_language() { + fn tags_reduce_to_supported_languages() { assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja)); assert_eq!(Language::from_tag("en_US"), Some(Language::En)); assert_eq!(Language::from_tag("es_MX"), Some(Language::Es)); @@ -430,6 +430,10 @@ mod tests { assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru)); assert_eq!(Language::from_tag("de-DE"), Some(Language::De)); assert_eq!(Language::from_tag("de_AT"), Some(Language::De)); + } + + #[test] + fn tags_reduce_to_supported_languages_with_locale_suffixes() { assert_eq!(Language::from_tag("de-CH"), Some(Language::De)); assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr)); assert_eq!(Language::parse("pt"), Some(Language::PtBr)); @@ -441,6 +445,10 @@ mod tests { assert_eq!(Language::from_tag("fr_CA"), Some(Language::Fr)); assert_eq!(Language::parse("fr"), Some(Language::Fr)); assert_eq!(Language::from_tag("sr-Latn-RS"), Some(Language::Sr)); + } + + #[test] + fn tags_reduce_to_other_supported_languages() { assert_eq!(Language::from_tag("sr-RS"), Some(Language::Sr)); assert_eq!(Language::parse("sr"), Some(Language::Sr)); assert_eq!(Language::from_tag("tr-TR"), Some(Language::Tr)); @@ -452,6 +460,10 @@ mod tests { assert_eq!(Language::from_tag("it-IT"), Some(Language::It)); assert_eq!(Language::from_tag("it_CH.UTF-8"), Some(Language::It)); assert_eq!(Language::parse("it"), Some(Language::It)); + } + + #[test] + fn tags_reduce_to_remaining_supported_languages() { assert_eq!(Language::from_tag("uk-XX"), Some(Language::Uk)); assert_eq!(Language::parse("uk"), Some(Language::Uk)); assert_eq!(Language::from_tag("vi-VN"), Some(Language::Vi)); @@ -463,7 +475,6 @@ mod tests { assert_eq!(Language::from_tag("ro_MD.UTF-8"), Some(Language::Ro)); assert_eq!(Language::parse("ro"), Some(Language::Ro)); } - #[test] fn an_unset_language_preference_is_english_until_the_app_applies_the_system_one() { // Deserializing never reads the host locale (tests stay deterministic); the desktop app diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e24dad5e1..9a6d54030 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -557,6 +557,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "AutoSave" => "Automatické ukládání", "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", "All sheets" => "Všechny listy", + "Record Macro" => "Záznam makra", + "Stop Recording" => "Zastavit záznam", + "Use Relative References" => "Použít relativní odkazy", + "Run\nMacro" => "Spustit makro", + "Macros…" => "Makra…", + "Recording" => "Záznam", + "Stop" => "Zastavit", + "New Macro" => "Nové makro", + "Show" => "Zobrazit", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 218eceb43..6427b279d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -462,6 +462,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", "Text Box" => "Textfeld", + "Macros" => "Makros", + "Record Macro" => "Makro aufzeichnen", + "Stop Recording" => "Aufzeichnung beenden", + "Use Relative References" => "Relative Verweise verwenden", + "Run\nMacro" => "Makro ausführen", + "Macros…" => "Makros…", + "Recording" => "Aufzeichnung", + "Stop" => "Beenden", + "New Macro" => "Neues Makro", + "Show" => "Anzeigen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index d826b1eb8..9231f8244 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -983,6 +983,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { "Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο." } + "Record Macro" => "Καταγραφή μακροεντολής", + "Stop Recording" => "Διακοπή καταγραφής", + "Use Relative References" => "Χρήση σχετικών αναφορών", + "Run\nMacro" => "Εκτέλεση μακροεντολής", + "Macros…" => "Μακροεντολές…", + "Recording" => "Καταγραφή", + "New Macro" => "Νέα μακροεντολή", + "Show" => "Εμφάνιση", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index ab1fa1276..a4b4e884a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -478,6 +478,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", "Text Box" => "Cuadro de texto", + "Macros" => "Macros", + "Record Macro" => "Grabar macro", + "Stop Recording" => "Detener grabación", + "Use Relative References" => "Usar referencias relativas", + "Run\nMacro" => "Ejecutar macro", + "Macros…" => "Macros…", + "Recording" => "Grabando", + "Stop" => "Detener", + "New Macro" => "Nueva macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 34511876a..fdb20f7bf 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -450,6 +450,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", "Text Box" => "Zone de texte", + "Macros" => "Macros", + "Record Macro" => "Enregistrer une macro", + "Stop Recording" => "Arrêter l’enregistrement", + "Use Relative References" => "Utiliser les références relatives", + "Run\nMacro" => "Exécuter la macro", + "Macros…" => "Macros…", + "Recording" => "Enregistrement", + "Stop" => "Arrêter", + "New Macro" => "Nouvelle macro", + "Show" => "Afficher", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 7f79b4076..31bc6721c 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -986,6 +986,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Layout rapporto", "Compact" => "Compatto", "Tabular" => "Tabulare", + "Record Macro" => "Registra macro", + "Stop Recording" => "Interrompi registrazione", + "Use Relative References" => "Usa riferimenti relativi", + "Run\nMacro" => "Esegui macro", + "Macros…" => "Macro…", + "Recording" => "Registrazione", + "New Macro" => "Nuova macro", + "Show" => "Mostra", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ea48f8074..1f06755dd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -423,6 +423,14 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"), ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), + ("Record Macro", "マクロの記録"), + ("Stop Recording", "記録終了"), + ("Use Relative References", "相対参照で記録"), + ("Run\nMacro", "マクロの実行"), + ("Macros…", "マクロ…"), + ("Recording", "記録中"), + ("New Macro", "新しいマクロ"), + ("Show", "表示"), ]; /// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`]. diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 936e7417c..c54ed7eee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", "Text Box" => "텍스트 상자", + "Macros" => "매크로", + "Record Macro" => "매크로 기록", + "Stop Recording" => "기록 중지", + "Use Relative References" => "상대 참조로 기록", + "Run\nMacro" => "매크로 실행", + "Macros…" => "매크로…", + "Recording" => "기록 중", + "Stop" => "중지", + "New Macro" => "새 매크로", + "Show" => "표시", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 9cedcce74..a28459f83 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -527,6 +527,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom In" => "Inzoomen", "Zoom Out" => "Uitzoomen", "Dismiss" => "Negeren", + "Record Macro" => "Macro opnemen", + "Stop Recording" => "Opname stoppen", + "Use Relative References" => "Relatieve verwijzingen gebruiken", + "Run\nMacro" => "Macro uitvoeren", + "Macros…" => "Macro's…", + "Recording" => "Opnemen", + "Stop" => "Stoppen", + "New Macro" => "Nieuwe macro", + "Show" => "Weergeven", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 1a229f90d..d7741a9c6 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -977,6 +977,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Układ raportu", "Compact" => "Kompaktowy", "Tabular" => "Tabelaryczny", + "Record Macro" => "Zarejestruj makro", + "Stop Recording" => "Zatrzymaj rejestrowanie", + "Use Relative References" => "Użyj odwołań względnych", + "Run\nMacro" => "Uruchom makro", + "Macros…" => "Makra…", + "Recording" => "Rejestrowanie", + "New Macro" => "Nowe makro", + "Show" => "Pokaż", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index d72ee2460..c79be64e1 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -982,6 +982,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" } "All sheets" => "Todas as planilhas", + "Record Macro" => "Gravar Macro", + "Stop Recording" => "Parar Gravação", + "Use Relative References" => "Usar Referências Relativas", + "Run\nMacro" => "Executar Macro", + "Macros…" => "Macros…", + "Recording" => "Gravando", + "New Macro" => "Nova Macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index e71576848..058911d87 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1039,6 +1039,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Formatare zonă diagramă…", "Error checking options" => "Opțiuni de verificare a erorilor", "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", + "Record Macro" => "Înregistrare macrocomandă", + "Stop Recording" => "Oprire înregistrare", + "Use Relative References" => "Utilizare referințe relative", + "Run\nMacro" => "Rulare macrocomandă", + "Macros…" => "Macrocomenzi…", + "Recording" => "Înregistrare", + "New Macro" => "Macrocomandă nouă", + "Show" => "Afișare", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e3ae28581..b0e894095 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -448,6 +448,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", "Text Box" => "Надпись", + "Macros" => "Макросы", + "Record Macro" => "Запись макроса", + "Stop Recording" => "Остановить запись", + "Use Relative References" => "Относительные ссылки", + "Run\nMacro" => "Выполнить макрос", + "Macros…" => "Макросы…", + "Recording" => "Запись", + "Stop" => "Остановить", + "New Macro" => "Новый макрос", + "Show" => "Показать", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index db75b63c2..c9ce49e9e 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", "Text Box" => "Текстуално поље", + "Macros" => "Makroi", + "Record Macro" => "Snimi makro", + "Stop Recording" => "Zaustavi snimanje", + "Use Relative References" => "Koristi relativne reference", + "Run\nMacro" => "Pokreni makro", + "Macros…" => "Makroi…", + "Recording" => "Snimanje", + "Stop" => "Zaustavi", + "New Macro" => "Novi makro", + "Show" => "Prikaži", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ea1a16380..68cd198bb 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Print" => "Yazdır", "Print What" => "Yazdırılacaklar", "Text Box" => "Metin Kutusu", + "Macros" => "Makrolar", + "Record Macro" => "Makro Kaydet", + "Stop Recording" => "Kaydı Durdur", + "Use Relative References" => "Göreli Başvuruları Kullan", + "Run\nMacro" => "Makro Çalıştır", + "Macros…" => "Makrolar…", + "Recording" => "Kaydediliyor", + "Stop" => "Durdur", + "New Macro" => "Yeni Makro", + "Show" => "Göster", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8055f77fc..72c1bf474 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -981,6 +981,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Report Layout", "Compact" => "Compact", "Tabular" => "Tabular", + "Record Macro" => "Записати макрос", + "Stop Recording" => "Зупинити запис", + "Use Relative References" => "Використовувати відносні посилання", + "Run\nMacro" => "Запустити макрос", + "Macros…" => "Макроси…", + "Recording" => "Запис", + "New Macro" => "Новий макрос", + "Show" => "Показати", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 874c7caff..df76b4a93 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Enable iterative calculation" => "Bật tính toán lặp", "Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.", "Text Box" => "Hộp văn bản", + "Macros" => "Macro", + "Record Macro" => "Ghi Macro", + "Stop Recording" => "Dừng Ghi", + "Use Relative References" => "Dùng Tham chiếu Tương đối", + "Run\nMacro" => "Chạy Macro", + "Macros…" => "Macro…", + "Recording" => "Đang ghi", + "Stop" => "Dừng", + "New Macro" => "Macro Mới", + "Show" => "Hiện", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9ce01e39d..d6dd0eabd 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -438,6 +438,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", "Text Box" => "文本框", + "Macros" => "宏", + "Record Macro" => "录制宏", + "Stop Recording" => "停止录制", + "Use Relative References" => "使用相对引用", + "Run\nMacro" => "运行宏", + "Macros…" => "宏…", + "Recording" => "正在录制", + "Stop" => "停止", + "New Macro" => "新建宏", + "Show" => "显示", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 385ed076b..c1f894ea9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -135,6 +135,25 @@ pub enum Icon { OpenFolder, Info, Print, + /// A macro: a script page with a run arrow. + Macro, + /// Record Macro: a ring around the recording dot. + Record, + /// Use Relative References: a cell and an arrow to a cell offset from it. + Relative, + /// A reference box's button: shrink the window to the box (Collapse) or restore it (Expand). + Collapse, + Expand, + /// The macro debugger's Run: a play triangle. + Play, + /// Step: an arrow down onto a line. + Step, + /// Continue: a bar and a play triangle. + Continue, + /// Run to Here: an arrow to a marked line. + RunTo, + /// Stop: a square. + StopSquare, } struct G<'a> { @@ -175,10 +194,10 @@ impl G<'_> { self.p.circle_stroke(self.pt(x, y), r * self.s, Stroke::new(1.0, c)); } } - fn text(&self, x: f32, y: f32, t: &str, size: f32, c: Color32, bold: bool, italic: bool) { - let fam = if bold { + fn text(&self, x: f32, y: f32, t: &str, size: f32, c: Color32, style: (bool, bool)) { + let fam = if style.0 { crate::theme::CELL_BOLD - } else if italic { + } else if style.1 { crate::theme::CELL_ITALIC } else { crate::theme::CELL @@ -194,11 +213,12 @@ impl G<'_> { struct Palette { paper: Color32, cat: [Color32; 8], + recording: Color32, } impl Palette { fn of(t: &Tokens) -> Palette { - Palette { paper: t.paper, cat: t.cat } + Palette { paper: t.paper, cat: t.cat, recording: t.recording } } fn cat(&self, c: Cat) -> Color32 { @@ -269,14 +289,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c); g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None); } - Icon::Bold => g.text(8.0, 8.5, "B", 12.0, c, true, false), - Icon::Italic => g.text(8.0, 8.5, "I", 12.0, c, false, true), + Icon::Bold => g.text(8.0, 8.5, "B", 12.0, c, (true, false)), + Icon::Italic => g.text(8.0, 8.5, "I", 12.0, c, (false, true)), Icon::Underline => { - g.text(8.0, 7.5, "U", 11.0, c, false, false); + g.text(8.0, 7.5, "U", 11.0, c, (false, false)); g.stroke(&[(4.0, 14.0), (12.0, 14.0)], c); } Icon::Strike => { - g.text(8.0, 8.0, "ab", 10.0, c, false, false); + g.text(8.0, 8.0, "ab", 10.0, c, (false, false)); g.stroke(&[(2.5, 8.5), (13.5, 8.5)], c); } Icon::Borders | Icon::BorderAll => { @@ -311,47 +331,20 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.circle(13.5, 10.0, 1.2, Some(c), None); } Icon::FontColor => { - g.text(8.0, 7.0, "A", 11.0, c, false, false); + g.text(8.0, 7.0, "A", 11.0, c, (false, false)); } - Icon::AlignTop | Icon::AlignMiddle | Icon::AlignBottom => { - let ys: [f32; 3] = match icon { - Icon::AlignTop => [3.0, 5.5, 8.0], - Icon::AlignMiddle => [5.5, 8.0, 10.5], - _ => [8.0, 10.5, 13.0], - }; - let edge = match icon { - Icon::AlignTop => 1.5, - Icon::AlignMiddle => -1.0, - _ => 14.5, - }; - if edge > 0.0 { - g.stroke(&[(2.0, edge), (14.0, edge)], light); - } - for (i, y) in ys.iter().enumerate() { - let w = if i == 1 { 8.0 } else { 10.0 }; - g.stroke(&[(8.0 - w / 2.0, *y), (8.0 + w / 2.0, *y)], c); - } - } - Icon::AlignLeft | Icon::AlignCenter | Icon::AlignRight | Icon::IndentDec | Icon::IndentInc => { - for (i, y) in [3.5f32, 6.5, 9.5, 12.5].iter().enumerate() { - let w = if i % 2 == 0 { 12.0 } else { 8.0 }; - let (x0, x1) = match icon { - Icon::AlignLeft => (2.0, 2.0 + w), - Icon::AlignRight => (14.0 - w, 14.0), - Icon::AlignCenter => (8.0 - w / 2.0, 8.0 + w / 2.0), - _ => (if i == 0 || i == 3 { 2.0 } else { 7.0 }, 14.0), - }; - g.stroke(&[(x0, *y), (x1, *y)], c); - } - if icon == Icon::IndentInc { - g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue); - } - if icon == Icon::IndentDec { - g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue); - } + Icon::AlignTop + | Icon::AlignMiddle + | Icon::AlignBottom + | Icon::AlignLeft + | Icon::AlignCenter + | Icon::AlignRight + | Icon::IndentDec + | Icon::IndentInc => { + paint_alignment(&g, icon, c, light, blue); } Icon::Orientation => { - g.text(6.0, 9.0, "ab", 8.5, c, false, false); + g.text(6.0, 9.0, "ab", 8.5, c, (false, false)); g.stroke(&[(9.0, 13.0), (14.0, 3.0)], blue); g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], blue); } @@ -373,18 +366,18 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], blue); g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], blue); } - Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false), - Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false), - Icon::Comma => g.text(8.0, 7.0, ",", 14.0, c, true, false), + Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, (false, false)), + Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, (false, false)), + Icon::Comma => g.text(8.0, 7.0, ",", 14.0, c, (true, false)), Icon::DecInc => { - g.text(5.0, 5.0, ".0", 6.5, c, false, false); - g.text(10.0, 11.0, ".00", 6.5, c, false, false); + g.text(5.0, 5.0, ".0", 6.5, c, (false, false)); + g.text(10.0, 11.0, ".00", 6.5, c, (false, false)); g.stroke(&[(2.0, 12.0), (5.0, 12.0)], blue); g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], blue); } Icon::DecDec => { - g.text(6.0, 5.0, ".00", 6.5, c, false, false); - g.text(11.0, 11.0, ".0", 6.5, c, false, false); + g.text(6.0, 5.0, ".00", 6.5, c, (false, false)); + g.text(11.0, 11.0, ".0", 6.5, c, (false, false)); g.stroke(&[(2.0, 12.0), (5.5, 12.0)], blue); g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], blue); } @@ -427,7 +420,7 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { } } } - Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false), + Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, (false, false)), Icon::FillDown => { g.rect(4.0, 2.0, 8.0, 12.0, Some(paper), Some(light)); g.stroke(&[(8.0, 4.0), (8.0, 11.0)], blue); @@ -438,8 +431,8 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], red); } Icon::SortFilter => { - g.text(4.5, 4.5, "A", 6.0, c, false, false); - g.text(4.5, 11.5, "Z", 6.0, c, false, false); + g.text(4.5, 4.5, "A", 6.0, c, (false, false)); + g.text(4.5, 11.5, "Z", 6.0, c, (false, false)); g.stroke(&[(8.0, 3.0), (8.0, 13.0)], blue); g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], blue); g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light); @@ -492,7 +485,7 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c)); g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4); } - Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, false, true), + Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, (false, true)), Icon::Plus => { g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3); g.line(&[(3.0, 8.0), (13.0, 8.0)], c, 1.3); @@ -564,13 +557,13 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { } Icon::TextBox => { g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c)); - g.text(8.0, 8.0, "A", 8.0, c, false, false); + g.text(8.0, 8.0, "A", 8.0, c, (false, false)); } - Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, false, false), - Icon::Function => g.text(8.0, 8.0, "fx", 11.0, c, false, true), + Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)), + Icon::Function => g.text(8.0, 8.0, "fx", 11.0, c, (false, true)), Icon::Name => { g.rect(1.5, 4.0, 13.0, 8.0, None, Some(c)); - g.text(8.0, 8.0, "ab", 8.0, c, false, false); + g.text(8.0, 8.0, "ab", 8.0, c, (false, false)); } Icon::Trace => { g.circle(4.0, 12.0, 1.6, Some(blue), None); @@ -588,8 +581,8 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { Icon::Filter => g.poly(&[(2.0, 3.0), (14.0, 3.0), (9.5, 8.5), (9.5, 13.5), (6.5, 12.0), (6.5, 8.5)], c), Icon::SortAsc | Icon::SortDesc => { let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") }; - g.text(5.0, 4.5, a, 6.5, c, false, false); - g.text(5.0, 11.5, b, 6.5, c, false, false); + g.text(5.0, 4.5, a, 6.5, c, (false, false)); + g.text(5.0, 11.5, b, 6.5, c, (false, false)); g.stroke(&[(11.0, 2.5), (11.0, 13.0)], blue); g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], blue); } @@ -614,9 +607,9 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.stroke(&[(7.0, y), (14.0, y)], light); } match icon { - Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, true, false), - Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, true, false), - _ => g.text(11.0, 13.0, "Σ", 6.0, blue, false, false), + Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, (true, false)), + Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, (true, false)), + _ => g.text(11.0, 13.0, "Σ", 6.0, blue, (false, false)), } } Icon::Lock => { @@ -624,7 +617,7 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2); } Icon::Spell => { - g.text(7.0, 6.0, "abc", 7.0, c, false, false); + g.text(7.0, 6.0, "abc", 7.0, c, (false, false)); g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], green, 1.4); } Icon::Freeze => { @@ -758,6 +751,50 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); g.circle(12.3, 7.6, 0.6, Some(c), None); } + Icon::Macro => { + g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c)); + for y in [4.5, 7.0, 9.5] { + g.stroke(&[(4.5, y), (9.5, y)], light); + } + g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green); + } + Icon::Record => { + g.circle(8.0, 8.0, 6.0, None, Some(c)); + g.circle(8.0, 8.0, 3.5, Some(pal.recording), None); + } + Icon::Relative => { + g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c)); + g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c)); + g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1); + g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c); + } + Icon::Collapse | Icon::Expand => { + // A small sheet with a selected block and an arrow pointing up (collapse) or down. + g.rect(2.0, 6.5, 12.0, 7.5, None, Some(c)); + g.rect(4.0, 9.0, 4.0, 3.0, Some(c), None); + if icon == Icon::Collapse { + g.poly(&[(8.0, 1.0), (11.5, 4.5), (4.5, 4.5)], c); + } else { + g.poly(&[(8.0, 5.5), (11.5, 2.0), (4.5, 2.0)], c); + } + } + Icon::Play => g.poly(&[(4.0, 2.5), (13.0, 8.0), (4.0, 13.5)], green), + Icon::Step => { + g.line(&[(8.0, 1.5), (8.0, 9.0)], c, 1.3); + g.poly(&[(4.8, 7.0), (11.2, 7.0), (8.0, 10.8)], c); + g.line(&[(3.0, 13.5), (13.0, 13.5)], c, 1.3); + } + Icon::Continue => { + g.rect(2.5, 2.5, 2.2, 11.0, Some(c), None); + g.poly(&[(6.5, 2.5), (14.0, 8.0), (6.5, 13.5)], green); + } + Icon::RunTo => { + g.line(&[(2.0, 5.0), (9.0, 5.0)], c, 1.3); + g.poly(&[(8.0, 2.0), (12.0, 5.0), (8.0, 8.0)], c); + g.line(&[(2.0, 11.5), (14.0, 11.5)], c, 1.6); + g.line(&[(13.0, 3.0), (13.0, 9.0)], c, 1.1); + } + Icon::StopSquare => g.rect(3.0, 3.0, 10.0, 10.0, Some(pal.recording), None), } } @@ -798,3 +835,42 @@ pub const LIBRARY: &[(&str, Icon)] = &[ pub fn from_name(name: &str) -> Option { LIBRARY.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, i)| *i) } + +fn paint_alignment(g: &G<'_>, icon: Icon, c: Color32, light: Color32, blue: Color32) { + if matches!(icon, Icon::AlignTop | Icon::AlignMiddle | Icon::AlignBottom) { + let ys: [f32; 3] = match icon { + Icon::AlignTop => [3.0, 5.5, 8.0], + Icon::AlignMiddle => [5.5, 8.0, 10.5], + _ => [8.0, 10.5, 13.0], + }; + let edge = match icon { + Icon::AlignTop => 1.5, + Icon::AlignMiddle => -1.0, + _ => 14.5, + }; + if edge > 0.0 { + g.stroke(&[(2.0, edge), (14.0, edge)], light); + } + for (i, y) in ys.iter().enumerate() { + let w = if i == 1 { 8.0 } else { 10.0 }; + g.stroke(&[(8.0 - w / 2.0, *y), (8.0 + w / 2.0, *y)], c); + } + } else { + for (i, y) in [3.5f32, 6.5, 9.5, 12.5].iter().enumerate() { + let w = if i % 2 == 0 { 12.0 } else { 8.0 }; + let (x0, x1) = match icon { + Icon::AlignLeft => (2.0, 2.0 + w), + Icon::AlignRight => (14.0 - w, 14.0), + Icon::AlignCenter => (8.0 - w / 2.0, 8.0 + w / 2.0), + _ => (if i == 0 || i == 3 { 2.0 } else { 7.0 }, 14.0), + }; + g.stroke(&[(x0, *y), (x1, *y)], c); + } + if icon == Icon::IndentInc { + g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue); + } + if icon == Icon::IndentDec { + g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue); + } + } +} diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 54058dd71..ea5daf33c 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -60,15 +60,47 @@ fn borders(preset: &str) -> Shortcut { pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Option { use Key::*; let navigation = matches!(key, ArrowLeft | ArrowRight | ArrowUp | ArrowDown | PageUp | PageDown | Home | End | Insert | Delete); - let k = if navigation { key } else { physical.unwrap_or(key) }; - // Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac, - // and Alt/Option alone. - let ctrl = m.ctrl && !m.alt; - let cmd = m.command && !m.alt; - let alt = m.alt && !m.ctrl && !m.command; - let ctrl_alt = m.alt && (m.ctrl || m.command); - let plain = !m.ctrl && !m.command && !m.alt; - let s = m.shift; + let key = if navigation { key } else { physical.unwrap_or(key) }; + let ctx = LookupContext { + key, + modifiers: m, + ctrl: m.ctrl && !m.alt, + cmd: m.command && !m.alt, + alt: m.alt && !m.ctrl && !m.command, + ctrl_alt: m.alt && (m.ctrl || m.command), + plain: !m.ctrl && !m.command && !m.alt, + shift: m.shift, + mac, + }; + lookup_number_formats(ctx) + .or_else(|| lookup_control_shortcuts(ctx)) + .or_else(|| lookup_windows_shortcuts(ctx)) + .or_else(|| lookup_platform_letters(ctx)) + .or_else(|| lookup_selection_shortcuts(ctx)) + .or_else(|| lookup_control_alt_shortcuts(ctx)) + .or_else(|| lookup_alt_shortcuts(ctx)) + .or_else(|| lookup_function_shortcuts(ctx)) +} + +#[derive(Clone, Copy)] +struct LookupContext { + key: Key, + modifiers: Modifiers, + ctrl: bool, + cmd: bool, + alt: bool, + ctrl_alt: bool, + plain: bool, + shift: bool, + mac: bool, +} + +fn lookup_number_formats(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; Some(match k { // Ctrl+Shift+~ ! @ # $ % ^ & _ : number formats and borders (Windows and Mac). Backtick if ctrl && s => Shortcut::Run("home.numberFormat", json!({"format": "General"})), @@ -89,9 +121,31 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio OpenBracket if ctrl => Shortcut::Run("formulas.selectPrecedents", json!({"all": true})), CloseBracket if ctrl && !s => run("formulas.selectDependents"), CloseBracket if ctrl => Shortcut::Run("formulas.selectDependents", json!({"all": true})), + _ => return None, + }) +} + +fn lookup_control_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;. Semicolon if mac && ctrl => Shortcut::Date, Semicolon if mac && cmd && !s => Shortcut::Time, + _ => return None, + }) +} + +fn lookup_windows_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Ctrl+2..5 (Windows). Num2 if ctrl && !mac => run("home.bold"), Num3 if ctrl && !mac => run("home.italic"), @@ -99,6 +153,17 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Num5 if ctrl && !mac => run("home.strikethrough"), // Ctrl+6 hides and shows objects (Windows, and Control+6 in Excel for Mac). Num6 if ctrl && !s => run("view.showObjects"), + _ => return None, + }) +} + +fn lookup_platform_letters(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Letters that differ between Windows and Mac. L if (cmd || ctrl) && s => run("data.filter"), L if cmd && mac => run("home.alignLeft"), @@ -119,6 +184,17 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Period if cmd && s => run("home.increaseFontSize"), Comma if cmd && s => run("home.decreaseFontSize"), Space if cmd && s => run("edit.selectAll"), + _ => return None, + }) +} + +fn lookup_selection_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Shift+Space selects the row; Ctrl+Space (Windows) / Control+Space (Excel for Mac) the // column. Cmd+Space stays free: it is the macOS Spotlight shortcut. Space if plain && s => run("selection.row"), @@ -127,6 +203,17 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio Home if cmd && s => Shortcut::ExtendToStart, End if cmd && s => Shortcut::ExtendToEnd, Home if plain && s => Shortcut::ExtendToRowStart, + _ => return None, + }) +} + +fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Ctrl+Alt / Cmd+Option combinations. V if ctrl_alt || (mac && m.ctrl && m.command) => Shortcut::Dialog("pasteSpecial", json!({})), Equals | Plus if ctrl_alt => Shortcut::Zoom(10), @@ -139,6 +226,17 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio ArrowRight if ctrl_alt && mac => borders("right"), ArrowUp if ctrl_alt && mac => borders("top"), ArrowDown if ctrl_alt && mac => borders("bottom"), + _ => return None, + }) +} + +fn lookup_alt_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Alt / Option alone. Equals if alt && !s && !mac => run("formulas.autoSum"), ArrowDown if alt && !s => Shortcut::DropDown, @@ -151,6 +249,17 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio F1 if alt && s => run("home.insertSheet"), F1 if alt => run("insert.chart"), F10 if alt && s => Shortcut::Dialog("errorChecking", json!({})), + _ => return None, + }) +} + +fn lookup_function_shortcuts(ctx: LookupContext) -> Option { + use Key::*; + let LookupContext { key: k, ctrl, cmd, alt, ctrl_alt, plain, shift: s, mac, .. } = ctx; + let _ = (ctrl, cmd, alt, ctrl_alt, plain, s, mac); + let m = ctx.modifiers; + let _ = m; + Some(match k { // Function keys. F1 if cmd && !s => run("view.collapseRibbon"), F2 if (cmd || ctrl) && s => Shortcut::Dialog("comment", json!({"threaded": true})), diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index ef0011861..2a2509f08 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -342,8 +342,8 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param) ("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal) ("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros) - ("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu) - ("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros) + ("WMR", Entry::Finish(Action::Dialog("recordMacro"))), // Excel: Record Macro; Record Macro dialog (stops recording while recording) + ("WMV", Entry::Finish(Action::Dialog("macros"))), // Excel: View Macros; containment(Macros): the Macros dialog ("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window ("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View ("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index f0e2c8f99..5dfe7b2f5 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2370,17 +2370,17 @@ }, { "seq": "WMR", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "recordMacro", "excel": "Record Macro", - "note": "Record Macro (macros menu)" + "note": "Record Macro dialog (stops recording while recording)" }, { "seq": "WMV", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "macros", "excel": "View Macros", - "note": "containment(Macros)" + "note": "containment(Macros): the Macros dialog" }, { "seq": "WN", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8cfccb68f..db9ab316f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// GridCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png")), + accent: Some([0x1f, 0x9d, 0x55]), + ..craft_update::suite::GRIDCRAFT +}; + pub mod backstage; mod border_preview; mod cell_pictures; @@ -15,6 +24,7 @@ pub mod control; mod craft_fonts_policy; pub mod credits; pub mod dialogs; +pub mod dnd; #[cfg(not(target_arch = "wasm32"))] pub mod doc_windows; pub mod editor; @@ -27,6 +37,8 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_editor; +pub mod macro_ui; pub mod outline; pub mod panes; pub mod paper_view; @@ -52,6 +64,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; + +mod app; +mod clock; +mod services; +mod state; + +pub use clock::{monotonic_ms, now_ms}; +pub use services::{Chrome, Inbox, Services}; +pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; pub mod desktop_theme; /// Persisted UI preferences. @@ -160,19 +181,6 @@ impl UiState { } } -/// Per-sheet view state (scroll position in sheet points). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SheetView { - pub scroll: egui::Vec2, -} - -/// Severity of a notice shown in the info bar under the ribbon. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NoticeKind { - Info, - Warning, -} - /// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the /// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). #[derive(Clone, Debug, PartialEq)] @@ -183,74 +191,6 @@ pub struct Notice { pub action: Option<(String, String)>, } -/// An answer to the Save Changes question. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SaveAnswer { - Save, - DontSave, - Cancel, -} - -/// One open window, as View › Switch Windows and the macOS Window menu list it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct OpenWindow { - /// The window's id, the `window` param of `view.switchWindows`. - pub window: u64, - /// Its workbook's `DocState::uid`. - pub doc: u64, - pub title: String, - pub dirty: bool, - pub active: bool, -} - -impl OpenWindow { - /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it - /// while the workbook has unsaved changes. - pub fn label(&self) -> String { - if self.dirty { format!("{} •", self.title) } else { self.title.clone() } - } -} - -/// Host services the platform shell provides (file pickers, opening URLs). -#[derive(Default)] -pub struct Services { - /// Desktop: discover configured printers on a worker. - pub printers: Option, - /// Desktop: submit the PDF with the chosen settings on a worker. - pub print_native: Option, - pub pick_open: Option Option>>, - pub pick_save: Option Option>>, - /// Native picture picker (PNG/JPEG), separate from workbook opening. - pub pick_picture: Option Option>>, - /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot - /// insert into a subsequent dialog or open the image as a workbook. - pub pick_picture_async: Option>, - pub open_url: Option>, - /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the - /// write (including any asynchronous fallback); an immediate error uses egui's text path. - pub copy_html: Option Result<(), String>>>, - /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the - /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from - /// the host pastes the plain text alone. - pub paste_html: Option Option>>, - /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V - /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes - /// GridCraft's own clipboard. - pub paste_text: Option Option>>, - /// Web: deliver a file to the user (name, bytes). - pub download: Option>, - /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. - pub open_async: Option>, - /// Files delivered asynchronously (name, bytes), opened on the next frame. - pub inbox: Option, - /// Web: hand PDF bytes to the browser's print flow directly (no download, - /// no intermediate file) — opens the system print dialog on that PDF. - pub print: Option>, -} - -/// Shared queue of files read asynchronously (browser file picker, drag and drop). -pub type Inbox = std::sync::Arc)>>>; - #[derive(Clone, Debug, Default)] pub struct Perf { pub frame_ms: f64, @@ -259,26 +199,6 @@ pub struct Perf { last: f64, } -/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host -/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar -/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the -/// host, not a user preference. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Chrome { - /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and - /// the theme switch. - pub title_bar: bool, - /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and - /// commands such as `ui.backstage`). - pub file_tab: bool, -} - -impl Default for Chrome { - fn default() -> Self { - Chrome { title_bar: true, file_tab: true } - } -} - pub struct SheetApp { pub session: Session, pub ui: UiState, @@ -358,6 +278,8 @@ pub struct SheetApp { /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). tab_run: Option<(u64, usize, u32, CellRef)>, + /// Automate › Macros: relative recording, recent macros, a macro's question. + pub macros: macro_ui::MacroUi, pub started: f64, /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. @@ -381,1552 +303,6 @@ pub struct SheetApp { updates_rt: updates_ui::Runtime, } -impl SheetApp { - /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. - pub fn new(session: Session, services: Services) -> SheetApp { - SheetApp { - session, - ui: UiState::default(), - chrome: Chrome::default(), - views: HashMap::new(), - editor: None, - text_box_editor: None, - dialog: None, - backstage: None, - message: None, - toast: None, - notice: None, - services, - printing: Default::default(), - print_feedback_seen: None, - synthetic: Default::default(), - synthetic_pending: false, - synthetic_injected_at_pass: None, - control_rx: None, - grid: grid::GridState { - last_border: "bottom".into(), - last_fill: "#FFFF00".into(), - last_font_color: "#C00000".into(), - ..Default::default() - }, - perf: Perf::default(), - fonts_ready: false, - fonts_set: false, - prepared_fonts: false, - fonts_bound: Vec::new(), - fonts_seen: (0, 0), - fonts_rebind: false, - fonts_han: None, - effective_dark: false, - desktop_theme: desktop_theme::DesktopTheme::default(), - desktop_dark: None, - name_box: None, - keytips: keytips::KeyTips::default(), - shots: control::Shots::default(), - capture_epoch: 0, - capture_geometry: None, - capture_geometry_from_raw: false, - selected_chart: None, - clipboard_text_at_copy: None, - cut_text: None, - tab_run: None, - started: now_ms(), - allow_close: false, - open_switch_windows: false, - ui_window: None, - parked: HashMap::new(), - native_windows: false, - arrange_windows: None, - #[cfg(not(target_arch = "wasm32"))] - updates_rt: Default::default(), - } - } - - /// One-time context setup: start cataloging the machine's fonts, then install the fonts and - /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). - /// - /// [`logic`]: SheetApp::logic - pub fn setup_context(ctx: &egui::Context, dark: bool) { - fonts::start_scan(); - Self::setup_context_for_language(ctx, dark, i18n::Language::En); - } - - /// Sets up fonts and visuals for a specific persisted interface language. - pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { - ctx.set_fonts(theme::font_definitions_for_language(language)); - theme::apply(ctx, dark); - } - - /// Test-harness setup: mark a deterministic font set (see - /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither - /// starts the host font scan nor replaces the harness's pinned set. Production uses - /// [`Self::setup_context`] instead. - #[doc(hidden)] - pub fn use_prepared_fonts(&mut self) { - self.prepared_fonts = true; - self.fonts_ready = true; - self.fonts_set = true; - self.fonts_rebind = false; - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - self.fonts_bound = self.used_fonts(); - self.fonts_seen = self.fonts_key(); - } - - /// The font names the open workbooks use: every style's font plus the theme's heading/body - /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as - /// each may be showing in a window of its own. - pub fn used_fonts(&self) -> Vec { - let mut names: Vec = Vec::new(); - for d in self.session.documents() { - names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); - names.push(d.wb.theme.major_font.clone()); - names.push(d.wb.theme.minor_font.clone()); - // Text boxes and shapes name their own typefaces. - names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); - } - names - } - - /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. - fn fonts_key(&self) -> (u64, u64) { - self.session - .documents() - .iter() - .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) - } - - /// Runs an engine command (or a UI command) and handles UI requests it makes. - pub fn run(&mut self, id: &str, params: Json) -> Result { - let r = self.run_command(id, params); - // This copy leaves the system clipboard alone: note what it holds, so Paste can tell - // that GridCraft's copy is newer while that text is still there. - if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { - self.clipboard_text_at_copy = self.system_clipboard_text(); - self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; - } - r - } - - fn run_command(&mut self, id: &str, params: Json) -> Result { - let switches_window = id.starts_with("window.") - || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") - || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); - if id.starts_with("file.") || switches_window { - self.settle_drafts()?; - } - if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { - self.store_view_state(); - } - if matches!( - id, - "sheet.activate" - | "sheet.next" - | "sheet.previous" - | "sheet.move" - | "sheet.hide" - | "sheet.unhide" - | "home.insertSheet" - | "home.deleteSheet" - | "edit.undo" - | "edit.redo" - | "object.delete" - | "object.move" - ) { - self.commit_text_box_edit()?; - } - if let Some(r) = self.run_ui_command(id, ¶ms) { - return r; - } - let before = self.session.active_window(); - let r = self.session.run(id, params); - if id.starts_with("file.") && self.session.active_window() != before { - // File › New or Open from the Backstage view: the window it was in goes back to its sheet. - self.backstage = None; - } - // Requests the command made (a dialog, a draft) belong to the window it left active. - self.follow_active_window(); - self.after_engine(); - if id == "view.arrangeAll" - && self.native_windows - && let Ok(r) = &r - { - let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); - let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - self.arrange_windows = Some((arrange, windows)); - } - // The new chart is selected, as after inserting any object, so Delete removes it, not its data. - if id == "insert.chart" - && let Ok(r) = &r - && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) - { - self.selected_chart = Some(chart); - } - if id == "file.exportPdf" - && let Ok(r) = &r - { - self.deliver_pdf(r); - } - r - } - - /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep - /// their existing internal clipboard behavior and do not write to the host clipboard. - pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { - // Only ask the engine for HTML when the host can publish it. - let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { - Ok(result) => result, - Err(e) => return self.alert(e), - }; - let Some(text) = result.get("text").and_then(Json::as_str) else { return }; - // The system clipboard gets this copy, so its text decides what Paste pastes. - self.clipboard_text_at_copy = None; - self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); - if let Some(html) = result.get("html").and_then(Json::as_str) - && let Some(copy_html) = &mut self.services.copy_html - { - match copy_html(html, text) { - Ok(()) => return, - Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), - } - } - ctx.copy_text(text.to_string()); - } - - /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), - /// with the clipboard's HTML flavour when the host can read it. - /// - /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text - /// that came with it is not pasted again over the moved block; GridCraft's own (empty) - /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the - /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. - pub fn paste_external(&mut self, text: String) { - if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { - if self.session.clipboard.is_none() { - return self.run_or_alert("edit.paste", json!({})); - } - } else { - self.cut_text = None; - } - let html = self.services.paste_html.as_mut().and_then(|read| read()); - let params = match html { - Some(html) => json!({"text": text, "html": html}), - None => json!({"text": text}), - }; - self.run_or_alert("edit.paste", params); - } - - /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, no host clipboard (web), or the text that was there when GridCraft last copied - /// without publishing, GridCraft's own clipboard is pasted. - pub fn paste_from_clipboard(&mut self) { - match self.system_clipboard_text() { - Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), - _ => self.run_or_alert("edit.paste", json!({})), - } - } - - /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. - /// `None` when it holds no text or the host can't read it. - fn system_clipboard_text(&mut self) -> Option { - self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) - } - - /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the - /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). - fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { - fn chars(t: &str) -> impl Iterator + '_ { - t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') - } - match (a, b) { - (Some(a), Some(b)) => chars(a).eq(chars(b)), - _ => false, - } - } - - /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. - fn deliver_pdf(&self, result: &Json) { - if result.get("path").is_some() { - return; - } - if let Some(dl) = &self.services.download - && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); - } - } - - /// Runs a command and shows its error in a message box (for menu/ribbon clicks). - /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls - /// this once — tests and embedders don't start the watcher thread). - pub fn watch_desktop_theme(&mut self) { - self.desktop_theme = desktop_theme::DesktopTheme::start(); - } - - pub fn run_or_alert(&mut self, id: &str, params: Json) { - // A menu/shortcut opens the settings first; the Print page's button submits explicitly. - if id == "file.print" { - if self.services.print_native.is_some() { - self.open_backstage(backstage::Page::Print); - return; - } - return self.print_now(params); - } - // Menu-style Export as PDF with no file named: ask where to save, like Save As does. - // Programmatic callers use `run` and keep getting the PDF bytes back. - if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { - return self.export_pdf_with_picker(params); - } - // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks - // which chart to insert; programmatic callers of `run` still get the first recommendation. - if id == "insert.recommendedCharts" && params.get("type").is_none() { - return self.open_dialog("recommendedCharts", json!({})); - } - // Switch Windows with no window named (key tip, command search) opens the View tab's - // list to choose from; programmatic callers of `run` get the list back. - if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { - self.ui.ribbon_tab = "View".into(); - self.ui.ribbon_collapsed = false; - self.open_switch_windows = true; - return; - } - match self.run(id, params) { - // A refused cell draft already shows its own message (with the rule's title). - Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} - Err(e) => self.alert(e), - Ok(_) => {} - } - } - - /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other - /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. - fn export_pdf_with_picker(&mut self, mut params: Json) { - if !self.commit_edit(0, 0, false, false) { - return; - } - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); - let Some(pick) = &self.services.pick_save else { return }; - let Some(path) = pick(&format!("{stem}.pdf")) else { return }; - // The picker may suggest a workbook extension; the export writes a PDF. - let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); - match &mut params { - Json::Object(map) => { - map.insert("path".into(), Json::String(path)); - } - other => *other = json!({"path": path}), - } - if let Err(e) = self.run("file.exportPdf", params) { - self.alert(e); - } - } - - /// Shows a command error: a toast for an unavailable command, else a message box. - fn alert(&mut self, e: String) { - // Shown in the window the user ends up in, also after a command that switched windows. - self.follow_active_window(); - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } - } - - /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the - /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible - /// on the Print page. The engine command still returns PDF bytes without submitting. - pub fn print_now(&mut self, params: Json) { - if let Err(error) = self.submit_print(params) { - self.alert(error); - } - } - - fn submit_print(&mut self, params: Json) -> Result { - if self.services.print_native.is_some() && !self.printing.ready() { - return Err("Select an available printer in File > Print before submitting.".into()); - } - let pdf = self.run("file.print", params)?; - let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; - let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; - let len = bytes.len(); - if let Some(submit) = &self.services.print_native { - self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; - self.print_feedback_seen = None; - } else if let Some(print) = &self.services.print { - print(&bytes); - } else { - return Err("Printing is unavailable here; export a PDF instead.".into()); - } - Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) - } - - /// UI-only commands (dialogs, view toggles that aren't saved in the file). - fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { - let r = match id { - "app.checkForUpdates" | "help.checkForUpdates" => { - self.check_for_updates(); - Ok(Json::Null) - } - "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), - "ui.printSubmit" => self.submit_print(p.clone()), - "ui.ribbonTab" => { - self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); - Ok(Json::Null) - } - "view.collapseRibbon" => { - self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; - Ok(Json::Null) - } - "view.expandFormulaBar" => { - self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); - Ok(json!({"on": self.ui.formula_bar_expanded})) - } - "view.darkMode" => { - let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; - self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); - self.ui.system_theme = false; - Ok(json!({"on": self.ui.dark})) - } - "view.theme" => { - match p.get("mode").and_then(Json::as_str) { - Some("system") => self.ui.system_theme = true, - Some(mode @ ("light" | "dark")) => { - self.ui.dark = mode == "dark"; - self.ui.system_theme = false; - } - _ => return Some(Err("theme mode must be system, light, or dark".into())), - } - Ok(json!({"mode": self.ui.theme_mode()})) - } - "view.animations" => { - self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); - Ok(json!({"on": self.ui.animations})) - } - "options.autoComplete" => { - self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); - Ok(json!({"on": self.ui.autocomplete})) - } - "view.interfaceScale" => { - // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. - let percent = match p.get("percent") { - Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), - Some(v) => v.as_f64().and_then(clamp_interface_scale), - None => None, - }; - match percent { - Some(v) => { - self.ui.interface_scale = v; - Ok(json!({"percent": v})) - } - None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), - } - } - "app.language.set" => { - // `{"language": "ja"}`; `code` is accepted as an alias. - let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); - match i18n::Language::parse(code) { - Some(l) => { - self.ui.language = l; - Ok(json!({"language": l})) - } - None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), - } - } - "app.language.english" => { - self.ui.language = i18n::Language::En; - Ok(json!({"language": i18n::Language::En})) - } - "app.language.japanese" => { - self.ui.language = i18n::Language::Ja; - Ok(json!({"language": i18n::Language::Ja})) - } - "ui.backstage" => { - // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. - match p.get("page").and_then(Json::as_str).map(str::trim) { - Some("close" | "none" | "") => self.backstage = None, - Some(id) => match backstage::Page::parse(id) { - Some(page) => self.open_backstage(page), - None => { - return Some(Err(format!( - "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" - ))); - } - }, - None => self.open_backstage(backstage::Page::Home), - } - Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) - } - "ui.dialog" => { - let name = p.get("name").and_then(Json::as_str).unwrap_or(""); - self.open_dialog(name, p.clone()); - Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) - } - "ui.message.dismiss" => { - self.message = None; - Ok(Json::Null) - } - "ui.notice" => Ok(match &self.notice { - Some(n) => json!({ - "text": n.text, - "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, - "action": n.action, - }), - None => Json::Null, - }), - "ui.notice.dismiss" => { - self.notice = None; - Ok(Json::Null) - } - _ => return None, - }; - Some(r) - } - - /// Picks up requests commands made (dialogs, edit mode…). - pub fn after_engine(&mut self) { - // The requests belong to the window the command left active. - self.follow_active_window(); - for req in self.session.take_ui_requests() { - match req { - UiRequest::Dialog(name, params) => self.open_dialog(&name, params), - UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), - UiRequest::EditCell(text) => { - let text = text.map(|text| { - self.session - .active() - .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) - .unwrap_or(text) - }); - self.begin_edit(text, false); - } - UiRequest::InsertName(name) => dialogs::insert_name(self, &name), - UiRequest::OpenUrl(u) => { - if let Some(f) = &self.services.open_url { - f(&u); - } - } - } - } - } - - /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect - /// Sheet labels and actions). - pub fn active_sheet_protected(&self) -> bool { - self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) - } - - pub fn open_dialog(&mut self, name: &str, params: Json) { - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet - // on a protected sheet. A sheet without a password is unprotected straight away. - let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; - if name == "unprotectSheet" - && self - .session - .active() - .and_then(|d| d.wb.active()) - .and_then(|s| s.protection.as_ref()) - .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) - { - self.run_or_alert("review.unprotectSheet", json!({})); - return; - } - match name { - "open" => { - if let Some(start) = &self.services.open_async { - start(); - } else if let Some(pick) = &self.services.pick_open - && let Some(path) = pick() - { - self.open_path(&path); - } - } - "saveAs" => { - let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - if let Some(pick) = &self.services.pick_save { - if let Some(path) = pick(&suggested) { - let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; - self.run_or_alert("file.save", json!({"path": path})); - } - } else if self.services.download.is_some() - && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) - && let Some(dl) = &self.services.download - && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); - dl(&name, &b); - } - } - // File › Options (`file.options`) and the File tab itself open the Backstage view. - "options" => self.open_backstage(backstage::Page::Options), - "backstage" => { - let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); - self.open_backstage(page); - } - other => self.dialog = dialogs::Dialog::open(self, other, params), - } - } - - /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first - /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. - pub fn open_backstage(&mut self, page: backstage::Page) { - if !self.chrome.file_tab { - return; - } - if !self.commit_edit(0, 0, false, false) { - return; - } - match &mut self.backstage { - Some(b) => b.page = page, - None => self.backstage = Some(backstage::Backstage::new(page)), - } - } - - pub fn open_path(&mut self, path: &str) { - match self.run("file.open", json!({"path": path})) { - Ok(r) => self.after_open(Some(path), &r), - // A password-protected workbook: ask for the password; the dialog opens it. - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - - /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent - /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the - /// password dialog and the control channel. - pub fn after_open(&mut self, path: Option<&str>, r: &Json) { - self.backstage = None; - if let Some(path) = path { - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(20); - } - self.show_open_warnings(r); - } - - /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the - /// most important one (a preserved VBA project outranks, say, a skipped optional part). - /// Kept deliberately generic: any open-time warning can reach the user this way. - pub fn show_open_warnings(&mut self, r: &Json) { - // The opened workbook's window shows them, however it was opened (a script may have run - // `file.open` on the session directly). - self.follow_active_window(); - let Some(list) = r.get("warnings").and_then(Json::as_array) else { - self.notice = None; - return; - }; - let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); - if msgs.is_empty() { - // A clean open clears any notice left over from the previous document. - self.notice = None; - return; - } - let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); - if !vba.is_empty() { - // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. - let dropped = vba.iter().any(|m| m.contains("dropped")); - let text = if dropped { - "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." - } else { - "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." - }; - self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); - } else { - let text = if msgs.len() == 1 { - msgs[0].to_string() - } else { - format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) - }; - self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); - } - } - - /// The open windows in order, for the Switch Windows lists. - pub fn open_windows(&self) -> Vec { - let active = self.session.active_window(); - self.session - .windows() - .iter() - .filter_map(|w| { - let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; - let title = self.session.window_title(w.id)?; - Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) - }) - .collect() - } - - /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. - pub fn view_key(&self) -> Option<(u64, usize)> { - self.session.active().map(|d| (d.uid, d.wb.active_sheet())) - } - - /// The active window and sheet: each window scrolls on its own. - fn scroll_key(&self) -> Option<(u64, usize)> { - Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) - } - - pub fn view(&self) -> SheetView { - self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) - } - - pub fn view_mut(&mut self) -> Option<&mut SheetView> { - let k = self.scroll_key()?; - let saved = self.saved_view(); - Some(self.views.entry(k).or_insert(saved)) - } - - /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). - fn saved_view(&self) -> SheetView { - let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; - if sh.view != gridcraft_engine::model::SheetViewMode::Normal { - return SheetView::default(); - } - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - let top = sh.view_top_left; - let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); - let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); - SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } - } - - /// Hands the active document's scroll positions (first visible cell per sheet) and active - /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: - /// Page Layout view scrolls over pages, not cells. - pub fn store_view_state(&mut self) { - let window = self.session.active_window(); - let Some(d) = self.session.active_mut() else { return }; - let mut tops = Vec::new(); - for (&(w, si), v) in &self.views { - let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { - continue; - }; - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - // A hair past the edge so a row that starts exactly at the scroll offset is the one found. - let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); - let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); - tops.push((si, CellRef::new(row, col))); - } - d.store_view(&tops); - } - - /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. - pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { - // The source formula is display-only on spill children. Grid replacement keeps its - // existing behavior, but no formula-bar entry path may edit that displayed formula. - if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { - return; - } - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - 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 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. - if text.is_none() && sh.cell_pictures.contains_key(&at) { - return; - } - let locale = self.ui.language.formula_locale(); - let current = if sh.cell_pictures.contains_key(&at) { - String::new() - } else { - match sh.cell(at) { - Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), - _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), - } - }; - let (text, replace) = match text { - Some(t) => (t, true), - None => (current.clone(), false), - }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); - ed.code_edit = !replace && sh.cell(at).is_some_and(|c| c.py.is_some()); - ed.window = self.session.active_window(); - ed.locale = locale; - if replace && self.ui.autocomplete && !ed.is_formula() { - ed.completion = editor::column_completion(sh, at, &ed.text); - } - self.editor = Some(ed); - self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; - } - - /// Commits the edit; `then` moves the selection (dr, dc) afterwards. - /// - /// The draft goes into the window and workbook it was typed in. If another window is - /// showing, that window stays active afterwards; a draft its own workbook refuses - /// (validation, a bad formula) brings its window back to the front with the draft still open. - pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; - if !self.draft_elsewhere() { - return self.commit_edit_here(dr, dc, array, fill_selection); - } - // Its window, or (when that window closed) its workbook's most recent one. - let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { - self.cancel_edit(); - return true; - }; - let back = self.session.active_window(); - self.session.activate_window(owner); - if !self.commit_edit_here(dr, dc, array, fill_selection) { - // Refused: its window stays in front with the draft open and the reason shown. - return false; - } - if let Some(back) = back { - self.session.activate_window(back); - } - true - } - - /// The cell draft belongs to a window or workbook other than the one showing. - fn draft_elsewhere(&self) -> bool { - self.editor.as_ref().is_some_and(|ed| { - self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) - }) - } - - /// Commits the cell and text-box drafts, as Enter would, before a command that can show - /// another workbook or write this one to a file, so a draft never lands in another workbook. - /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. - pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { - if !self.commit_edit(0, 0, false, false) { - return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); - } - self.commit_text_box_edit() - } - - fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(ed) = self.editor.take() else { return true }; - self.session.mode = gridcraft_engine::Mode::Ready; - let text = match &ed.completion { - Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), - _ => ed.text.clone(), - }; - // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if (ed.code_edit || (!fill_selection && !array)) && ed.original.as_ref() == Some(&text) { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - return true; - } - // All the code deleted: the cell is cleared through cell.set, as an empty entry clears any - // other cell. Code of spaces is still code. - if ed.code_edit && !text.is_empty() { - return self.commit_code_edit(ed, text, dr, dc); - } - let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { - Some(Ok(text)) => text, - Some(Err(error)) => { - self.message = Some(("GridCraft".into(), error.to_string())); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - None => text, - }; - // Data validation. - if let Some(d) = self.session.active() - && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) - { - if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { - let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; - self.message = Some((title, msg)); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - self.toast = Some((msg, now_ms())); - } - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) - && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has - // already cleared the cell as its own step). - let unchanged = !fill_selection - && !array - && text.is_empty() - && self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| { - sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.py.is_none() && c.value == gridcraft_engine::core::Value::Empty) - }); - let r = if unchanged { - Ok(json!({})) - } else if fill_selection { - self.session.run("range.fill", json!({"input": text})) - } else { - self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) - }; - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); - true - } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - false - } - } - } - - /// Commits a changed edit of a Python cell's code through `python.set`, which keeps the cell's - /// return type; `cell.set` would replace the Python cell with an ordinary one holding the code - /// as text. When the command refuses the code, its message shows and the edit stays open. - fn commit_code_edit(&mut self, ed: editor::EditState, code: String, dr: i64, dc: i64) -> bool { - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - let r = if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) { - self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - } else { - Ok(Json::Null) - }; - let r = r.and_then(|_| self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": code}))); - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); - true - } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - false - } - } - } - - /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs - /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. - pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { - let run = self.tab_run.take(); - let ok = self.commit_edit(dr, dc, false, false); - self.tab_run = if ok { None } else { run }; - ok - } - - pub fn cancel_edit(&mut self) { - self.editor = None; - self.session.mode = gridcraft_engine::Mode::Ready; - } - - /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. - pub fn move_after_enter(&mut self, dr: i64, dc: i64) { - let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); - // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the - // last Tab landed on, which ends the run. - let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); - let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); - if multi { - let fwd = dr > 0 || dc > 0; - let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); - } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { - let back = i64::from(col) - i64::from(at.col); - let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); - } else { - let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); - if dr == 0 && dc != 0 { - let start = run.map(|r| r.2); - self.tab_run = self.session.active().and_then(|d| { - let (uid, sheet) = (d.uid, d.wb.active_sheet()); - let col = start.or(here.map(|h| h.2.col))?; - Some((uid, sheet, col, d.selection.active)) - }); - } - } - self.grid.ensure_visible = true; - } - - /// Terminal feedback also reaches the normal window when printing starts through control. - /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in - /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. - pub fn poll_printing(&mut self, ctx: &egui::Context) { - self.printing.poll(ctx); - if self.printing.message == self.print_feedback_seen { - return; - } - self.print_feedback_seen = self.printing.message.clone(); - let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = - self.printing.message.clone() - else { - return; - }; - let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); - if matches!(message, printing::Feedback::Submitted(_)) { - self.toast = Some((text, now_ms())); - } else { - self.alert(text); - } - } - - /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a - /// workbook has unsaved changes, and asks about them first. - fn guard_window_close(&mut self, ctx: &egui::Context) { - if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { - return; - } - // A cell still being edited is part of the workbook, as for File › Close. - if !self.commit_edit(0, 0, false, false) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - return; - } - if !self.session.documents().iter().any(|d| d.is_dirty()) { - return; - } - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - match self.dialog.as_mut() { - // Already asking about a workbook (File › Close): answering it now goes on to quit. - Some(d) if d.name() == "saveChanges" => { - d.values.insert("quit".into(), json!(true)); - } - _ => self.continue_quit(ctx), - } - } - - /// Asks about the next unsaved workbook before quitting, in its window, or closes the app - /// once none is left. - pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { - let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); - match dirty.and_then(|uid| self.session.doc_window(uid)) { - Some(window) => { - // Its window comes forward (the native window too: see `doc_windows`). - 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})); - } - None => { - self.allow_close = true; - ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); - } - } - } - - /// Answers the Save Changes question about the active workbook (its buttons). While quitting, - /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. - /// Returns whether the question is settled: Save keeps it open when the cell being edited - /// can't be entered. - pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { - match answer { - SaveAnswer::Cancel => {} - SaveAnswer::DontSave => { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - SaveAnswer::Save => { - // File operations include the cell still being edited, as File › Save does. - if !self.commit_edit(0, 0, false, false) { - return false; - } - self.run_or_alert("file.save", json!({})); - if self.session.active().is_some_and(|x| !x.is_dirty()) { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - } - } - true - } - - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its - /// workbook refuses stops the quit too, with its window in front and the reason shown. - pub fn request_quit(&mut self, ctx: &egui::Context) { - while let Some(window) = self.parked_window_with_draft() { - self.enter_window(window); - if self.settle_drafts().is_err() { - return; - } - } - if self.settle_drafts().is_err() { - return; - } - if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { - // Already asking about a workbook (File › Close): answering it goes on to quit. - d.values.insert("quit".into(), json!(true)); - return; - } - self.continue_quit(ctx); - } - - /// Per-frame logic (control channel, screenshots). Call before `ui`. - pub fn logic(&mut self, ctx: &egui::Context) { - self.ensure_text_width(ctx); - // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; - // Floating pictures' textures go with their workbook, even when no sheet is painted. - let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); - floating_pictures::forget_closed(ctx, &open); - self.apply_font_state(ctx); - if let Some(answer) = self.desktop_theme.take_change() { - self.desktop_dark = Some(answer); - } - self.sync_theme_preference(ctx); - self.sync_input_options(ctx); - // The geometry captures are bound to. Native frames recorded it from their fresh - // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is - // monotonic, so a resize back to the original values is still a new epoch and old pixels - // are never saved with new geometry. - let capture_geometry = control::current_capture_geometry(self, ctx); - control::note_capture_geometry(self, capture_geometry); - // Drain before admission: completed or timed-out captures release their slots and - // reservation first, so a new request is never refused for budget that is about to free. - // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only - // issues the capture command (after every widget, so a command cannot be missed). - control::drain_screenshots(self, ctx); - control::poll(self, ctx); - // Printing feedback is polled once per frame, here, so hidden windows and logic-only - // frames still see worker completion. `ui` and the desktop app no longer poll it. - self.poll_printing(ctx); - self.guard_window_close(ctx); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::tick(self, ctx); - // Files read asynchronously (web file picker, dropped files). - self.poll_async_opens(); - self.autosave(ctx); - self.update_title(ctx); - self.request_hidden_paint(ctx); - // One window always shows a workbook, unless each workbook has a native window of its own - // (`doc_windows` decides what having none left means). - if self.session.active().is_none() && !self.native_windows { - self.session.new_workbook(); - } - self.follow_active_window(); - } - - /// Installs the text-width closure once, on the first frame. - fn ensure_text_width(&mut self, ctx: &egui::Context) { - if self.session.text_width.is_some() { - return; - } - let ctx = ctx.clone(); - self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); - } - - /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook - /// font binding. Every branch keeps its one-frame paint gate. - fn apply_font_state(&mut self, ctx: &egui::Context) { - // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate - // before it handles input, so a font change requested now would consume the step without - // applying it. Delay the change one frame; the hidden-paint request keeps it coming. - let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); - if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { - self.fonts_ready = false; - self.fonts_set = false; - } - if !self.fonts_ready { - self.start_font_setup(ctx); - } else if self.fonts_rebind { - // A font-set change made last frame is active now. - self.fonts_rebind = false; - } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { - self.rebind_workbook_fonts(ctx); - } - } - - /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until - /// then. Startup always proceeds: without it a queued step would be re-offered forever. - fn start_font_setup(&mut self, ctx: &egui::Context) { - if self.fonts_set { - self.fonts_ready = true; - return; - } - if self.prepared_fonts { - ctx.set_fonts(theme::pinned_font_definitions()); - theme::apply(ctx, self.ui.dark); - } else { - SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); - } - self.fonts_set = true; - // The rebuilt set has no workbook fonts yet: bind them again once it is active. - self.fonts_bound.clear(); - self.fonts_seen = (0, 0); - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - ctx.request_repaint(); - } - - /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts - /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint - /// (same one-frame cost as startup). Only when an open workbook changed. - fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { - let seen = self.fonts_key(); - if seen == self.fonts_seen { - return; - } - let used = self.used_fonts(); - self.fonts_seen = seen; - if used == self.fonts_bound { - return; - } - ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); - self.fonts_bound = used; - self.fonts_rebind = true; - ctx.request_repaint(); - } - - /// Follows the desktop/system theme preference and records the effective theme. - fn sync_theme_preference(&mut self, ctx: &egui::Context) { - let preference = match ( - self.ui.system_theme, - ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), - ) { - (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, - (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, - _ => self.ui.theme_preference(), - }; - if ctx.options(|o| o.theme_preference) != preference { - ctx.set_theme(preference); - ctx.request_repaint(); - } - self.effective_dark = ctx.theme() == egui::Theme::Dark; - } - - /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. - fn sync_input_options(&mut self, ctx: &egui::Context) { - // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom - // keys stay off (they would also swallow GridCraft's shortcuts). - if ctx.options(|o| o.zoom_with_keyboard) { - ctx.options_mut(|o| o.zoom_with_keyboard = false); - } - // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native - // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu - // bar's Quit item has the key instead (it asks about unsaved workbooks first). - if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { - ctx.options_mut(|o| o.quit_shortcuts.clear()); - } - let zoom = self.ui.zoom_factor(); - if (ctx.zoom_factor() - zoom).abs() > 0.001 { - ctx.set_zoom_factor(zoom); - ctx.request_repaint(); - } - } - - /// Opens files that arrived asynchronously (web file picker, dropped files). - fn poll_async_opens(&mut self) { - let arrived: Vec<(String, Vec)> = self - .services - .inbox - .as_ref() - .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) - .unwrap_or_default(); - for (name, bytes) in arrived { - let b64 = gridcraft_engine::io::base64_encode(&bytes); - match self.run("file.open", json!({"name": &name, "base64": &b64})) { - // A file picked from File › Open (web) arrives here: show it, not the Backstage. - Ok(r) => { - self.backstage = None; - self.show_open_warnings(&r); - } - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { - self.backstage = None; - self.open_dialog("openPassword", json!({"name": name, "base64": b64})); - } - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - } - - /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in - /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a - /// forced hidden UI pass from saving the same change twice. - fn autosave(&mut self, ctx: &egui::Context) { - let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); - let now = crate::monotonic_ms(); - let autosave_due = - self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; - if autosave_due { - self.grid.last_autosave = now; - self.store_view_state(); - let _ = self.session.run("file.save", json!({})); - } else if self.ui.autosave && dirty_xlsx { - // Keep checking even when no input arrives while the window is hidden. - ctx.request_repaint_after(std::time::Duration::from_secs(2)); - } - } - - /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the - /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. - pub fn start_update_check(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, false); - } - - /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. - pub fn check_for_updates(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, true); - #[cfg(target_arch = "wasm32")] - { - self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); - } - } - - /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a - /// pass and a control-channel rename while hidden updates it on return. - fn update_title(&mut self, ctx: &egui::Context) { - // Native windows get theirs from `doc_windows`. - if self.native_windows { - return; - } - let Some(d) = self.session.active() else { return }; - let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); - if self.grid.last_title.as_deref() != Some(title.as_str()) { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.grid.last_title = Some(title); - } - } - - /// A queued/settling capture with a capturable size and an unapplied synthetic step both need - /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` - /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A - /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues - /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden - /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. - /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control - /// protocol documents the limitation. - fn request_hidden_paint(&self, ctx: &egui::Context) { - if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { - return; - } - // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an - // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the - // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). - #[cfg(not(target_arch = "wasm32"))] - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - ctx.request_repaint(); - } - - /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - // Capture geometry from this frame's real input before any synthetic event is added. This - // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, - // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded - // even though egui's last UI pass still describes the old layout. - let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); - control::note_capture_geometry(self, geometry); - self.capture_geometry_from_raw = true; - // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the - // next pass and for a hidden logic-only tick, so the batch is offered once and survives - // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. - // - // A completed egui pass may have consumed the injected events without applying them: the - // font gate returns before input handling, but `begin_pass` has already taken the events. - // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch - // still pending means exactly that, and the step is offered again for the next real pass. - if self.synthetic_pending { - let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); - if !consumed_by_an_unapplied_pass { - return; - } - } - if let Some(batch) = self.synthetic.front() { - raw.events.extend(batch.iter().cloned()); - self.synthetic_pending = true; - self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); - } - } - - /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI - /// preferences, so it stays as the user left it across restarts (#1901). - pub fn toggle_autosave(&mut self) { - self.ui.autosave = !self.ui.autosave; - } - - /// Lays out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - if !self.fonts_ready || self.fonts_rebind { - ctx.request_repaint(); - return; - } - let t0 = now_ms(); - // A script may have switched windows since the last frame. - self.follow_active_window(); - text_box::before_ui(self, &ctx); - // Another window came forward without going through a command (which settles drafts - // first): the draft goes into its own window instead of floating over this one. If its - // workbook refuses it, its window comes back to the front with the draft open. - if self.draft_elsewhere() { - self.commit_edit(0, 0, false, false); - } - // The language the widgets translate with this frame (see `i18n::current`). - i18n::set_current(&ctx, self.ui.language); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::show(self, &ctx); - let t = theme::Tokens::get(&ctx); - // A host can turn the File tab off while Backstage is already open: close it. - if !self.chrome.file_tab { - self.backstage = None; - } - // Keytips opened the File view (Alt, F): its letters keep working there. - if self.backstage.is_none() || self.keytips.prefix().is_some() { - self.ribbon_keys(&ctx); - } - if self.chrome.title_bar { - ribbon::title_bar(self, ui); - } - if self.backstage.is_some() { - // File tab: the Backstage view replaces everything below the title bar. - backstage::show(self, ui); - } else { - ribbon::show(self, ui); - widgets::notice_bar(self, ui); - if self.session.prefs.formula_bar { - formula_bar::show(self, ui); - } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); - } - dialogs::show(self, &ctx); - widgets::message_box(self, &ctx); - widgets::toast(self, &ctx); - // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. - if ctx.viewport_id() == egui::ViewportId::ROOT { - control::issue_screenshots(self, &ctx); - } - if !ctx.input(|i| i.focused) { - // Nothing else to do. - } - let now = now_ms(); - self.perf.frame_ms = now - t0; - if self.perf.last > 0.0 { - let dt = now - self.perf.last; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; - } - } - self.perf.last = now; - // The step is applied only after a completed UI pass. A discarded or repeated pass of the - // same frame must not offer it again (grid sizing calls `ui` more than once per frame); - // provenance is the injected pass number, so only a pass that missed input handling is - // re-offered by `raw_input_hook`. - // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. - if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { - self.synthetic.pop_front(); - self.synthetic_pending = false; - self.synthetic_injected_at_pass = None; - } - } - - fn ribbon_keys(&mut self, ctx: &egui::Context) { - let before = self.keytips.prefix().map(str::to_string); - let enabled = self.editor.is_none() - && self.dialog.is_none() - && self.message.is_none() - && self.name_box.is_none() - && !ctx.text_edit_focused() - && self.grid.context_menu.is_none() - && self.grid.object_menu.is_none() - && self.grid.header_menu.is_none() - && self.grid.fill_menu.is_none() - && self.grid.filter_menu.is_none() - && self.grid.list_picker.is_none() - && self.grid.renaming_tab.is_none() - && (before.is_some() || !egui::Popup::is_any_open(ctx)); - let actions = self.keytips.process(ctx, enabled); - // Backing out of F to the tab letters, or finishing an F sequence, closes the File view - // that Alt, F opened: the command runs on the sheet, as it did without the view. - if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { - self.backstage = None; - } - if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { - // Leave pointer cancellation to the popup so clicking a menu item still works. - egui::Popup::close_all(ctx); - } - for action in actions { - match action { - keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), - keytips::Action::Tab(tab) => { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); - self.ui.ribbon_collapsed = false; - } - keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), - keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), - keytips::Action::Command(id) => self.run_or_alert(id, json!({})), - keytips::Action::CommandWith(id, params) => { - let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); - // A Borders preset picked by key tip becomes the split button's face, as a - // click on the same menu item does. - if id == "home.borders" - && let Some(preset) = params.get("preset").and_then(Json::as_str) - { - self.grid.last_border = preset.to_string(); - } - self.run_or_alert(id, params); - } - keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), - keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), - // Reserved sequence: fires silently while its command is missing. When the - // command lands in the catalog it goes live with no table edit. - keytips::Action::Pending(id) => { - if gridcraft_engine::find_command(id).is_some() { - self.run_or_alert(id, json!({})); - } - } - // Route to the ribbon control that owns this keytip: it opens its dropdown or - // runs its click handler with the right params (the engine command alone would - // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), - } - } - } - - /// Current selection's top-left cell text as A1 (for the Name Box). - pub fn name_box_text(&self) -> String { - let Some(d) = self.session.active() else { return String::new() }; - let sel = &d.selection; - let cur = sel.current(); - // A named range that exactly matches shows its name. - let sh = d.wb.active(); - if let Some(sh) = sh { - for n in &d.wb.names { - let target = n.formula.replace('$', ""); - let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), - None => (None, target.clone()), - }; - if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { - return n.name.clone(); - } - } - } - if self.grid.drag_select && !cur.is_single() { - return format!("{}R x {}C", cur.height(), cur.width()); - } - sel.active.a1() - } -} - /// Strips the engine's error prefixes for message boxes. pub fn clean_error(e: &str) -> String { if let Some(rest) = e.split_once("`: ").map(|(_, r)| r) { @@ -1935,268 +311,6 @@ pub fn clean_error(e: &str) -> String { e.to_string() } -/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last -/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive -/// deadlines or pacing; use [`monotonic_ms`] for those. -pub fn now_ms() -> f64 { - gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) -} - -/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. -/// -/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a -/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the -/// browser's `performance.now()`, which is monotonic too. -#[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm -pub fn monotonic_ms() -> f64 { - use std::sync::OnceLock; - static START: OnceLock = OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 -} - -#[cfg(target_arch = "wasm32")] -/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. -pub fn monotonic_ms() -> f64 { - web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) -} - pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } - -#[cfg(test)] -mod lifecycle_tests { - use super::*; - - /// Builds a key-A press or release event without modifiers. - fn key_event(pressed: bool) -> egui::Event { - egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } - } - - /// Returns a test app with a new workbook and default services. - fn app() -> SheetApp { - let mut session = Session::new(); - session.new_workbook(); - SheetApp::new(session, Services::default()) - } - - /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it - /// is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - #[test] - /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. - fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic.push_back(vec![key_event(false)]); - - let mut first = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut first); - assert_eq!(first.events.len(), 1, "only one step is offered"); - assert!(app.synthetic_pending); - assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); - - // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step - // only once; a hidden logic-only tick does not duplicate it or offer the release. - let mut second = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut second); - assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); - assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); - } - - /// Returns raw input with a screen rect at the given size. - fn raw_with_size(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } - } - - #[test] - /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. - fn a_font_gated_pass_offers_the_synthetic_step_again() { - let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); - let mut app = app(); - app.fonts_ready = true; - app.fonts_set = true; - // Binds `session.text_width`, as eframe's first frame does. - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - app.synthetic.push_back(vec![key_event(true)]); - - // The font gate returns before input handling; `begin_pass` has still consumed the events. - app.fonts_rebind = true; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); - assert_eq!(app.synthetic.len(), 1, "the step is still queued"); - - // A completed pass that did not apply it made the hook offer it again for a real pass. - app.fonts_rebind = false; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(!app.synthetic_pending); - assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); - } - - #[test] - /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. - fn a_pending_step_delays_a_font_change() { - let ctx = egui::Context::default(); - let mut app = app(); - app.fonts_ready = true; - app.fonts_han = Some(theme::HanOrder::Chinese); - app.ui.language = i18n::Language::Ja; - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); - app.synthetic.clear(); - app.synthetic_pending = false; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); - } - - #[test] - /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. - fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { - /// Runs one logic tick with the requested width and native scale. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { - let mut raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); - viewport.native_pixels_per_point = Some(native_ppp); - } - let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0, 2.0); - let first = app.capture_geometry.expect("the first logic tick records the geometry"); - assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); - assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); - tick(&ctx, &mut app, 900.0, 2.0); - let resized = app.capture_geometry.expect("geometry"); - assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); - let epoch = app.capture_epoch; - tick(&ctx, &mut app, 800.0, 2.0); - assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); - assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); - } - - #[test] - /// A resize and a resize back both advance the capture epoch. - fn the_capture_epoch_changes_even_when_the_geometry_returns() { - /// Runs one UI pass at the requested width. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0); - let base = app.capture_epoch; - tick(&ctx, &mut app, 900.0); - let changed = app.capture_epoch; - assert!(changed > base, "a resize increments the capture epoch"); - tick(&ctx, &mut app, 800.0); - assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); - } - - /// Extracts title viewport commands from a logic output. - fn titles(out: &egui::LogicOutput) -> Vec { - out.viewport_commands - .values() - .flatten() - .filter_map(|c| match c { - egui::ViewportCommand::Title(title) => Some(title.clone()), - _ => None, - }) - .collect() - } - - #[test] - /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. - fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { - let ctx = egui::Context::default(); - let mut app = app(); - let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("autosave.xlsx"); - app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); - app.ui.autosave = true; - app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); - app.grid.last_autosave = monotonic_ms() - 3000.0; - - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); - assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); - let saved_at = app.grid.last_autosave; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - /// The title is sent on the first tick and after a rename, but not on every unchanged frame. - fn a_logic_only_tick_updates_the_title_once_per_change() { - let ctx = egui::Context::default(); - let mut app = app(); - let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let title = app.session.active().unwrap().display_title().to_string(); - let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); - let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); - - app.session.active_mut().unwrap().title = "Renamed".into(); - let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); - } - - #[test] - /// A hidden tick surfaces a failed print job in the app message. - fn a_logic_only_tick_polls_printing_feedback() { - let ctx = egui::Context::default(); - let mut app = app(); - app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); - app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); - app.services.print_native = Some(Box::new(|_, _| { - let (sender, receiver) = std::sync::mpsc::channel(); - sender.send(Err("printer is offline".into())).unwrap(); - receiver - })); - app.run("ui.printSubmit", serde_json::json!({})).unwrap(); - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); - assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); - } - - #[test] - /// Pending synthetic input makes logic request a real hidden paint. - fn a_pending_synthetic_step_requests_a_hidden_paint() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); - let painted = out - .viewport_output - .get(&ctx.viewport_id()) - .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); - assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); - } -} diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs new file mode 100644 index 000000000..c74153c1b --- /dev/null +++ b/crates/ui-egui/src/macro_editor.rs @@ -0,0 +1,2471 @@ +//! The macro step editor (Automate › New Macro; Macros › Edit, Create, Step Into): a large, +//! resizable window. +//! +//! - **Left, the step list.** Each row has its number, a breakpoint dot in the gutter (click to +//! toggle), a category icon, a one-line summary, an error marker and an enabled checkbox. Rows +//! drag to reorder within their list (the shared [`crate::dnd`] helpers); Add Step opens a +//! Command Palette picker; Duplicate and Delete act on the selected step. +//! - **Right, the inspector.** Step: the selected step's fields, with RefEdit boxes that switch +//! between relative tokens (`Input.col(2)`) and absolute A1, and token suggestions. Inputs: the +//! shape, header and column types each input must have, from an example. Output: the anchor and +//! what to do when results would land on data. +//! - **The debug bar.** Check (a dry run that colours the grid), Run, Step, Continue, Run to Here +//! and Stop (keep the changes or undo them). While debugging, the current step is highlighted +//! and its parameters are shown as they will run. +//! +//! The editor works on a draft and saves it with `macro.save`; checking, running and debugging +//! save first, then go through `macro.check`, `macro.run` and `macro.debug`. + +use std::collections::BTreeMap; + +use egui::{Color32, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use gridcraft_engine::core::RangeRef; +use gridcraft_engine::macros::policy::{Policy, macro_policy}; +use gridcraft_engine::macros::resolve::{self, Env, Place}; +use gridcraft_engine::model::{ColType, CompareOp, InputKind, Macro, NamedInput, OnConflict, RepeatOf, Step, StepKind, Workbook}; +use serde_json::{Map, Value as Json, json}; + +use crate::dialogs::Dialog; +use crate::icons::{self, Icon}; +use crate::theme::{self, Cat, Tokens}; +use crate::widgets::small_button; +use crate::{SheetApp, dnd, macro_ui}; + +/// The inspector's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + Step, + Inputs, + Output, +} + +/// The Add Step picker (or Change, which replaces a step's command). +#[derive(Clone, Debug, Default)] +pub struct Picker { + pub search: String, + /// The step whose command is being changed; `None` adds a step. + pub replace: Option, + pub index: usize, +} + +/// The editor's state while it is open. +#[derive(Clone, Debug)] +pub struct Editor { + /// The workbook (`DocState::uid`) the macro is in: the editor saves, checks and runs there only. + pub doc: u64, + pub draft: Macro, + /// The name the macro is saved under (`None` until it is first saved). + pub saved_as: Option, + pub dirty: bool, + /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). + pub selected: Option, + pub tab: Tab, + /// The input selected on the Inputs tab. + pub input: usize, + pub picker: Option, + /// The last `macro.check` result (the grid overlays come from it). + pub check: Option, + /// The debugger's state while paused. + pub debug: Option, + /// What the last run, check or debug session ended with. + pub outcome: Option, + /// Errors from the last check or run, by step path. + pub errors: BTreeMap, + /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). + pub ask_stop: bool, + pub closing: bool, + /// Close as the title bar's close button does (asking first) on the next frame. + pub close_requested: bool, + /// The window is quitting: once the editor has closed, the window close goes on. + pub quit: bool, + /// Asking whether to save before closing. + pub ask_close: bool, + /// Start debugging on the first frame (Macros › Step Into). + pub start_debug: bool, + /// Check on the first frame (`{"check": true}`). + pub start_check: bool, + /// The example the first input stands for (the selection when the editor opened, or the + /// input's default range): Check, Run and Debug use it, and tokens are shown against it. + pub example: String, + /// The parameter being added to the selected step: (key, value). + pub new_param: (String, String), + /// The reference boxes' picking state. + pub refs: RefPick, +} + +/// Which reference box grid selections write into, and the selection to put back on closing. +#[derive(Clone, Debug, Default)] +pub struct RefPick { + /// The reference box that grid selections currently write into. + pub pick: Option, + /// The selection reference last written by (or seen while) picking. + pub seen: String, + /// Sheet, ranges and active cell from before picking, put back when the editor closes. + pub home: Option<(usize, String, String)>, + /// The reference box the window is collapsed to (only that box shows). + pub collapsed: Option, + /// Whether the picking box was drawn this frame: a pick ends with a box that left the screen + /// (another step or tab was chosen), or a later selection would land in it unseen. + pub drawn: bool, +} + +// ---------------------------------------------------------------- step paths + +fn child(parent: &str, i: usize) -> String { + if parent.is_empty() { (i + 1).to_string() } else { format!("{parent}.{}", i + 1) } +} + +/// `"4.then.2"` → (`"4.then"`, 1). `None` for a branch key or anything else that isn't a step. +pub fn split_path(path: &str) -> Option<(&str, usize)> { + let (parent, last) = path.rsplit_once('.').unwrap_or(("", path)); + Some((parent, last.parse::().ok()?.checked_sub(1)?)) +} + +/// The step list a parent key names: `""` (the top), `"2"` (a Repeat's body), `"4.then"`. +pub fn list_mut<'a>(mut cur: &'a mut Vec, parent: &str) -> Option<&'a mut Vec> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get_mut(idx)?; + cur = match (&mut st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step> { + let (parent, i) = split_path(path)?; + list_mut(steps, parent)?.get_mut(i) +} + +/// The step list a parent key names, read-only (see [`list_mut`]). +pub fn list_at<'a>(mut cur: &'a [Step], parent: &str) -> Option<&'a [Step]> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get(idx)?; + cur = match (&st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +/// A copy of the step at `path`. Reads the path in place: copying the whole list for every row +/// made a large macro's step list quadratic. +pub fn step_at(steps: &[Step], path: &str) -> Option { + let (parent, i) = split_path(path)?; + list_at(steps, parent)?.get(i).cloned() +} + +/// The paths of the steps with a breakpoint. +pub fn breakpoints(steps: &[Step], parent: &str, out: &mut Vec) { + for (i, st) in steps.iter().enumerate() { + let path = child(parent, i); + if st.breakpoint { + out.push(path.clone()); + } + match &st.kind { + StepKind::Repeat { body, .. } => breakpoints(body, &path, out), + StepKind::If { then, otherwise, .. } => { + breakpoints(then, &format!("{path}.then"), out); + breakpoints(otherwise, &format!("{path}.else"), out); + } + _ => {} + } + } +} + +/// One row of the step list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + /// The step path, or the branch key for an If's Then/Else row. + pub key: String, + /// The list it is in, and its place there. + pub parent: String, + pub index: usize, + pub depth: usize, + /// `Some("Then")` / `Some("Else")` for a branch row. + pub branch: Option<&'static str>, +} + +/// The step list flattened into rows, nested steps indented under their Repeat or If. +pub fn rows(steps: &[Step], parent: &str, depth: usize, out: &mut Vec) { + if depth > 16 { + return; + } + for (i, st) in steps.iter().enumerate() { + let key = child(parent, i); + out.push(Row { key: key.clone(), parent: parent.to_string(), index: i, depth, branch: None }); + match &st.kind { + StepKind::Repeat { body, .. } => rows(body, &key, depth + 1, out), + StepKind::If { then, otherwise, .. } => { + for (label, list, b) in [("Then", then, "then"), ("Else", otherwise, "else")] { + let bk = format!("{key}.{b}"); + out.push(Row { key: bk.clone(), parent: key.clone(), index: 0, depth: depth + 1, branch: Some(label) }); + rows(list, &bk, depth + 2, out); + } + } + _ => {} + } + } +} + +/// Moves the step at `from` in list `parent` so it lands before row `to` (an index among the +/// list's steps as they are now). Returns the step's new path. +pub fn move_step(steps: &mut Vec, parent: &str, from: usize, to: usize) -> Option { + let list = list_mut(steps, parent)?; + if from >= list.len() { + return None; + } + let st = list.remove(from); + let to = if to > from { to - 1 } else { to }.min(list.len()); + list.insert(to, st); + Some(child(parent, to)) +} + +// ---------------------------------------------------------------- summaries + +fn short(v: &Json) -> String { + let text = match v { + Json::String(s) => s.clone(), + other => other.to_string(), + }; + if text.chars().count() > 40 { format!("{}…", text.chars().take(39).collect::()) } else { text } +} + +/// The label of command `id` (`Set Cell`), or the id when it isn't a command. +pub fn command_label(id: &str) -> String { + gridcraft_engine::find_command(id).map(|c| c.label.to_string()).unwrap_or_else(|| id.to_string()) +} + +/// A step in one line: "Set D2 to =B2*C2", "Repeat for each row of Input.data". +pub fn summary(st: &Step) -> String { + match &st.kind { + StepKind::Command { id, params } => { + let p = |k: &str| params.get(k).map(short); + match (id.as_str(), p("cell"), p("input"), p("range")) { + ("cell.set", Some(c), Some(i), _) => format!("Set {c} to {i}"), + ("selection.set", _, _, Some(r)) => format!("Select {r}"), + _ => { + let label = command_label(id); + let args: Vec = + params.as_object().map(|o| o.iter().take(3).map(|(k, v)| format!("{k}: {}", short(v))).collect()).unwrap_or_default(); + if args.is_empty() { label } else { format!("{label} · {}", args.join(", ")) } + } + } + } + StepKind::Repeat { over, of, .. } => { + format!("Repeat for each {} of {}", if *of == RepeatOf::Rows { "row" } else { "column" }, if over.is_empty() { "…" } else { over }) + } + StepKind::If { cell, op, value, .. } => { + let op = match op { + CompareOp::Eq => "=", + CompareOp::Ne => "≠", + CompareOp::Gt => ">", + CompareOp::Ge => "≥", + CompareOp::Lt => "<", + CompareOp::Le => "≤", + CompareOp::Empty => return format!("If {cell} is empty"), + CompareOp::NotEmpty => return format!("If {cell} isn't empty"), + CompareOp::Contains => "contains", + }; + format!("If {cell} {op} {}", short(value)) + } + StepKind::Stop { message, .. } => { + if message.is_empty() { + "Stop".into() + } else { + format!("Stop: {message}") + } + } + } +} + +/// The icon and colour of a step's category. +pub fn category(st: &Step) -> (Icon, Cat) { + match &st.kind { + StepKind::Repeat { .. } => (Icon::Redo, Cat::Purple), + StepKind::If { .. } => (Icon::Validation, Cat::Amber), + StepKind::Stop { .. } => (Icon::StopSquare, Cat::Red), + StepKind::Command { id, .. } => { + let prefix = id.split('.').next().unwrap_or(""); + match prefix { + "cell" | "range" | "edit" => (Icon::Sheet, Cat::Blue), + "selection" => (Icon::Find, Cat::Slate), + "home" => (Icon::Format, Cat::Orange), + "data" | "table" | "pivot" => (Icon::SortFilter, Cat::Green), + "insert" | "chart" | "object" => (Icon::Insert, Cat::Purple), + "formulas" => (Icon::Function, Cat::Teal), + "sheet" | "view" | "pageLayout" => (Icon::Sheet, Cat::Amber), + _ => (Icon::Script, Cat::Slate), + } + } + } +} + +/// Problems the editor can see without running: an unknown command, one a macro may not run, +/// one that needs a permission the macro doesn't list, a Repeat or If with nothing to work on. +pub fn static_error(m: &Macro, st: &Step) -> Option { + match &st.kind { + StepKind::Command { id, params } => { + if gridcraft_engine::find_command(id).is_none() { + return Some(format!("There is no command “{id}”.")); + } + match macro_policy(id, params, gridcraft_engine::Trust::Local) { + Policy::Deny(why) => Some(format!("{id} can't run in a macro: {why}.")), + Policy::NeedsPermission(what) if !m.permissions.iter().any(|p| p == id) => { + Some(format!("{id} {what}: the macro must list it in its permissions.")) + } + _ => None, + } + } + StepKind::Repeat { over, .. } if over.trim().is_empty() => Some("Choose the range to repeat over.".into()), + StepKind::If { cell, .. } if cell.trim().is_empty() => Some("Choose the cell to test.".into()), + _ => None, + } +} + +/// The parameters a new step starts with, from the command's documentation: the required ones, +/// plus an optional `cell` or `range` (`{cell?, input, array?}` → cell, input). +pub fn required_keys(doc: &str) -> Vec { + let Some(start) = doc.find('{') else { return vec![] }; + let mut depth = 0i32; + let mut quoted = false; + let mut part = String::new(); + let mut parts = Vec::new(); + for c in doc.get(start + 1..).unwrap_or("").chars() { + if c == '"' { + quoted = !quoted; + } + if quoted { + continue; + } + match c { + '{' | '[' | '(' => depth += 1, + '}' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + break; + } + '}' | ']' | ')' => depth -= 1, + ',' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + continue; + } + _ => {} + } + if depth == 0 { + part.push(c); + } + } + parts + .iter() + .filter_map(|p| { + let p = p.trim(); + let key: String = p.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); + let rest = p.get(key.len()..).unwrap_or("").trim_start(); + let optional = rest.starts_with('?') || rest.starts_with('|'); + (!key.is_empty() && (!optional || key == "cell" || key == "range")).then_some(key) + }) + .take(8) + .collect() +} + +// ---------------------------------------------------------------- tokens + +/// The token for `target` relative to the input called `name` at `input`: `Input`, `Input.col(2)`, +/// `Input.rows(2..3)`, `Input.cell(1,4)`, `Input.offset(0,4)`, or a cell offset from the input's +/// first cell (`Input.cell(1,1).offset(-1,0)`) with a `resize` when it is larger than one cell. +pub fn token_for(name: &str, input: RangeRef, target: RangeRef) -> String { + let (i, r) = (input, target); + if r == i { + return name.to_string(); + } + let dr = i64::from(r.start.row) - i64::from(i.start.row); + let dc = i64::from(r.start.col) - i64::from(i.start.col); + let (h, w) = (r.height(), r.width()); + if h == i.height() && w == i.width() { + return format!("{name}.offset({dr},{dc})"); + } + let inside = i.contains(r.start) && i.contains(r.end); + if inside { + let (r0, r1) = (dr + 1, dr + i64::from(h)); + let (c0, c1) = (dc + 1, dc + i64::from(w)); + if r.start.row == i.start.row && r.end.row == i.end.row { + return if w == 1 { format!("{name}.col({c0})") } else { format!("{name}.cols({c0}..{c1})") }; + } + if r.start.col == i.start.col && r.end.col == i.end.col { + return if h == 1 { format!("{name}.row({r0})") } else { format!("{name}.rows({r0}..{r1})") }; + } + let cell = format!("{name}.cell({r0},{c0})"); + return if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") }; + } + let cell = format!("{name}.cell(1,1).offset({dr},{dc})"); + if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") } +} + +/// `Sheet1!$B$2:$D$9,C4` → [(Some("Sheet1"), B2:D9), (None, C4)]. +pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { + text.split(',') + .filter_map(|part| { + let part = part.trim(); + let (sheet, body) = match part.rsplit_once('!') { + Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), + None => (None, part), + }; + RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) + }) + .take(500) + .collect() +} + +/// The inputs bound to their examples, for showing tokens as A1 and A1 as tokens. +fn bound_inputs(wb: &Workbook, ed: &Editor) -> Vec<(String, Place, bool)> { + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &[], output: None, item: None }; + ed.draft + .inputs + .iter() + .enumerate() + .filter_map(|(k, i)| { + let example = if k == 0 { Some(ed.example.as_str()) } else { i.default_ref.as_deref() }; + let place = resolve::resolve_ref(example?, &env).ok()?; + Some((i.name.clone(), place, i.header)) + }) + .collect() +} + +/// The suggestions offered beside a reference box. +fn token_suggestions(wb: &Workbook, ed: &Editor, in_repeat: bool) -> Vec { + let mut out = Vec::new(); + for (k, i) in ed.draft.inputs.iter().enumerate() { + let n = &i.name; + out.push(n.clone()); + if i.header { + out.push(format!("{n}.data")); + } + let width = if k == 0 { parse_refs(&ed.example).first().map_or(3, |(_, r)| r.width()) } else { 3 }.clamp(1, 6); + out.extend((1..=width).map(|c| format!("{n}.col({c})"))); + out.push(format!("{n}.rows(2..)")); + out.push(format!("{n}.cell(1,1)")); + out.push(format!("{n}.right")); + out.push(format!("{n}.below")); + } + if ed.draft.output.anchor.is_some() { + out.push("Output".into()); + out.push("Output.offset(1,0)".into()); + } + if in_repeat { + out.push("Row".into()); + out.push("Row.right".into()); + } + out.extend(wb.names.iter().take(20).map(|n| format!("Name:{}", n.name))); + out +} + +// ---------------------------------------------------------------- opening + +/// Opens the editor: `{macro}` edits a saved macro (`debug: true` also starts debugging it, +/// `check: true` checks it), `{new}` starts one with that name, `{}` one with the next free name. +pub fn open(app: &mut SheetApp, params: &Json) -> Option { + let doc = app.session.active()?; + let wb = &doc.wb; + let selection = doc.selection.current().a1(); + let found = params["macro"].as_str().and_then(|n| gridcraft_engine::macros::find(wb, n)).and_then(|i| wb.macros.get(i)).cloned(); + let (draft, saved_as) = match found { + Some(m) => { + let name = m.name.clone(); + (m, Some(name)) + } + None => { + let name = params["new"] + .as_str() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(|n| gridcraft_engine::macros::unique_name(wb, n)) + .unwrap_or_else(|| gridcraft_engine::macros::next_name(wb)); + (Macro { name, inputs: vec![NamedInput::default()], ..Default::default() }, None) + } + }; + let example = draft.inputs.first().and_then(|i| i.default_ref.clone()).unwrap_or(selection); + let start_debug = params["debug"].as_bool() == Some(true) && saved_as.is_some(); + let title = format!("Macro: {}", draft.name); + let selected = (!draft.steps.is_empty()).then(|| "1".to_string()); + let doc = doc.uid; + app.macros.editor = Some(Editor { + doc, + draft, + dirty: saved_as.is_none(), + saved_as, + selected, + tab: Tab::Step, + input: 0, + picker: None, + check: None, + debug: None, + outcome: None, + errors: BTreeMap::new(), + ask_stop: false, + closing: false, + close_requested: false, + quit: false, + ask_close: false, + start_debug, + start_check: params["check"].as_bool() == Some(true), + example, + new_param: Default::default(), + refs: RefPick::default(), + }); + Some(Dialog::custom("macroEditor", &title, json!({}))) +} + +// ---------------------------------------------------------------- actions + +/// Whether the editor's workbook is the active one, which the `macro.*` commands act on. When +/// another workbook is active (File › New, Switch Window, an agent), says so and returns false. +fn at_home(app: &mut SheetApp, ed: &Editor, title: &str) -> bool { + if app.session.active().is_some_and(|d| d.uid == ed.doc) { + return true; + } + let book = home_title(app, ed); + app.message = Some((title.into(), format!("The macro “{}” is in “{book}”. Switch back to it to save, check or run the macro.", ed.draft.name))); + false +} + +/// The title of the editor's workbook. +fn home_title(app: &SheetApp, ed: &Editor) -> String { + app.session.documents().iter().find(|d| d.uid == ed.doc).map(|d| d.display_title()).unwrap_or_default() +} + +/// Saves the draft (renaming the saved macro if its name changed). False when it couldn't. +pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { + if !at_home(app, ed, "Save Macro") { + return false; + } + ed.draft.name = ed.draft.name.trim().to_string(); + if let Err(e) = ed.draft.check_limits() { + app.message = Some(("Save Macro".into(), e)); + return false; + } + if let Some(old) = ed.saved_as.clone() + && !old.eq_ignore_ascii_case(&ed.draft.name) + { + if let Err(e) = app.run("macro.rename", json!({"name": old, "to": ed.draft.name})) { + app.message = Some(("Save Macro".into(), crate::clean_error(&e))); + return false; + } + // The workbook has it under the new name now, even if the save below fails. + ed.saved_as = Some(ed.draft.name.clone()); + } + // A new draft never replaces a macro that has its name; the person chooses another. + let replace = ed.saved_as.is_some(); + if !replace + && let Some(doc) = app.session.active() + && let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, &ed.draft.name) + { + app.message = Some(("Save Macro".into(), e)); + return false; + } + match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft), "replace": replace})) { + Ok(_) => { + ed.saved_as = Some(ed.draft.name.clone()); + ed.dirty = false; + true + } + Err(e) => { + app.message = Some(("Save Macro".into(), crate::clean_error(&e))); + false + } + } +} + +fn ensure_saved(app: &mut SheetApp, ed: &mut Editor) -> bool { + if ed.dirty || ed.saved_as.is_none() { save(app, ed) } else { true } +} + +/// The inputs a run from the editor uses: the first input's example. +fn run_inputs(ed: &Editor) -> Json { + match ed.draft.inputs.first() { + Some(i) if !ed.example.trim().is_empty() => json!({ i.name.clone(): ed.example }), + _ => json!({}), + } +} + +/// The step path an error message names (`Step 3 (cell.set): …` → `3`). +fn error_step(msg: &str) -> Option<(String, String)> { + let rest = msg.get(msg.find("Step ")? + 5..)?; + let end = rest.find([' ', ':']).unwrap_or(rest.len()); + let path = rest.get(..end)?.to_string(); + split_path(&path)?; + Some((path, msg.to_string())) +} + +/// Check: validates the inputs and dry-runs the macro; the grid shows what it would touch. +pub fn check(app: &mut SheetApp, ed: &mut Editor) { + // A reference box picking from the grid would take the selections the action makes. + ed.refs.pick = None; + if !at_home(app, ed, "Check Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match app.run("macro.check", json!({"name": name, "inputs": run_inputs(ed)})) { + Ok(v) => { + for e in v["errors"].as_array().into_iter().flatten().filter_map(Json::as_str) { + if let Some((path, msg)) = error_step(e) { + ed.errors.insert(path, msg); + } + } + if let Some(e) = v["footprint"]["error"].as_object() + && let Some(step) = e.get("step").and_then(Json::as_str) + { + ed.errors.insert(step.to_string(), e.get("message").and_then(Json::as_str).unwrap_or("").to_string()); + } + ed.outcome = Some(check_text(&v)); + ed.check = Some(v); + } + Err(e) => { + ed.check = None; + ed.outcome = Some(crate::clean_error(&e)); + } + } +} + +fn check_text(v: &Json) -> String { + if let Some(errors) = v["errors"].as_array().filter(|e| !e.is_empty()) { + return errors.iter().filter_map(Json::as_str).collect::>().join(" "); + } + if let Some(asks) = v["needsApproval"].as_array() { + return format!("Needs approval before it can run: it {}.", asks.iter().filter_map(Json::as_str).collect::>().join(", ")); + } + let cells: u64 = v["footprint"]["sheets"].as_array().into_iter().flatten().filter_map(|s| s["changed"].as_u64()).sum(); + let conflicts = v["conflicts"].as_u64().unwrap_or(0); + let mut text = format!("Check passed: writes {cells} cell{}", if cells == 1 { "" } else { "s" }); + if conflicts > 0 { + text.push_str(&format!(", {conflicts} of them hold data (red)")); + } else { + text.push_str(", over no data"); + } + if v["footprint"]["partial"].is_object() { + text.push_str(". Later steps can't be previewed."); + } + text +} + +/// Run: runs the saved macro as one undo step. +pub fn run(app: &mut SheetApp, ed: &mut Editor) { + ed.refs.pick = None; + if !at_home(app, ed, "Run Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match macro_ui::call(app, "Run Macro", "macro.run", json!({"name": name, "inputs": run_inputs(ed)})) { + Some(v) => ed.outcome = Some(format!("Ran {} step{}.", v["ran"].as_u64().unwrap_or(0), if v["ran"] == 1 { "" } else { "s" })), + None => { + if let Some((_, text)) = app.message.as_ref() + && let Some((path, msg)) = error_step(text) + { + ed.errors.insert(path, msg); + } + } + } +} + +/// The breakpoints sent with each debugger action: the draft's (they replace the saved ones, so +/// a dot cleared while paused takes effect at once). +fn bp_params(ed: &Editor) -> Json { + let mut list = Vec::new(); + breakpoints(&ed.draft.steps, "", &mut list); + json!({"breakpoints": list, "breakpointsOnly": true}) +} + +fn with(mut base: Json, extra: Json) -> Json { + if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) { + for (k, v) in e { + b.insert(k.clone(), v.clone()); + } + } + base +} + +/// Step, Continue or Run to Here: starts debugging if needed (paused before step 1), then acts. +pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { + ed.refs.pick = None; + let debugging = app.session.macro_debug.is_some(); + // Starting selects the first step: take Run to Here's target before that. + let target = ed.selected.clone().filter(|s| split_path(s).is_some()); + if !debugging { + if !at_home(app, ed, "Debug Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + // A dry run first, so the grid shows the inputs and what the run will write. + check(app, ed); + let name = ed.draft.name.clone(); + let p = with(json!({"name": name, "inputs": run_inputs(ed), "action": "start"}), bp_params(ed)); + let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", p) else { return }; + apply_debug(ed, &v); + if action == "step" || v["state"] != "paused" { + return; + } + } + let p = match action { + "runTo" => match target { + Some(step) => json!({"action": "runTo", "step": step}), + None => return, + }, + other => json!({"action": other}), + }; + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", with(p, bp_params(ed))) { + apply_debug(ed, &v); + } +} + +/// Stop: ends the debugged run, keeping its changes as one undo step or undoing them. +pub fn stop(app: &mut SheetApp, ed: &mut Editor, keep: bool) { + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", json!({"action": "stop", "keep": keep})) { + apply_debug(ed, &v); + } +} + +/// Takes in a `macro.debug` result: paused (select the current step), or ended (say how). +pub fn apply_debug(ed: &mut Editor, v: &Json) { + match v["state"].as_str() { + Some("paused") => { + if let Some(step) = v["current"]["step"].as_str() { + ed.selected = Some(step.to_string()); + ed.tab = Tab::Step; + } + ed.outcome = None; + ed.debug = Some(v.clone()); + } + Some(state) => { + ed.debug = None; + let executed = v["executed"].as_u64().unwrap_or(0); + ed.outcome = Some(match state { + "finished" => format!("Finished: {executed} step{} ran, one undo step.", if executed == 1 { "" } else { "s" }), + "failed" => { + if let Some(step) = v["error"]["step"].as_str() { + let msg = v["error"]["message"].as_str().unwrap_or("").to_string(); + ed.errors.insert(step.to_string(), msg); + ed.selected = Some(step.to_string()); + } + v["message"].as_str().unwrap_or("The macro failed; nothing was changed.").to_string() + } + _ if v["kept"] == true => format!("Stopped after {executed} step{}; the changes were kept.", if executed == 1 { "" } else { "s" }), + _ => match v["stopped"].as_str() { + Some(msg) => format!("Stopped: {msg}"), + None => "Stopped; the changes were undone.".into(), + }, + }); + } + None => {} + } +} + +// ---------------------------------------------------------------- the window + +/// Draws the editor window. `open` turns false when it closes. +pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mut bool) { + let Some(mut ed) = app.macros.editor.take() else { + *open = false; + return; + }; + // A prompt's button re-ran a debugger action (making room for the output, approving). + if let Some(v) = app.macros.debug_result.take() { + apply_debug(&mut ed, &v); + } + if ed.start_check { + ed.start_check = false; + check(app, &mut ed); + } + if ed.start_debug { + ed.start_debug = false; + debug_action(app, &mut ed, "step"); + } + // The run ended elsewhere (the status bar, an agent). + if ed.debug.is_some() && app.session.macro_debug.is_none() { + ed.debug = None; + } + // Its workbook was closed: the macro went with it. + if !app.session.documents().iter().any(|d| d.uid == ed.doc) { + *open = false; + return; + } + let home = app.session.active().is_some_and(|d| d.uid == ed.doc); + let home_title = home_title(app, &ed); + let dirty_mark = if ed.dirty { " •" } else { "" }; + let screen = ctx.content_rect(); + let fit = (screen.size() - vec2(16.0, 16.0)).max(vec2(240.0, 200.0)); + let mut win_open = true; + ed.refs.drawn = false; + egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) + .id(Id::unique("gridcraft.macro_editor")) + .collapsible(false) + .resizable(true) + // Never larger than the app's window, so a narrow one keeps every control on screen. + .default_size(vec2(840.0, 560.0).min(fit)) + .min_size(vec2(660.0, 380.0).min(fit)) + // To the right, so the data the overlays mark (usually top left) stays in view. + .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) + .open(&mut win_open) + .show(ctx, |ui| { + if !home { + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(format!("This macro is in “{home_title}”. Switch back to it to edit, check or run the macro.")) + .color(t.danger), + ); + } + ui.add_enabled_ui(home, |ui| { + if let Some(key) = ed.refs.collapsed.clone() { + ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); + apply_ref_value(&mut ed, d, &key); + return; + } + body(app, ui, d, &mut ed); + }); + }); + if ed.refs.pick.is_some() && !ed.refs.drawn { + ed.refs.pick = None; + } + if !win_open || std::mem::take(&mut ed.close_requested) { + if app.session.macro_debug.is_some() { + ed.ask_stop = true; + ed.closing = true; + } else if ed.dirty { + ed.ask_close = true; + } else { + *open = false; + } + } + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && ed.picker.is_some() { + ed.picker = None; + } + prompts(app, ctx, &mut ed, open); + // The overlays mark the editor's workbook's cells, so they show only while it is active. + app.grid.macro_overlay = if *open && home { overlay(app, &ed) } else { None }; + if *open { + app.macros.editor = Some(ed); + } else { + if home { + restore_home(app, &ed.refs); + } + if ed.quit { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// Asks the open step editor to close, as its close button does: it asks about a debug run or +/// unsaved steps first. With `quit` (the window is closing), the window close goes on once the +/// editor has closed; a Cancel in its questions keeps both open. +pub fn request_close(app: &mut SheetApp, quit: bool) { + if let Some(ed) = app.macros.editor.as_mut() { + ed.close_requested = true; + ed.quit = quit; + } +} + +/// Closes the step editor at once for a caller outside its window (the control channel's +/// `ui.dialog.cancel`), with the cleanup its own close does. Refused while it holds a debug run +/// or unsaved steps, which only its window asks about. +pub fn close_now(app: &mut SheetApp) -> Result<(), String> { + if app.session.macro_debug.is_some() { + return Err("A macro is being debugged: stop it first (macro.debug {\"action\": \"stop\"}).".into()); + } + if app.macros.editor.as_ref().is_some_and(|ed| ed.dirty) { + return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); + } + if let Some(ed) = app.macros.editor.take() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + restore_home(app, &ed.refs); + } + app.grid.macro_overlay = None; + app.dialog = None; + Ok(()) +} + +// ---------------------------------------------------------------- reference boxes + +/// A range as an absolute reference: `$B$2:$D$9`. +fn abs(r: &RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The grid selection as a reference: absolute, comma-joined areas, with the sheet name when it +/// isn't the sheet picking started on. +fn selection_ref(app: &SheetApp, home_sheet: Option) -> String { + let Some(doc) = app.session.active() else { return String::new() }; + let areas: Vec = doc.selection.ranges.iter().map(abs).collect(); + match (home_sheet, doc.wb.active()) { + (Some(h), Some(sh)) if h != doc.wb.active_sheet() => { + let q = gridcraft_engine::formula::quote_sheet(&sh.name); + areas.iter().map(|a| format!("{q}!{a}")).collect::>().join(",") + } + _ => areas.join(","), + } +} + +/// Starts picking into `key`: later grid selections are written into the box. The first pick +/// remembers the selection, so closing the editor can put it back. +fn begin_pick(app: &SheetApp, refs: &mut RefPick, key: &str) { + if refs.home.is_none() + && let Some(doc) = app.session.active() + { + let ranges: Vec = doc.selection.ranges.iter().map(|r| r.a1()).collect(); + refs.home = Some((doc.wb.active_sheet(), ranges.join(","), doc.selection.active.a1())); + } + refs.pick = Some(key.to_string()); + refs.seen = selection_ref(app, refs.home.as_ref().map(|h| h.0)); +} + +/// Puts back the sheet and selection from before picking started. +fn restore_home(app: &mut SheetApp, refs: &RefPick) { + if let Some((sheet, ranges, active)) = &refs.home { + let _ = app.session.run("selection.set", json!({"sheet": sheet, "range": ranges, "active": active})); + } +} + +/// A reference box (Excel's RefEdit): a text field that grid selections fill while it is the +/// pick target, with a button that collapses the window to just this box and expands it again. +/// The text is kept in `d.values[key]`. +fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key: &str, width: f32) { + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + let sel = selection_ref(app, refs.home.as_ref().map(|h| h.0)); + let picking = refs.pick.as_deref() == Some(key); + if picking { + refs.drawn = true; + } + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); + if r.gained_focus() || (r.clicked() && !picking) { + begin_pick(app, refs, key); + } else if picking && !r.has_focus() && sel != refs.seen && app.session.macro_debug.is_none() { + // A selection made on the grid becomes the reference; while debugging, the selection + // follows the steps and is not a pick. + s = sel.clone(); + refs.seen = sel; + } + let collapsed = refs.collapsed.as_deref() == Some(key); + let t = Tokens::get(ui.ctx()); + let (icon, tip) = if collapsed { (Icon::Expand, "Expand the editor") } else { (Icon::Collapse, "Collapse the editor to this box") }; + if crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0)).clicked() { + refs.collapsed = if collapsed { None } else { Some(key.to_string()) }; + if !collapsed { + begin_pick(app, refs, key); + } + } + }); + d.values.insert(key.into(), json!(s)); +} + +/// Keep changes? (stopping a debugged run) and Save changes? (closing with unsaved edits). +fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut bool) { + if ed.ask_stop { + let mut answer: Option> = None; + egui::Modal::new(Id::unique("gridcraft.macro_editor.stop")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new("Stop debugging").strong()); + ui.label("Keep the changes the macro has made so far? They are one undo step either way."); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Undo Changes").clicked() { + answer = Some(Some(false)); + } + if ui.button("Keep Changes").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(keep)) => { + ed.ask_stop = false; + stop(app, ed, keep); + if ed.closing { + if ed.dirty { ed.ask_close = true } else { *open = false } + } + ed.closing = false; + } + Some(None) => { + ed.ask_stop = false; + ed.closing = false; + ed.quit = false; + } + None => {} + } + return; + } + if ed.ask_close { + let mut answer: Option> = None; + egui::Modal::new(Id::unique("gridcraft.macro_editor.close")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new(format!("Save changes to macro “{}”?", ed.draft.name)).strong()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Don't Save").clicked() { + answer = Some(Some(false)); + } + if ui.button("Save").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(true)) => { + ed.ask_close = false; + if save(app, ed) { + *open = false; + } + } + Some(Some(false)) => { + ed.ask_close = false; + *open = false; + } + Some(None) => { + ed.ask_close = false; + ed.quit = false; + } + None => {} + } + } +} + +fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let debugging = app.session.macro_debug.is_some(); + // Name, description, Save; they wrap in a narrow window. + ui.horizontal_wrapped(|ui| { + let label = ui.label("Name:"); + let name = + ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")).labelled_by(label.id); + let label = ui.label("Description:"); + let desc = ui + .add_enabled( + !debugging, + egui::TextEdit::singleline(&mut ed.draft.description).desired_width((ui.available_width() - 90.0).max(80.0)).id_salt("me_desc"), + ) + .labelled_by(label.id); + if name.changed() || desc.changed() { + ed.dirty = true; + } + if ui.add_enabled(ed.dirty && !debugging, egui::Button::new("Save")).clicked() { + save(app, ed); + } + }); + ui.add_space(2.0); + toolbar(app, ui, ed, debugging); + status_line(app, ui, ed, &t); + ui.separator(); + let avail = ui.available_size(); + // The step list takes about half; in a narrow window the inspector keeps room for its tabs. + let left_w = (avail.x * 0.52).max(300.0).min((avail.x - 190.0).max(160.0)); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(left_w, avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(left_w, avail.y)); + if ed.picker.is_some() { + picker(ui, ed); + } else { + step_list(app, ui, ed, debugging); + } + }); + ui.separator(); + ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + egui::ScrollArea::both().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { + inspector(app, ui, d, ed, debugging); + }); + }); + }); +} + +fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + // The buttons wrap onto a second row in a narrow window. + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let editing = !debugging; + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).clicked() { + ed.picker = Some(Picker::default()); + } + let has_step = ed.selected.as_deref().and_then(split_path).is_some(); + if ui + .add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) + .clicked() + { + duplicate(ed); + } + if ui.add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).clicked() { + delete(ed); + } + sep(ui, &t); + if ui + .add_enabled(editing, |ui: &mut Ui| { + small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) + }) + .clicked() + { + check(app, ed); + } + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).clicked() { + run(app, ed); + } + sep(ui, &t); + if small_button(ui, Icon::Step, "Step", if debugging { "Run the current step" } else { "Start debugging: stop before the first step" }, false) + .clicked() + { + debug_action(app, ed, "step"); + } + if small_button(ui, Icon::Continue, "Continue", "Run to the next breakpoint or the end", false).clicked() { + debug_action(app, ed, "continue"); + } + let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); + if ui.add_enabled(can_run_to, |ui: &mut Ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).clicked() { + debug_action(app, ed, "runTo"); + } + if ui.add_enabled(debugging, |ui: &mut Ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).clicked() { + ed.ask_stop = true; + } + }); +} + +fn sep(ui: &mut Ui, t: &Tokens) { + let (r, _) = ui.allocate_exact_size(vec2(9.0, 20.0), Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 2.0), pos2(r.center().x, r.bottom() - 2.0)], Stroke::new(1.0, t.separator)); +} + +/// Where the run is (while debugging) or what the last check, run or debug session said, with +/// the overlay legend. +fn status_line(app: &SheetApp, ui: &mut Ui, ed: &Editor, t: &Tokens) { + if let Some(st) = &ed.debug { + let cur = &st["current"]; + let step = cur["step"].as_str().unwrap_or(""); + let what = match cur["kind"].as_str() { + Some("command") => command_label(cur["command"].as_str().unwrap_or("")), + Some(k) => k.to_string(), + None => String::new(), + }; + let mut head = format!("Paused before step {step} · {what}"); + if let (Some(at), Some(of)) = (cur["iteration"]["at"].as_u64(), cur["iteration"]["of"].as_u64()) { + head.push_str(&format!(" · pass {at} of {of}")); + } + if st["atBreakpoint"] == true { + head.push_str(" · at a breakpoint"); + } + ui.label(egui::RichText::new(head).strong().color(t.text)); + let resolved = cur.get("params").or(cur.get("over")).or(cur.get("cell")).map(|p| p.to_string()); + if let Some(p) = resolved { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Runs with:").color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(p).monospace().color(t.text)).truncate()); + }); + } else if let Some(e) = cur["paramsError"].as_str() { + ui.label(egui::RichText::new(e).color(t.danger)); + } + let changed: Vec<&str> = st["changed"].as_array().into_iter().flatten().filter_map(Json::as_str).take(6).collect(); + ui.label( + egui::RichText::new(format!("Changed so far: {}", if changed.is_empty() { "nothing".into() } else { changed.join(", ") })) + .color(t.text_dim), + ); + } else if let Some(text) = &ed.outcome { + let bad = !ed.errors.is_empty() || ed.check.as_ref().is_some_and(|c| c["ok"] == false); + ui.add(egui::Label::new(egui::RichText::new(text).color(if bad { t.danger } else { t.text })).wrap()); + } else { + ui.label( + egui::RichText::new("Check shows on the grid what the macro reads and writes. Click a step's gutter to set a breakpoint.") + .color(t.text_dim), + ); + } + if app.grid.macro_overlay.is_some() { + ui.horizontal(|ui| { + for (label, c) in [ + ("Inputs", t.sheet_macro_input), + ("Writes", t.sheet_macro_footprint), + ("Writes over data", t.sheet_macro_conflict), + ("Current step", t.sheet_macro_target), + ] { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, c); + ui.label(egui::RichText::new(label).small().color(t.text_dim)); + ui.add_space(6.0); + } + }); + } +} + +// ---------------------------------------------------------------- the step list + +/// A step being dragged: the list it is in and its place there. +#[derive(Clone, Debug)] +struct StepDrag { + parent: String, + index: usize, + label: String, +} + +fn step_list(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + let mut list = Vec::new(); + rows(&ed.draft.steps, "", 0, &mut list); + if list.is_empty() { + ui.add_space(8.0); + ui.label(egui::RichText::new("No steps yet. Add Step picks a command, or record one with Automate › Record Macro.").color(t.text_dim)); + return; + } + let current = ed.debug.as_ref().and_then(|s| s["current"]["step"].as_str().map(str::to_string)); + let dragging = egui::DragAndDrop::payload::(ui.ctx()); + let pointer = ui.ctx().pointer_latest_pos(); + let mut placed: Vec<(String, Rect)> = Vec::new(); + egui::ScrollArea::vertical().id_salt("me_steps").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for row in &list { + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag()); + placed.push((row.parent.clone(), rect)); + let selected = ed.selected.as_deref() == Some(row.key.as_str()); + let is_current = current.as_deref() == Some(row.key.as_str()); + let p = ui.painter(); + if is_current { + p.rect_filled(rect, 3.0, t.accent_soft); + } else if selected { + p.rect_filled(rect, 3.0, t.pressed); + } else if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + if resp.clicked() { + ed.selected = Some(row.key.clone()); + ed.tab = Tab::Step; + } + let indent = row.depth as f32 * 16.0; + if let Some(label) = row.branch { + p.text(pos2(rect.left() + 58.0 + indent, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::ui_bold(12.0), t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{label} branch {}", row.parent))); + continue; + } + let Some(st) = step_at(&ed.draft.steps, &row.key) else { continue }; + // Gutter: the breakpoint dot (click to toggle), the current-step arrow. + let gutter = Rect::from_min_size(rect.min, vec2(18.0, rect.height())); + let g = ui.interact(gutter, Id::unique(("me_bp", &row.key)), Sense::click()); + if st.breakpoint { + p.circle_filled(gutter.center(), 5.0, t.breakpoint); + } else if g.hovered() { + p.circle_stroke(gutter.center(), 4.5, Stroke::new(1.0, t.breakpoint)); + } + if is_current { + let c = pos2(gutter.right() + 1.0, gutter.center().y); + p.add(egui::Shape::convex_polygon(vec![c + vec2(-6.0, -5.0), c + vec2(1.0, 0.0), c + vec2(-6.0, 5.0)], t.accent, Stroke::NONE)); + } + g.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, st.breakpoint, format!("Breakpoint {}", row.key))); + if g.on_hover_text("Breakpoint: the debugger stops before this step").clicked() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.breakpoint = !s.breakpoint; + ed.dirty = true; + } + p.text(pos2(rect.left() + 48.0, rect.center().y), egui::Align2::RIGHT_CENTER, &row.key, theme::ui_font(11.5), t.text_dim); + let (icon, cat) = category(&st); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 62.0 + indent, rect.center().y), vec2(16.0, 16.0)), icon, t.cat(cat)); + let text = summary(&st); + let text_color = if st.enabled { t.text } else { t.text_disabled }; + let text_left = rect.left() + 76.0 + indent; + let text_clip = Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 46.0, rect.bottom())); + p.with_clip_rect(text_clip).text(pos2(text_left, rect.center().y), egui::Align2::LEFT_CENTER, &text, theme::ui_font(12.5), text_color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Step {}: {text}", row.key))); + // Error marker. + let error = ed.errors.get(&row.key).cloned().or_else(|| static_error(&ed.draft, &st)); + if let Some(err) = error { + let c = pos2(rect.right() - 36.0, rect.center().y); + p.circle_filled(c, 7.0, t.danger); + p.text(c, egui::Align2::CENTER_CENTER, "!", theme::ui_bold(11.0), t.window); + ui.interact(Rect::from_center_size(c, vec2(16.0, 16.0)), Id::unique(("me_err", &row.key)), Sense::hover()).on_hover_text(err); + } + // Enabled. + let mut on = st.enabled; + let cb = + ui.put(Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(18.0, 18.0)), egui::Checkbox::without_text(&mut on)); + if cb.on_hover_text("Run this step (a disabled step is skipped)").changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.enabled = on; + ed.dirty = true; + } + if !debugging { + dnd::drag_source(ui, &resp, StepDrag { parent: row.parent.clone(), index: row.index, label: text.clone() }); + } + } + }); + // Dropping a dragged step among its own list's rows. + if let (Some(drag), Some(p)) = (dragging, pointer) { + let group: Vec = + list.iter().zip(&placed).filter(|(r, _)| r.branch.is_none() && r.parent == drag.parent).map(|(_, (_, rect))| *rect).collect(); + let zone = group.iter().fold(Rect::NOTHING, |a, r| a.union(*r)); + let area = placed.iter().fold(Rect::NOTHING, |a, (_, r)| a.union(*r)).expand(12.0); + dnd::ghost(ui, &drag.label, p); + if area.contains(p) && zone.is_positive() { + dnd::drop_marker(ui, zone, &group, p.y); + } + if ui.input(|i| i.pointer.any_released()) { + let _ = egui::DragAndDrop::take_payload::(ui.ctx()); + if area.contains(p) { + let to = dnd::insert_index(&group, p.y); + if to != drag.index + && to != drag.index + 1 + && let Some(path) = move_step(&mut ed.draft.steps, &drag.parent, drag.index, to) + { + ed.selected = Some(path); + ed.errors.clear(); + ed.dirty = true; + } + } + } + } + let _ = app; +} + +/// Where a new step goes: after the selected step in its list, at the end of a selected branch +/// or Repeat, or at the end of the macro. +fn insertion_point(ed: &Editor) -> (String, usize) { + let Some(sel) = ed.selected.as_deref() else { return (String::new(), ed.draft.steps.len()) }; + let mut steps = ed.draft.steps.clone(); + if split_path(sel).is_none() { + // A branch row: the end of that branch. + let len = list_mut(&mut steps, sel).map_or(0, |l| l.len()); + return (sel.to_string(), len); + } + if let Some(st) = step_mut(&mut steps, sel) + && let StepKind::Repeat { body, .. } = &st.kind + { + return (sel.to_string(), body.len()); + } + match split_path(sel) { + Some((parent, i)) => (parent.to_string(), i + 1), + None => (String::new(), ed.draft.steps.len()), + } +} + +fn insert_step(ed: &mut Editor, step: Step) { + let (parent, at) = insertion_point(ed); + if let Some(list) = list_mut(&mut ed.draft.steps, &parent) { + let at = at.min(list.len()); + list.insert(at, step); + ed.selected = Some(child(&parent, at)); + ed.tab = Tab::Step; + ed.errors.clear(); + ed.dirty = true; + } +} + +fn duplicate(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && let Some(st) = list.get(i).cloned() + { + list.insert(i + 1, st); + ed.selected = Some(child(parent, i + 1)); + ed.errors.clear(); + ed.dirty = true; + } +} + +fn delete(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && i < list.len() + { + list.remove(i); + ed.selected = if list.is_empty() { (!parent.is_empty()).then(|| parent.to_string()) } else { Some(child(parent, i.min(list.len() - 1))) }; + ed.errors.clear(); + ed.dirty = true; + } +} + +// ---------------------------------------------------------------- Add Step + +/// The steps the picker offers for `query`: control steps first, then commands a macro may run. +pub fn picker_items(query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_ascii_lowercase(); + let mut out: Vec<(String, String, String)> = [ + ("@repeat", "Repeat for each row", "Control: run steps once per row of a range"), + ("@if", "If", "Control: run steps when a cell meets a condition"), + ("@stop", "Stop with message", "Control: end the run with a message"), + ] + .iter() + .filter(|(_, l, _)| q.is_empty() || l.to_ascii_lowercase().contains(&q)) + .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())) + .collect(); + for c in gridcraft_engine::command_specs() { + if matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)) || c.id.starts_with("ui.") { + continue; + } + if q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q) { + out.push((c.id.to_string(), c.label.to_string(), c.menu.join(" › "))); + } + if out.len() >= 60 { + break; + } + } + out +} + +/// A new step for a picker item, with the command's required parameters filled in as blanks. +pub fn new_step(item: &str) -> Step { + match item { + "@repeat" => Step::new(StepKind::Repeat { over: "Input".into(), of: RepeatOf::Rows, max: 10_000, body: vec![] }), + "@if" => { + Step::new(StepKind::If { cell: "Input.cell(1,1)".into(), op: CompareOp::NotEmpty, value: Json::Null, then: vec![], otherwise: vec![] }) + } + "@stop" => Step::new(StepKind::Stop { message: String::new(), rollback: false }), + id => { + let doc = gridcraft_engine::find_command(id).map(|c| c.params).unwrap_or(""); + let params: Map = required_keys(doc).into_iter().map(|k| (k, json!(""))).collect(); + Step::command(id, Json::Object(params)) + } + } +} + +fn picker(ui: &mut Ui, ed: &mut Editor) { + let Some(pk) = ed.picker.as_mut() else { return }; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(if pk.replace.is_some() { "Change the command" } else { "Add a step" }).strong()); + if ui.small_button("Cancel").clicked() { + pk.search = "\u{1}".into(); + } + }); + if pk.search == "\u{1}" { + ed.picker = None; + return; + } + let r = ui.add( + egui::TextEdit::singleline(&mut pk.search) + .hint_text("Search commands, e.g. bold, sort, set…") + .desired_width(f32::INFINITY) + .id_salt("me_pick"), + ); + r.request_focus(); + if r.changed() { + pk.index = 0; + } + let mut items = picker_items(&pk.search); + // Change swaps a command step's command; it can't turn the step into Repeat, If or Stop. + if pk.replace.is_some() { + items.retain(|(id, ..)| !id.starts_with('@')); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + pk.index = (pk.index + 1).min(items.len().saturating_sub(1)); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + pk.index = pk.index.saturating_sub(1); + } + let mut chosen = ui.input(|i| i.key_pressed(egui::Key::Enter)).then_some(pk.index); + egui::ScrollArea::vertical().id_salt("me_pick_list").auto_shrink([false, false]).show(ui, |ui| { + for (i, (id, label, menu)) in items.iter().enumerate() { + let r = ui.add(egui::Button::selectable(i == pk.index, label.as_str()).min_size(vec2(ui.available_width(), 20.0))); + if r.clicked() { + chosen = Some(i); + } + r.on_hover_text(format!("{id} {menu}")); + } + if items.is_empty() { + ui.label(egui::RichText::new("No command matches.").color(t.text_dim)); + } + }); + let Some(item) = chosen.and_then(|i| items.get(i)).map(|x| x.0.clone()) else { return }; + let replace = pk.replace.clone(); + ed.picker = None; + match replace { + Some(path) => { + let fresh = new_step(&item); + if let Some(st) = step_mut(&mut ed.draft.steps, &path) { + st.kind = fresh.kind; + ed.dirty = true; + } + } + None => insert_step(ed, new_step(&item)), + } +} + +// ---------------------------------------------------------------- the inspector + +fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { + ui.horizontal_wrapped(|ui| { + for (tab, label) in [(Tab::Step, "Details"), (Tab::Inputs, "Inputs"), (Tab::Output, "Output")] { + if ui.selectable_label(ed.tab == tab, label).clicked() { + ed.tab = tab; + } + } + }); + ui.separator(); + ui.add_enabled_ui(!debugging, |ui| match ed.tab { + Tab::Step => step_tab(app, ui, d, ed), + Tab::Inputs => inputs_tab(app, ui, d, ed), + Tab::Output => output_tab(app, ui, d, ed), + }); +} + +/// The dialog value key of a reference box. +fn ref_key(path: &str, field: &str) -> String { + format!("ref:{path}:{field}") +} + +/// After a RefEdit box changed (typed, or picked on the grid): writes it into the draft. +fn apply_ref_value(ed: &mut Editor, d: &Dialog, key: &str) { + let Some(v) = d.values.get(key).and_then(Json::as_str).map(str::to_string) else { return }; + let mut parts = key.splitn(3, ':'); + let (_, path, field) = (parts.next(), parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if set_ref(ed, path, field, &v) { + ed.dirty = true; + } +} + +/// Writes reference text into the field a ref box edits. True when it changed. +fn set_ref(ed: &mut Editor, path: &str, field: &str, v: &str) -> bool { + match path { + "@output" => { + let new = (!v.trim().is_empty()).then(|| v.trim().to_string()); + let changed = ed.draft.output.anchor != new; + ed.draft.output.anchor = new; + changed + } + "@example" => { + let changed = ed.example != v; + ed.example = v.to_string(); + if let Some(i) = ed.draft.inputs.first_mut() + && i.default_ref.is_some() + { + i.default_ref = Some(v.to_string()); + } + changed + } + _ => { + let Some(st) = step_mut(&mut ed.draft.steps, path) else { return false }; + let slot: Option<&mut String> = match (&mut st.kind, field) { + (StepKind::Repeat { over, .. }, "over") => Some(over), + (StepKind::If { cell, .. }, "cell") => Some(cell), + _ => None, + }; + if let Some(s) = slot { + let changed = s != v; + *s = v.to_string(); + return changed; + } + if let StepKind::Command { params, .. } = &mut st.kind + && let Some(o) = params.as_object_mut() + { + let changed = o.get(field).and_then(Json::as_str) != Some(v); + o.insert(field.to_string(), json!(v)); + return changed; + } + false + } + } +} + +/// A reference box: RefEdit (type, or select on the grid), Relative/Absolute, and suggestions. +fn ref_field(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, (path, field): (&str, &str), value: &str, in_repeat: bool) { + let key = ref_key(path, field); + if ed.refs.pick.as_deref() != Some(key.as_str()) || d.values.get(&key).is_none() { + d.values.insert(key.clone(), json!(value)); + } + let width = (ui.available_width() - 96.0).clamp(120.0, 320.0); + ui.horizontal(|ui| { + ref_edit(app, ui, d, &mut ed.refs, &key, width); + apply_ref_value(ed, d, &key); + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + let Some(doc) = app.session.active() else { return }; + let wb = doc.wb.clone(); + let inputs = bound_inputs(&wb, ed); + let env = Env { wb: &wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + let relative = !current.trim().is_empty() && resolve::is_token(¤t, &env); + if path != "@example" { + if ui.selectable_label(relative, "Rel").on_hover_text("Relative: a token that follows the input the macro runs on").clicked() && !relative + { + let place = resolve::resolve_ref(¤t, &env).ok(); + if let (Some(p), Some((name, input, _))) = (place, inputs.first()) + && p.sheet == input.sheet + { + let tok = token_for(name, input.range, p.range); + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + if ui.selectable_label(!relative && !current.trim().is_empty(), "Abs").on_hover_text("Absolute: always these cells").clicked() + && relative + && let Ok(p) = resolve::resolve_ref(¤t, &env) + { + let a = resolve::a1(p, &env); + set_ref(ed, path, field, &a); + d.values.insert(key.clone(), json!(a)); + ed.dirty = true; + } + let menu = ui.button("▾").on_hover_text("Tokens"); + egui::Popup::menu(&menu).show(|ui| { + ui.set_max_height(300.0); + egui::ScrollArea::vertical().show(ui, |ui| { + for tok in token_suggestions(&wb, ed, in_repeat) { + let shown = + resolve::resolve_ref(&tok, &env).map(|p| format!("{tok} {}", resolve::a1(p, &env))).unwrap_or_else(|_| tok.clone()); + if ui.button(shown).clicked() { + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + }); + }); + } + }); + // What a token stands for on the example. + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + if let Some(doc) = app.session.active() { + let wb = &doc.wb; + let inputs = bound_inputs(wb, ed); + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + if !current.trim().is_empty() && resolve::is_token(¤t, &env) && !current.starts_with("Row") && !current.starts_with("Output") { + let t = Tokens::get(ui.ctx()); + match resolve::resolve_ref(¤t, &env) { + Ok(p) => ui.label(egui::RichText::new(format!("= {} on the example", resolve::a1(p, &env))).small().color(t.text_dim)), + Err(e) => ui.label(egui::RichText::new(e.message()).small().color(t.danger)), + }; + } + } +} + +fn step_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let Some(path) = ed.selected.clone() else { + ui.label(egui::RichText::new("Select a step to see its settings.").color(t.text_dim)); + return; + }; + let Some(st) = step_at(&ed.draft.steps, &path) else { + ui.label( + egui::RichText::new(format!( + "Steps here run when the If is {}. Add Step puts a step in this branch.", + if path.ends_with("then") { "true" } else { "false" } + )) + .color(t.text_dim), + ); + return; + }; + let in_repeat = path.contains('.'); + let (icon, cat) = category(&st); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.cat(cat)); + let title = match &st.kind { + StepKind::Command { id, .. } => command_label(id), + StepKind::Repeat { .. } => "Repeat".into(), + StepKind::If { .. } => "If".into(), + StepKind::Stop { .. } => "Stop".into(), + }; + ui.label(egui::RichText::new(format!("Step {path} · {title}")).strong()); + if let StepKind::Command { id, .. } = &st.kind { + ui.label(egui::RichText::new(id).monospace().color(t.text_dim)); + if ui.small_button("Change…").clicked() { + ed.picker = Some(Picker { replace: Some(path.clone()), ..Default::default() }); + } + } + }); + let mut enabled = st.enabled; + let mut bp = st.breakpoint; + ui.horizontal(|ui| { + let a = ui.checkbox(&mut enabled, "Enabled").changed(); + let b = ui.checkbox(&mut bp, "Breakpoint").changed(); + if (a || b) + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.enabled = enabled; + s.breakpoint = bp; + ed.dirty = true; + } + }); + if let Some(e) = ed.errors.get(&path).cloned().or_else(|| static_error(&ed.draft, &st)) { + ui.label(egui::RichText::new(e).color(t.danger)); + } + ui.add_space(4.0); + match st.kind.clone() { + StepKind::Command { id, params } => command_fields(app, ui, d, ed, &path, &id, ¶ms, in_repeat), + StepKind::Repeat { over, of, max, .. } => { + ui.label("For each"); + let mut of2 = of; + ui.horizontal(|ui| { + ui.radio_value(&mut of2, RepeatOf::Rows, "row"); + ui.radio_value(&mut of2, RepeatOf::Columns, "column"); + ui.label("of:"); + }); + ref_field(app, ui, d, ed, (&path, "over"), &over, in_repeat); + let mut max2 = max; + ui.horizontal(|ui| { + ui.label("At most"); + ui.add(egui::DragValue::new(&mut max2).range(1..=100_000)); + ui.label("times"); + }); + if (of2 != of || max2 != max) + && let Some(StepKind::Repeat { of, max, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *of = of2; + *max = max2; + ed.dirty = true; + } + ui.label(egui::RichText::new("Inside, Row (or Column) stands for the current one, e.g. Row.right.").small().color(t.text_dim)); + } + StepKind::If { cell, op, value, .. } => { + ui.label("If cell"); + ref_field(app, ui, d, ed, (&path, "cell"), &cell, in_repeat); + let mut op2 = op; + let ops = [ + (CompareOp::Eq, "equals"), + (CompareOp::Ne, "doesn't equal"), + (CompareOp::Gt, "is greater than"), + (CompareOp::Ge, "is at least"), + (CompareOp::Lt, "is less than"), + (CompareOp::Le, "is at most"), + (CompareOp::Empty, "is empty"), + (CompareOp::NotEmpty, "isn't empty"), + (CompareOp::Contains, "contains"), + ]; + let label = ops.iter().find(|(o, _)| *o == op).map_or("", |x| x.1); + craft_ui::ComboBox::from_id_salt(("me_op", &path)).selected_text(label).show_ui(ui, |ui| { + for (o, l) in ops { + ui.selectable_value(&mut op2, o, l); + } + }); + let mut text = match &value { + Json::String(s) => s.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let changed_value = if matches!(op2, CompareOp::Empty | CompareOp::NotEmpty) { + false + } else { + ui.add(egui::TextEdit::singleline(&mut text).hint_text("value").id_salt(("me_ifv", &path))).changed() + }; + if (op2 != op || changed_value) + && let Some(StepKind::If { op, value, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *op = op2; + if changed_value { + *value = scalar(&text); + } + ed.dirty = true; + } + } + StepKind::Stop { message, rollback } => { + let (mut m, mut rb) = (message.clone(), rollback); + ui.label("Message:"); + let a = ui.add(egui::TextEdit::singleline(&mut m).desired_width(f32::INFINITY).id_salt(("me_stop", &path))).changed(); + let b = ui.checkbox(&mut rb, "Undo what the macro did before stopping").changed(); + if (a || b) + && let Some(StepKind::Stop { message, rollback }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *message = m; + *rollback = rb; + ed.dirty = true; + } + } + } + ui.add_space(6.0); + ui.label("Note:"); + let mut note = st.note.clone(); + if ui.add(egui::TextEdit::multiline(&mut note).desired_rows(2).desired_width(f32::INFINITY).id_salt(("me_note", &path))).changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.note = note; + ed.dirty = true; + } +} + +/// Typed text as a JSON value: a number, TRUE/FALSE, or text. +fn scalar(text: &str) -> Json { + let t = text.trim(); + if let Ok(n) = t.parse::() + && n.is_finite() + { + return json!(n); + } + match t.to_ascii_lowercase().as_str() { + "true" => json!(true), + "false" => json!(false), + _ => json!(text), + } +} + +#[allow(clippy::too_many_arguments)] +fn command_fields(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, path: &str, id: &str, params: &Json, in_repeat: bool) { + let t = Tokens::get(ui.ctx()); + let fields: Vec<(String, Json)> = params.as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); + let mut remove: Option = None; + let mut set: Option<(String, Json)> = None; + egui::Grid::new(("me_params", path)).num_columns(3).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + for (k, v) in &fields { + ui.label(k.as_str()); + match v { + Json::String(s) if resolve::is_ref_key(k) => { + ui.vertical(|ui| ref_field(app, ui, d, ed, (path, k), s, in_repeat)); + } + Json::String(s) => { + let mut text = s.clone(); + if ui.add(egui::TextEdit::singleline(&mut text).desired_width(220.0).id_salt(("me_p", path, k))).changed() { + set = Some((k.clone(), json!(text))); + } + } + Json::Bool(b) => { + let mut on = *b; + if ui.checkbox(&mut on, "").changed() { + set = Some((k.clone(), json!(on))); + } + } + Json::Number(n) => { + let mut x = n.as_f64().unwrap_or(0.0); + if ui.add(egui::DragValue::new(&mut x).speed(0.1)).changed() { + set = Some((k.clone(), json!(x))); + } + } + other => { + let jk = format!("json:{path}:{k}"); + let mut text = d.values.get(&jk).and_then(Json::as_str).map(str::to_string).unwrap_or_else(|| other.to_string()); + let ok = serde_json::from_str::(&text).is_ok(); + let r = ui.add( + egui::TextEdit::singleline(&mut text) + .desired_width(220.0) + .font(egui::TextStyle::Monospace) + .text_color(if ok { t.text } else { t.danger }) + .id_salt(("me_pj", path, k)), + ); + if r.changed() { + if let Ok(v) = serde_json::from_str::(&text) { + set = Some((k.clone(), v)); + } + d.values.insert(jk, json!(text)); + } + } + } + if crate::widgets::icon_button(ui, Icon::Close, t.text_dim, "Remove this parameter", vec2(18.0, 18.0)).clicked() { + remove = Some(k.clone()); + } + ui.end_row(); + } + }); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) { + if !params.is_object() { + *params = json!({}); + } + if let Some(o) = params.as_object_mut() { + if let Some((k, v)) = set { + o.insert(k, v); + ed.dirty = true; + } + if let Some(k) = remove { + o.remove(&k); + ed.dirty = true; + } + } + } + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut ed.new_param.0).hint_text("parameter").desired_width(90.0).id_salt("me_np_k")); + ui.add(egui::TextEdit::singleline(&mut ed.new_param.1).hint_text("value").desired_width(140.0).id_salt("me_np_v")); + let key = ed.new_param.0.trim().to_string(); + if ui.add_enabled(!key.is_empty(), egui::Button::new("Add")).clicked() { + let raw = ed.new_param.1.clone(); + let v = serde_json::from_str::(&raw).unwrap_or(Json::String(raw)); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) + && let Some(o) = params.as_object_mut() + { + o.insert(key, v); + ed.dirty = true; + } + ed.new_param = Default::default(); + } + }); + if let Some(spec) = gridcraft_engine::find_command(id) + && !spec.params.is_empty() + { + ui.add_space(4.0); + ui.add(egui::Label::new(egui::RichText::new(spec.params).small().color(t.text_dim)).wrap()); + } +} + +fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("The ranges the macro works on. Each is checked before the macro runs.").small().color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for (k, i) in ed.draft.inputs.iter().enumerate() { + if ui.selectable_label(ed.input == k, i.name.as_str()).clicked() { + ed.input = k; + } + } + if ui.small_button("Add").clicked() && ed.draft.inputs.len() < 64 { + let n = (2..).map(|k| format!("Input{k}")).find(|n| !ed.draft.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(n))).unwrap_or_default(); + ed.draft.inputs.push(NamedInput { name: n, ..Default::default() }); + ed.input = ed.draft.inputs.len() - 1; + ed.dirty = true; + } + if ui.add_enabled(!ed.draft.inputs.is_empty(), egui::Button::new("Remove").small()).clicked() && ed.input < ed.draft.inputs.len() { + ed.draft.inputs.remove(ed.input); + ed.input = ed.input.saturating_sub(1); + ed.dirty = true; + } + }); + let k = ed.input; + let Some(mut i) = ed.draft.inputs.get(k).cloned() else { + ui.label(egui::RichText::new("No inputs: the macro works on fixed cells only.").color(t.text_dim)); + return; + }; + let before = i.clone(); + ui.separator(); + egui::Grid::new("me_input").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut i.name).desired_width(140.0).id_salt("me_in_name")); + ui.end_row(); + ui.label("Shape:"); + ui.horizontal(|ui| { + ui.radio_value(&mut i.kind, InputKind::Range, "range"); + ui.radio_value(&mut i.kind, InputKind::Cell, "single cell"); + }); + ui.end_row(); + if i.kind == InputKind::Range { + ui.label("Data rows:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.rows[0]).range(0..=1_048_576).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.rows[1]) + .range(0..=1_048_576) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label("Columns:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.cols[0]).range(0..=16_384).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.cols[1]) + .range(0..=16_384) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut i.header, "First row holds column names"); + ui.end_row(); + } + ui.label(""); + ui.checkbox(&mut i.non_empty, "Every cell must hold something"); + ui.end_row(); + }); + // Column types, one per column of the example (or of the list so far). + let ncols = if k == 0 { parse_refs(&ed.example).first().map_or(0, |(_, r)| r.width() as usize) } else { 0 }.max(i.col_types.len()).min(16); + if i.kind == InputKind::Range && ncols > 0 { + ui.label("Each column must hold:"); + let headers = if k == 0 { example_headers(app, &ed.example, i.header) } else { vec![] }; + egui::Grid::new("me_types").num_columns(2).show(ui, |ui| { + for c in 0..ncols { + let label = headers.get(c).filter(|h| !h.is_empty()).cloned().unwrap_or_else(|| format!("Column {}", c + 1)); + ui.label(label); + let mut ty = i.col_types.get(c).copied().unwrap_or(ColType::Any); + let before_ty = ty; + craft_ui::ComboBox::from_id_salt(("me_ty", c)).selected_text(type_name(ty)).show_ui(ui, |ui| { + for x in [ColType::Any, ColType::Number, ColType::Text, ColType::Date, ColType::Bool] { + ui.selectable_value(&mut ty, x, type_name(x)); + } + }); + if ty != before_ty { + while i.col_types.len() <= c { + i.col_types.push(ColType::Any); + } + if let Some(slot) = i.col_types.get_mut(c) { + *slot = ty; + } + } + ui.end_row(); + } + }); + } + if i != before { + tidy_input(&mut i, &before); + if let Some(slot) = ed.draft.inputs.get_mut(k) { + *slot = i; + } + ed.dirty = true; + } + if k == 0 { + ui.separator(); + ui.label("Example (what Check, Run and Debug use here):"); + let example = ed.example.clone(); + ref_field(app, ui, d, ed, ("@example", "example"), &example, false); + ui.horizontal(|ui| { + if ui.button("Use current selection as example").clicked() { + use_selection_as_example(app, ed); + } + let mut fixed = ed.draft.inputs.first().is_some_and(|i| i.default_ref.is_some()); + if ui.checkbox(&mut fixed, "Always run on this range").on_hover_text("Otherwise the macro runs on the selection").changed() + && let Some(first) = ed.draft.inputs.first_mut() + { + first.default_ref = fixed.then(|| ed.example.clone()); + ed.dirty = true; + } + }); + } +} + +fn type_name(t: ColType) -> &'static str { + match t { + ColType::Any => "anything", + ColType::Number => "numbers", + ColType::Text => "text", + ColType::Date => "dates", + ColType::Bool => "TRUE or FALSE", + } +} + +/// The header texts of the example's first row (when it has a header). +fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { + let (Some(doc), true) = (app.session.active(), header) else { return vec![] }; + let wb = &doc.wb; + let Some((sheet, r)) = parse_refs(example).into_iter().next() else { return vec![] }; + let si = sheet.and_then(|s| wb.sheet_index(&s)).unwrap_or(wb.active_sheet()); + let Some(sh) = wb.sheet(si) else { return vec![] }; + (r.start.col..=r.end.col.min(r.start.col.saturating_add(15))) + .map(|c| gridcraft_engine::display::cell_text(wb, sh, gridcraft_engine::core::CellRef::new(r.start.row, c))) + .collect() +} + +/// Keeps an input the person is editing to the shapes a macro may declare, as the edit is made: +/// a single cell is one row and one column with no header and at most one column type; a least +/// above its most moves the other end along (the one just set wins); and a range lists no more +/// column types than it allows columns. +pub fn tidy_input(i: &mut NamedInput, before: &NamedInput) { + if i.kind == InputKind::Cell { + (i.rows, i.cols, i.header) = ([1, 1], [1, 1], false); + i.col_types.truncate(1); + return; + } + for (now, was) in [(&mut i.rows, before.rows), (&mut i.cols, before.cols)] { + if now[1] != 0 && now[0] > now[1] { + if now[0] != was[0] { + now[1] = now[0]; + } else { + now[0] = now[1]; + } + } + } + if i.cols[1] != 0 { + i.col_types.truncate(i.cols[1] as usize); + } +} + +/// "Use current selection as example": the selection becomes the first input's example, and its +/// shape, header and column types are read from it. +pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { + let Some(doc) = app.session.active() else { return }; + let r = doc.selection.current(); + let Some(sh) = doc.wb.active() else { return }; + ed.example = r.a1(); + let Some(first) = ed.draft.inputs.first_mut() else { return }; + let inferred = infer_input(sh, r); + let before = first.clone(); + first.kind = if r.is_single() { InputKind::Cell } else { InputKind::Range }; + first.header = inferred.0; + first.col_types = inferred.1; + first.cols = [r.width(), r.width()]; + first.rows = [1, 0]; + tidy_input(first, &before); + if first.default_ref.is_some() { + first.default_ref = Some(ed.example.clone()); + } + ed.dirty = true; +} + +/// Whether the first row of `r` looks like a header (all text over a row that isn't), and what +/// each column holds below it. +pub fn infer_input(sh: &gridcraft_engine::model::Sheet, r: RangeRef) -> (bool, Vec) { + use gridcraft_engine::core::{CellRef, Value}; + let kind = |v: &Value| match v { + Value::Number(_) => Some(ColType::Number), + Value::Text(_) => Some(ColType::Text), + Value::Bool(_) => Some(ColType::Bool), + _ => None, + }; + let rows = r.height().min(2_000); + let cols = r.width().min(16); + let first: Vec> = (0..cols).map(|c| kind(&sh.value(CellRef::new(r.start.row, r.start.col + c)))).collect(); + let header = rows > 1 + && first.iter().all(|k| *k == Some(ColType::Text)) + && (0..cols).any(|c| matches!(kind(&sh.value(CellRef::new(r.start.row + 1, r.start.col + c))), Some(ColType::Number | ColType::Bool))); + let start = r.start.row + u32::from(header); + let types = (0..cols) + .map(|c| { + let mut seen: Option = None; + for row in start..r.start.row + rows { + match (kind(&sh.value(CellRef::new(row, r.start.col + c))), seen) { + (None, _) => {} + (Some(k), None) => seen = Some(k), + (Some(k), Some(s)) if k == s => {} + _ => return ColType::Any, + } + } + seen.unwrap_or(ColType::Any) + }) + .collect(); + (header, types) +} + +fn output_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label("Output anchor (what the Output token stands for):"); + let anchor = ed.draft.output.anchor.clone().unwrap_or_default(); + ref_field(app, ui, d, ed, ("@output", "anchor"), &anchor, false); + ui.label( + egui::RichText::new("Leave it empty when no step uses Output. A token such as Input.right keeps it beside the data.") + .small() + .color(t.text_dim), + ); + ui.add_space(6.0); + ui.label("When results would land on cells that hold data:"); + let mut rule = ed.draft.output.on_conflict; + for (r, label) in [ + (OnConflict::Ask, "Ask (overwrite, shift down or shift right)"), + (OnConflict::Fail, "Don't run"), + (OnConflict::Overwrite, "Overwrite the data"), + (OnConflict::ShiftDown, "Shift the data down"), + (OnConflict::ShiftRight, "Shift the data right"), + ] { + ui.radio_value(&mut rule, r, label); + } + if rule != ed.draft.output.on_conflict { + ed.draft.output.on_conflict = rule; + ed.dirty = true; + } + ui.label(egui::RichText::new("Agents and other programs that run the macro get an error instead of the question.").small().color(t.text_dim)); + if !ed.draft.permissions.is_empty() { + ui.add_space(6.0); + ui.label(format!("May use files through: {}", ed.draft.permissions.join(", "))); + } +} + +// ---------------------------------------------------------------- grid overlays + +/// What the grid shows while the editor is open: the inputs (blue), the cells the macro writes +/// (green), the ones among them that hold data (red), and the current step's target (outlined). +/// Each range is on the named sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MacroOverlay { + pub inputs: Vec<(String, RangeRef)>, + pub footprint: Vec<(String, RangeRef)>, + pub conflicts: Vec<(String, RangeRef)>, + pub target: Vec<(String, RangeRef)>, +} + +impl MacroOverlay { + fn is_empty(&self) -> bool { + self.inputs.is_empty() && self.footprint.is_empty() && self.conflicts.is_empty() && self.target.is_empty() + } +} + +fn named_refs(text: &str, default_sheet: &str) -> Vec<(String, RangeRef)> { + parse_refs(text).into_iter().map(|(s, r)| (s.unwrap_or_else(|| default_sheet.to_string()), r)).collect() +} + +/// The overlay for the editor's last check and debugger state. +pub fn overlay(app: &SheetApp, ed: &Editor) -> Option { + let sheet = app.session.active()?.wb.active()?.name.clone(); + let mut o = MacroOverlay::default(); + if let Some(c) = &ed.check { + for v in c["inputs"].as_object().into_iter().flat_map(|m| m.values()).filter_map(Json::as_str) { + o.inputs.extend(named_refs(v, &sheet)); + } + for s in c["footprint"]["sheets"].as_array().into_iter().flatten() { + let name = s["sheet"].as_str().unwrap_or(&sheet).to_string(); + for v in s["cells"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.footprint.extend(named_refs(v, &name)); + } + for v in s["conflicts"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.conflicts.extend(named_refs(v, &name)); + } + } + } + if let Some(st) = &ed.debug { + let cur = &st["current"]; + if let Some(p) = cur["params"].as_object() { + for (k, v) in p { + if resolve::is_ref_key(k) + && let Some(s) = v.as_str() + { + o.target.extend(named_refs(s, &sheet)); + } + } + } + for k in ["over", "cell"] { + if let Some(s) = cur[k].as_str() { + o.target.extend(named_refs(s, &sheet)); + } + } + } + (!o.is_empty()).then_some(o) +} + +/// One overlay layer: its ranges, colour, wash alpha and stroke width. +type OverlayLayer<'a> = (&'a Vec<(String, RangeRef)>, Color32, u8, f32); + +/// Paints the overlay's ranges on `sh` (called from the grid's overlay pass). +pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_engine::model::Sheet, o: &MacroOverlay) { + let t = Tokens::get(p.ctx()); + let wash = |c: Color32, a: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); + let layers: [OverlayLayer; 3] = [ + (&o.footprint, t.sheet_macro_footprint, 30, 1.5), + (&o.inputs, t.sheet_macro_input, 26, 2.0), + (&o.conflicts, t.sheet_macro_conflict, 60, 2.0), + ]; + for (list, c, alpha, width) in layers { + for (name, r) in list.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r); + if !p.clip_rect().intersects(rect) { + continue; + } + p.rect_filled(rect, 0.0, wash(c, alpha)); + p.rect_stroke(rect, 0.0, Stroke::new(width, c), StrokeKind::Inside); + let _ = name; + } + } + for (_, r) in o.target.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r).expand(1.0); + p.rect_stroke(rect, 1.0, Stroke::new(2.5, t.sheet_macro_target), StrokeKind::Outside); + for corner in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + p.rect_filled(Rect::from_center_size(corner, vec2(5.0, 5.0)), 0.0, t.sheet_macro_target); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_edited_input_keeps_a_shape_a_macro_may_declare() { + let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; + // Switching a range to a single cell drops what a cell can't have. + let mut cell = NamedInput { kind: InputKind::Cell, ..range.clone() }; + tidy_input(&mut cell, &range); + assert_eq!((cell.rows, cell.cols, cell.header, cell.col_types.len()), ([1, 1], [1, 1], false, 1)); + // A least set above the most moves the most up; a most set below the least moves the least. + let mut i = NamedInput { rows: [12, 10], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [12, 12]); + let mut i = NamedInput { rows: [2, 1], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [1, 1]); + // At most 0 is no limit; fewer columns drop the column types past them. + let mut i = NamedInput { rows: [12, 0], cols: [1, 2], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!((i.rows, i.cols, i.col_types.len()), ([12, 0], [1, 2], 2)); + // Each passes the format's own check. + for input in [cell, i] { + let m = Macro { name: "M".into(), inputs: vec![input], ..Macro::default() }; + assert_eq!(m.check_limits(), Ok(()), "{m:?}"); + } + } + + fn steps() -> Vec { + vec![ + Step::command("cell.set", json!({"cell": "A1", "input": "x"})), + Step::new(StepKind::Repeat { + over: "Input".into(), + of: RepeatOf::Rows, + max: 10, + body: vec![Step::command("cell.set", json!({"cell": "Row.right", "input": "a"})), Step::command("home.bold", json!({}))], + }), + Step::new(StepKind::If { + cell: "B1".into(), + op: CompareOp::Empty, + value: Json::Null, + then: vec![Step::new(StepKind::Stop { message: "empty".into(), rollback: false })], + otherwise: vec![], + }), + ] + } + + #[test] + fn paths_reach_nested_steps() { + let mut s = steps(); + assert_eq!(split_path("3.then.1"), Some(("3.then", 0))); + assert_eq!(split_path("3.then"), None); + assert_eq!(split_path("0"), None); + assert!(matches!(step_at(&s, "2.2").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert!(matches!(step_at(&s, "3.then.1").map(|s| s.kind), Some(StepKind::Stop { .. }))); + assert!(step_at(&s, "3.else.1").is_none()); + assert!(step_at(&s, "1.1").is_none(), "a command has no body"); + assert!(step_at(&s, "9").is_none()); + assert!(list_mut(&mut s, "3.else").is_some_and(|l| l.is_empty())); + } + + #[test] + fn rows_flatten_with_branches() { + let mut out = Vec::new(); + rows(&steps(), "", 0, &mut out); + let keys: Vec<&str> = out.iter().map(|r| r.key.as_str()).collect(); + assert_eq!(keys, ["1", "2", "2.1", "2.2", "3", "3.then", "3.then.1", "3.else"]); + assert_eq!(out.iter().find(|r| r.key == "2.2").map(|r| (r.parent.as_str(), r.index, r.depth)), Some(("2", 1, 1))); + assert_eq!(out.iter().find(|r| r.key == "3.then").and_then(|r| r.branch), Some("Then")); + } + + #[test] + fn moving_a_step_within_its_list() { + let mut s = steps(); + // Step 1 dropped after step 2 (before row 2 of 0..3). + assert_eq!(move_step(&mut s, "", 0, 2).as_deref(), Some("2")); + assert!(matches!(&s[0].kind, StepKind::Repeat { .. })); + // Inside the Repeat: the second step to the top. + assert_eq!(move_step(&mut s, "1", 1, 0).as_deref(), Some("1.1")); + assert!(matches!(step_at(&s, "1.1").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert_eq!(move_step(&mut s, "", 7, 0), None); + // Past the end lands last. + assert_eq!(move_step(&mut s, "", 0, 99).as_deref(), Some("3")); + } + + #[test] + fn breakpoints_are_listed_by_path() { + let mut s = steps(); + for p in ["1", "2.2", "3.then.1"] { + if let Some(st) = step_mut(&mut s, p) { + st.breakpoint = true; + } + } + let mut out = Vec::new(); + breakpoints(&s, "", &mut out); + assert_eq!(out, ["1", "2.2", "3.then.1"]); + } + + #[test] + fn summaries_and_categories() { + let s = steps(); + assert_eq!(summary(&s[0]), "Set A1 to x"); + assert_eq!(summary(&s[1]), "Repeat for each row of Input"); + assert_eq!(summary(&s[2]), "If B1 is empty"); + assert_eq!(category(&s[0]), (Icon::Sheet, Cat::Blue)); + assert_eq!(category(&s[1]).1, Cat::Purple); + let long = Step::command("home.bold", json!({"range": "A1:B2"})); + assert!(summary(&long).contains("range: A1:B2"), "{}", summary(&long)); + } + + #[test] + fn static_errors_flag_unknown_and_denied_commands() { + let m = Macro::default(); + assert!(static_error(&m, &Step::command("no.such", json!({}))).is_some_and(|e| e.contains("no command"))); + assert!(static_error(&m, &Step::command("file.save", json!({}))).is_some_and(|e| e.contains("can't run in a macro"))); + assert!(static_error(&m, &Step::command("data.getData", json!({"path": "/tmp/x.csv"}))).is_some_and(|e| e.contains("permissions"))); + assert_eq!(static_error(&m, &Step::command("cell.set", json!({"cell": "A1"}))), None); + } + + #[test] + fn required_keys_come_from_the_params_doc() { + assert_eq!(required_keys("{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}"), ["cell", "input"]); + assert_eq!(required_keys("{range?, options: {a, b}, on?: bool}"), ["range", "options"]); + assert!(required_keys("no braces").is_empty()); + assert!(required_keys("{").is_empty()); + } + + #[test] + fn the_picker_offers_control_steps_and_allowed_commands_only() { + let all = picker_items(""); + assert_eq!(all.first().map(|x| x.0.as_str()), Some("@repeat")); + // Denied commands aren't offered (exports without a path are allowed). + let all = picker_items("e"); + assert!(!all.iter().any(|x| x.0 == "file.save" || x.0 == "edit.undo" || x.0.starts_with("macro."))); + let bold = picker_items("bold"); + assert!(bold.iter().any(|x| x.0 == "home.bold")); + assert!(matches!(new_step("@if").kind, StepKind::If { .. })); + assert!(matches!(new_step("cell.set").kind, StepKind::Command { params, .. } if params.get("cell").is_some())); + } + + #[test] + fn tokens_for_ranges_resolve_back_to_them() { + let wb = Workbook::default(); + let input = RangeRef::parse("B2:D9").unwrap_or(RangeRef::cell(gridcraft_engine::core::CellRef::new(0, 0))); + let inputs = vec![("Input".to_string(), Place { sheet: 0, range: input }, false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (target, want) in [ + ("B2:D9", "Input"), + ("C2:C9", "Input.col(2)"), + ("B2:C9", "Input.cols(1..2)"), + ("B3:D9", "Input.rows(2..8)"), + ("B4:D4", "Input.row(3)"), + ("C3", "Input.cell(2,2)"), + ("C3:D4", "Input.cell(2,2).resize(2,2)"), + ("F2:H9", "Input.offset(0,4)"), + ("E2", "Input.cell(1,1).offset(0,3)"), + ("E2:E9", "Input.cell(1,1).offset(0,3).resize(8,1)"), + ] { + let r = RangeRef::parse(target).unwrap_or(input); + let tok = token_for("Input", input, r); + assert_eq!(tok, want, "{target}"); + assert_eq!(resolve::resolve_ref(&tok, &env).map(|p| p.range), Ok(r), "{tok}"); + } + } + + #[test] + fn references_parse_with_sheets_and_dollars() { + let refs = parse_refs("'My Sheet'!$B$2:$C$3, D4"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].0.as_deref(), Some("My Sheet")); + assert_eq!(refs[0].1.a1(), "B2:C3"); + assert_eq!(refs[1], (None, RangeRef::parse("D4").unwrap_or(refs[0].1))); + assert!(parse_refs("not a ref").is_empty()); + } + + #[test] + fn inputs_are_inferred_from_an_example() { + use gridcraft_engine::core::{CellRef, Value}; + let mut sh = gridcraft_engine::model::Sheet::new("S"); + for (c, h) in ["Item", "Qty"].iter().enumerate() { + sh.set_value(CellRef::new(0, c as u32), Value::Text((*h).into())); + } + for r in 1..4 { + sh.set_value(CellRef::new(r, 0), Value::Text(format!("x{r}").into())); + sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r))); + } + let (header, types) = infer_input(&sh, RangeRef::parse("A1:B4").unwrap_or(RangeRef::cell(CellRef::new(0, 0)))); + assert!(header); + assert_eq!(types, [ColType::Text, ColType::Number]); + } +} diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs new file mode 100644 index 000000000..4277721b8 --- /dev/null +++ b/crates/ui-egui/src/macro_ui.rs @@ -0,0 +1,677 @@ +//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status +//! bar), running a macro and answering its questions (approve it, make room for its output), the +//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record +//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes +//! through the `macro.*` and `automate.*` commands. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::dialogs::Dialog; +use crate::theme::{self, Tokens}; + +/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree. +pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text}; + +/// How many macros the Run Macro menu keeps as recent. +const RECENT: usize = 8; + +/// Macro state that belongs to the window, not the workbook. +#[derive(Clone, Debug)] +pub struct MacroUi { + /// Use Relative References: record steps relative to the selection when recording started. + pub relative: bool, + /// Macros run from this window, most recent first (Run Macro's menu). + pub recent: Vec, + /// A question from a macro command, answered with one of its buttons. + pub question: Option, + /// The step editor, while it is open. + pub editor: Option, + /// A debugger result from a question's button, for the editor to take in. + pub debug_result: Option, +} + +impl Default for MacroUi { + fn default() -> Self { + MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None } + } +} + +impl MacroUi { + fn note_recent(&mut self, name: &str) { + self.recent.retain(|n| !n.eq_ignore_ascii_case(name)); + self.recent.insert(0, name.to_string()); + self.recent.truncate(RECENT); + } +} + +/// A question from a command (approve this macro? overwrite the data in the way?). Each choice +/// runs the command again with its parameters added; Cancel drops it. +#[derive(Clone, Debug)] +pub struct Question { + /// The workbook (`DocState::uid`) the question is about: the answer runs only there. + pub doc: u64, + pub title: String, + pub text: String, + pub command: String, + pub params: Json, + pub choices: Vec<(String, Json)>, +} + +/// The macros of the active workbook: (name, description, shortcut). +pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option)> { + app.session + .active() + .map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone(), m.shortcut.clone())).collect()) + .unwrap_or_default() +} + +/// How many commands the recorder has captured, while recording. +pub fn recorded_steps(app: &SheetApp) -> Option { + app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped)) +} + +/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was +/// recorded as a macro. +pub fn toggle_recording(app: &mut SheetApp) { + if app.session.is_recording() { + stop_recording(app); + } else { + app.open_dialog("recordMacro", json!({})); + } +} + +/// Stops recording and says what was saved. +pub fn stop_recording(app: &mut SheetApp) { + match app.run("macro.record", json!({"stop": true})) { + Ok(v) => match v["saved"].as_str() { + Some(name) => { + let steps = v["steps"].as_u64().unwrap_or(0); + app.macros.note_recent(name); + let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }); + if let Some(k) = v["shortcutDropped"].as_str() { + text.push_str(&format!(" without {k}: another macro took it while recording")); + } + app.toast = Some((text, crate::now_ms())); + } + None => app.toast = Some(("Recording stopped".into(), crate::now_ms())), + }, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } +} + +/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt. +pub fn run_macro(app: &mut SheetApp, name: &str) { + call(app, "Run Macro", "macro.run", json!({"name": name})); +} + +/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in +/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran. +pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option { + match app.run(id, params.clone()) { + Ok(v) => { + note_result(app, id, ¶ms, &v); + Some(v) + } + Err(e) => { + if !ask(app, title, id, ¶ms, &e) { + app.message = Some((title.into(), crate::clean_error(&e))); + } + None + } + } +} + +/// The question in an engine error that asks something: `approve: …` or `confirm: …`. +fn question_text(e: &str, prefix: &str) -> Option { + let rest = e.split_once(prefix)?.1; + Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string()) +} + +/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro +/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land +/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question. +pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool { + let (text, choices) = if let Some(q) = question_text(e, "approve: ") { + (q, vec![("Run".to_string(), json!({"approve": true}))]) + } else if let Some(q) = question_text(e, "confirm: ") { + if id.starts_with("macro.") { + // The engine's hint for programs ("run again with onConflict …") becomes the buttons. + let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']); + ( + format!("{what}. Overwrite them, or move the data out of the way?"), + vec![ + ("Overwrite".to_string(), json!({"onConflict": "overwrite"})), + ("Shift Down".to_string(), json!({"onConflict": "shiftDown"})), + ("Shift Right".to_string(), json!({"onConflict": "shiftRight"})), + ], + ) + } else { + (q, vec![("OK".to_string(), json!({"overwrite": true}))]) + } + } else { + return false; + }; + let doc = app.session.active().map_or(0, |d| d.uid); + app.macros.question = Some(Question { doc, title: title.into(), text, command: id.into(), params: params.clone(), choices }); + true +} + +/// After a macro command ran (directly or from a prompt's button): remember the macro and say +/// what it did. +pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { + if id == "macro.debug" { + app.macros.debug_result = Some(v.clone()); + return; + } + if id != "macro.run" { + return; + } + let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); + if name.is_empty() { + return; + } + app.macros.note_recent(&name); + let ran = v["ran"].as_u64().unwrap_or(0); + let text = match v["stopped"].as_str() { + Some(msg) => format!("Macro “{name}” stopped: {msg}"), + None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }), + }; + app.toast = Some((text, crate::now_ms())); +} + +/// Drops the step editor once its workbook is closed. The editor's dialog stays with its +/// workbook's window, so it may not be drawn again to notice. +pub fn forget_closed_editor(app: &mut SheetApp) { + if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) { + app.macros.editor = None; + app.grid.macro_overlay = None; + } +} + +/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel. +pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { + if app.message.is_some() { + return; + } + let Some(q) = app.macros.question.clone() else { return }; + let mut answer: Option> = None; + egui::Modal::new(egui::Id::unique("gridcraft.macro_question")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label(egui::RichText::new(&q.title).strong()); + ui.add(egui::Label::new(&q.text).wrap()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + // Right to left: list the choices reversed, so the first one ends up next to Cancel + // and Enter picks it. + for (k, (label, extra)) in q.choices.iter().enumerate().rev() { + if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked() + || (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + answer = Some(Some(extra.clone())); + } + } + }); + }); + match answer { + // Another workbook became active meanwhile (an agent): the answer would run there. + Some(Some(_)) if app.session.active().is_none_or(|d| d.uid != q.doc) => { + app.macros.question = None; + let book = app.session.documents().iter().find(|d| d.uid == q.doc).map(|d| d.display_title()).unwrap_or_default(); + app.message = + Some((q.title.clone(), format!("The question was about “{book}”, which is no longer the active workbook, so nothing was done."))); + } + Some(Some(extra)) => { + app.macros.question = None; + let mut params = q.params.clone(); + if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) { + m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + // A second question (approve, then make room) asks again. + call(app, &q.title, &q.command, params); + } + Some(None) => app.macros.question = None, + None => {} + } +} + +/// The status bar while a macro is debugged ("Debugging “Name”", with Show when the editor is +/// closed) and while recording (a red dot, "Recording · 7 steps" and Stop). +pub fn status(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if let Some(name) = app.session.macro_debug.as_ref().map(|d| d.name.clone()) { + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.breakpoint); + ui.label(egui::RichText::new(format!("Debugging “{name}”")).font(theme::ui_font(12.5)).color(t.text)); + if app.macros.editor.is_none() { + let show = + ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Show")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if show.on_hover_text("Open the macro in the step editor").clicked() { + app.open_dialog("macroEditor", json!({"macro": name})); + } + } + } + let Some(n) = recorded_steps(app) else { return }; + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.recording); + let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" }); + ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text)); + let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if stop.on_hover_text("Stop recording and save the macro").clicked() { + stop_recording(app); + } +} + +// ---------------------------------------------------------------- shortcuts + +/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own +/// shortcuts win over a macro's (a file can carry any letter). +pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option { + let letter = parse_shortcut(key.name())?; + if RESERVED_SHORTCUTS.iter().any(|(c, _)| *c == letter) { + return None; + } + let wb = &app.session.active()?.wb; + wb.macros.iter().find(|m| m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)).map(|m| m.name.clone()) +} + +/// Ctrl+Shift+letter on the grid: runs the macro with that shortcut. True when one ran. +pub fn run_shortcut(app: &mut SheetApp, key: egui::Key) -> bool { + let Some(name) = shortcut_macro(app, key) else { return false }; + run_macro(app, &name); + true +} + +// ---------------------------------------------------------------- Macros dialog + +/// What a row of the Macros dialog is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Macro, + /// An Automate script (`automate.*`). + Script, +} + +impl Kind { + fn key(self) -> &'static str { + match self { + Kind::Macro => "macro", + Kind::Script => "script", + } + } +} + +/// A row of the Macros dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub name: String, + pub kind: Kind, + pub description: String, + pub shortcut: Option, + pub steps: u64, +} + +/// The Macros dialog's rows: the workbook's macros, then its Automate scripts (from +/// `view.macros`, kept in the dialog's result), and whether the file carries a VBA project. +pub fn entries(app: &SheetApp, d: &Dialog) -> (Vec, bool) { + let mut out: Vec = app + .session + .active() + .map(|doc| { + doc.wb + .macros + .iter() + .map(|m| Entry { + name: m.name.clone(), + kind: Kind::Macro, + description: m.description.clone(), + shortcut: m.shortcut.clone(), + steps: m.step_count() as u64, + }) + .collect() + }) + .unwrap_or_default(); + let mut vba = false; + for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() { + if it.get("kind").and_then(Json::as_str) == Some("vba") { + vba = true; + continue; + } + let Some(name) = it.get("name").and_then(Json::as_str) else { continue }; + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps }); + } + (out, vba) +} + +/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros` +/// (the Automate scripts and a file's VBA project), as before macros existed. +pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { + let names = macro_list(app); + let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned(); + let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default(); + let doc = app.session.active().map_or(0, |d| d.uid); + let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro", "doc": doc})); + d.result = app.session.run("view.macros", json!({})).ok(); + d +} + +/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the +/// description under them; Options… switches to the shortcut and description of one macro. +pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + // Its rows and the Options pane are the workbook's it opened on: another workbook becoming + // active (an agent) closes it rather than run, delete or change a macro of the same name there. + let active = app.session.active().map(|doc| doc.uid); + if d.values.get("doc").and_then(Json::as_u64).is_some_and(|doc| Some(doc) != active) { + *open = false; + return; + } + // Scripts can change while the dialog is open (Delete): read them again when the workbook does. + let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0); + if d.values.get("revision").and_then(Json::as_u64) != Some(rev) { + d.result = app.session.run("view.macros", json!({})).ok(); + d.values.insert("revision".into(), json!(rev)); + } + if d.values.get("options").and_then(Json::as_bool) == Some(true) { + options(app, ui, d); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + let untrusted = d + .result + .as_ref() + .and_then(Json::as_array) + .is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); + if untrusted { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + let (list, vba) = entries(app, d); + let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string(); + let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro }; + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(330.0); + let label = ui.label("Macro name:"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).labelled_by(label.id).changed() { + kind = Kind::Macro; + } + egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(326.0, 200.0)); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + if list.is_empty() { + ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim)); + } + for e in &list { + let label = match (e.kind, &e.shortcut) { + (Kind::Macro, Some(k)) => format!("{} {k}", e.name), + (Kind::Macro, None) => e.name.clone(), + (Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }), + }; + let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim()); + let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0))); + if r.clicked() { + name = e.name.clone(); + kind = e.kind; + } + if r.double_clicked() { + *open = false; + run_entry(app, e); + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(18.0); + let w = vec2(100.0, 24.0); + let target = name.trim().to_string(); + let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned(); + let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro); + if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked() + && let Some(e) = &found + { + *open = false; + run_entry(app, e); + } + if ui + .add_enabled(is_macro, egui::Button::new("Step Into").min_size(w)) + .on_hover_text("Open the macro in the step editor and stop before its first step") + .clicked() + { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target, "debug": true})); + } + if ui.add_enabled(is_macro, egui::Button::new("Edit").min_size(w)).clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target})); + } + let free = !target.is_empty() && !list.iter().any(|e| e.kind == Kind::Macro && e.name.eq_ignore_ascii_case(&target)); + if ui.add_enabled(free, egui::Button::new("Create").min_size(w)).on_hover_text("Write a new macro with this name").clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"new": target})); + } + if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked() + && let Some(e) = &found + { + let (title, command, what) = match e.kind { + Kind::Macro => ("Delete Macro", "macro.delete", "macro"), + Kind::Script => ("Delete Script", "automate.deleteScript", "script"), + }; + app.macros.question = Some(Question { + doc: app.session.active().map_or(0, |d| d.uid), + title: title.into(), + text: format!("Do you want to delete {what} “{}”?", e.name), + command: command.into(), + params: json!({"name": e.name}), + choices: vec![("Delete".into(), json!({}))], + }); + } + if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked() + && let Some(e) = &found + { + let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default(); + d.values.insert("options".into(), json!(true)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(e.description)); + } + ui.add_space(12.0); + if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() { + *open = false; + } + }); + }); + ui.add_space(6.0); + ui.label("Description"); + let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim)); + if vba { + ui.add_space(6.0); + ui.label( + egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim), + ); + } + d.values.insert("name".into(), json!(name)); + d.values.insert("kind".into(), json!(kind.key())); +} + +/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script. +fn run_entry(app: &mut SheetApp, e: &Entry) { + match e.kind { + Kind::Macro => run_macro(app, &e.name), + Kind::Script => { + call(app, "Run Script", "automate.runScript", json!({"name": e.name})); + } + } +} + +/// Macro Options: the shortcut letter and the description of the selected macro. +fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { + let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string(); + ui.label(egui::RichText::new(format!("Macro name: {name}")).strong()); + ui.add_space(4.0); + ui.label("Shortcut key:"); + let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string(); + shortcut_field(ui, &mut key, "macro_shortcut"); + let letter = parse_shortcut(&key); + let conflict = match (key.trim().is_empty(), letter, app.session.active()) { + (true, ..) => None, + (false, None, _) => Some("Use a letter from A to Z.".to_string()), + (false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c), + (false, Some(_), None) => None, + }; + if let Some(c) = &conflict { + ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(4.0); + let label = ui.label("Description:"); + let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string(); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")).labelled_by(label.id); + ui.add_space(6.0); + let mut done = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + done = true; + } + if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() { + done = save_options(app, &name, letter, &desc); + } + }); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); + if done { + d.values.remove("options"); + } +} + +/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter. +fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) { + ui.horizontal(|ui| { + let label = ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" }); + ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)).labelled_by(label.id); + }); + *key = key.to_ascii_uppercase(); +} + +/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown). +pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, description: &str) -> bool { + let mut m = match app.run("macro.get", json!({"name": name})) { + Ok(m) if m.is_object() => m, + Ok(_) => return false, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + return false; + } + }; + if let Some(o) = m.as_object_mut() { + match letter { + Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))), + None => o.remove("shortcut"), + }; + o.insert("description".into(), json!(description)); + } + // Macro Options changes a macro that exists, so the save replaces it. + match app.run("macro.save", json!({"macro": m, "replace": true})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + false + } + } +} + +// ---------------------------------------------------------------- Record Macro dialog + +/// Record Macro: the macro's name, shortcut and description before recording starts. While +/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel). +pub fn open_record(app: &mut SheetApp, params: &Json) -> Option { + if app.session.is_recording() { + stop_recording(app); + return None; + } + let suggested = app.session.active().map(|d| gridcraft_engine::macros::next_name(&d.wb)).unwrap_or_else(|| "Macro1".into()); + let name = params["name"].as_str().map(str::to_string).filter(|n| !n.trim().is_empty()).unwrap_or(suggested); + Some(Dialog::custom("recordMacro", "Record Macro", json!({"name": name, "shortcut": "", "description": ""}))) +} + +/// What is wrong with the Record Macro dialog's name or shortcut, if anything. +pub fn record_problem(app: &SheetApp, name: &str, key: &str) -> Option { + let doc = app.session.active()?; + if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) { + return Some(e); + } + if key.trim().is_empty() { + return None; + } + match parse_shortcut(key) { + None => Some("Use a letter from A to Z for the shortcut.".into()), + Some(c) => shortcut_conflict(&doc.wb, name, c), + } +} + +/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the +/// shortcut is not usable, and the reason shows under the fields. +pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut name = get(d, "name"); + let mut key = get(d, "shortcut"); + let mut desc = get(d, "description"); + let label = ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")).labelled_by(label.id); + ui.add_space(4.0); + ui.label("Shortcut key:"); + shortcut_field(ui, &mut key, "record_shortcut"); + ui.add_space(4.0); + let label = ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")).labelled_by(label.id); + ui.checkbox(&mut app.macros.relative, "Use Relative References"); + let problem = record_problem(app, &name, &key); + if let Some(p) = &problem { + ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + *open = false; + } + if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() { + let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative}); + if !key.trim().is_empty() { + p["shortcut"] = json!(key.trim()); + } + match app.run("macro.record", p) { + Ok(_) => *open = false, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } + } + }); + d.values.insert("name".into(), json!(name)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn questions_are_read_from_the_error() { + let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)"; + assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened.")); + assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None); + assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?")); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 30ba3ba80..ec6146018 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -185,16 +185,23 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_wrapped(|ui| { for (l, k, s) in [ ("Column", "column", ""), - ("Stacked", "column", "stacked"), + ("Stacked Column", "column", "stacked"), + ("100% Stacked Column", "column", "stacked100"), ("Bar", "bar", ""), + ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), ("Line", "line", "markers"), + ("Stacked Line", "line", "stacked"), ("Area", "area", ""), + ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), ("Pie", "pie", ""), ("Doughnut", "doughnut", ""), ("Scatter", "scatter", ""), ("Radar", "radar", ""), ] { - if ui.button(l).clicked() { + let cur = chart.kind == gridcraft_engine::cmd::insert::chart_kind(k, s); + if ui.selectable_label(cur, l).clicked() { app.run_or_alert("chart.set", json!({"chart": id, "type": k, "subtype": s})); } } diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 70a3f0e25..f444b6ac2 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -120,19 +120,20 @@ fn pages_iter(geo: &Geo, pg: &PageGrid) -> impl Iterator } /// Paints the desk, the pages and their cells; returns the per-page quadrants for the headings. -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_pages( - app: &SheetApp, - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - cf: &mut CfCache, - t: &Tokens, -) -> Vec<((u32, u32), (u32, u32), Rect)> { +pub(crate) struct PagePaintInput<'a> { + pub(crate) app: &'a SheetApp, + pub(crate) painter: &'a Painter, + pub(crate) geo: &'a Geo, + pub(crate) workbook: &'a Workbook, + pub(crate) sheet_index: usize, + pub(crate) sheet: &'a Sheet, + pub(crate) selection: &'a gridcraft_engine::Selection, + pub(crate) outline: Rect, + pub(crate) tokens: &'a Tokens, +} + +pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec { + let PagePaintInput { app, painter: p, geo, workbook: wb, sheet_index: si, sheet: sh, selection: sel, outline, tokens: t } = input; let mut quads = Vec::new(); let Some(pg) = geo.paper.clone() else { return quads }; p.rect_filled(geo.cells, 0.0, t.desk); @@ -161,7 +162,7 @@ pub(crate) fn paint_pages( } painted += 1; let pc = p.with_clip_rect(clip); - grid::paint_quadrant(&pc, geo, wb, si, sh, rows, cols, cf, t); + grid::paint_quadrant(grid::QuadrantPaint { painter: &pc, geo, workbook: wb, sheet_index: si, sheet: sh, rows, cols, cf, tokens: t }); if !pg.is_data_page(i, j) { // "Click to add data" pages: faded until something is typed there. pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75)); @@ -170,7 +171,17 @@ pub(crate) fn paint_pages( } // The selection outline may straddle the margin edge (as at A1 in Normal view). let sel_clip = clip.expand(3.0).intersect(page).intersect(geo.cells); - grid::paint_selection(&p.with_clip_rect(sel_clip), geo, sh, sel, outline, t, app, rows, cols); + grid::paint_selection(grid::SelectionPaintInput { + painter: &p.with_clip_rect(sel_clip), + geo, + sheet: sh, + selection: sel, + outline, + tokens: t, + app, + rows, + cols, + }); } quads } diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index a6bffce4e..ef753fa99 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -169,7 +169,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if ui.button(lang.tr("Calculated Field…")).clicked() { app.run_or_alert("pivot.calculatedField", json!({"pivot": name})); } - egui::ComboBox::from_id_salt("pv_layout") + craft_ui::ComboBox::from_id_salt("pv_layout") .selected_text(lang.tr("Report Layout")) .show_ui(ui, |ui| { for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 37cb2a844..49aba2b0e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -10,207 +10,39 @@ use crate::keytips::keytip_table; use crate::theme::{self, Cat, Tokens}; use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; +mod automate; +mod contextual; +mod controls; +mod data; +mod formulas; +mod galleries; +mod home; +mod insert; +mod page_layout; +mod review; +mod shortcuts; +mod title_bar; +mod view; + +use automate::automate; +use contextual::{chart_design, table_design}; +pub(crate) use controls::{ComboResult, editable_combo}; +use controls::{act, keytip}; +use data::data; +use formulas::formulas; +pub use home::FONTS; +use home::home; +use insert::{draw, insert}; +use page_layout::page_layout; +use review::review; +pub use shortcuts::shortcut; +pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar}; +use title_bar::{TITLE_BAR_H, TITLE_CONTROL_H}; +use view::view; + pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; - -/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the -/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take -/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit -/// vertically centred in it, with the controls beside them (#170). -const MAC: bool = cfg!(target_os = "macos"); -pub const MAC_TITLE_BAR: f32 = 28.0; -/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the -/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the -/// gap after them from shrinking). -const MAC_LIGHT: f32 = 12.0; -const MAC_LIGHT_PITCH: f32 = 20.0; -const MAC_LIGHTS_LEFT: f32 = 10.0; -/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights -/// keep from the top of the window. -pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; - -/// Leave room for the macOS traffic lights when the content extends into the title bar. -pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; -const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; -/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. -const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); -/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title. -pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let ch = TITLE_CONTROL_H; - egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { - let rect = ui.max_rect(); - // Dragging the empty title area moves the window. - let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag()); - if bg.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if bg.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - ui.horizontal_centered(|ui| { - ui.add_space(TITLE_LEFT_PAD); - // AutoSave toggle (saves after each change when the workbook has a file). - ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); - let on = app.ui.autosave; - let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); - ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); - ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); - let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; - ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim }); - if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { - app.toggle_autosave(); - } - ui.add_space(6.0); - let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); - control_keytip(app, ui, &__k, "start"); - if __k.clicked() { - app.open_dialog("start", json!({})); - } - if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() - // File operations must include the cell the user is still editing (as the File > Save menu item already does). - && app.commit_edit(0, 0, false, false) - { - app.run_or_alert("file.save", json!({})); - } - // The commands' own availability: there must be a step, and no undo group may be open on - // the document (an action that spans several calls is still running). - let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); - let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); - // Named and marked enabled or not for assistive technology (and the UI tests). - let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); - undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo")); - if undo.clicked() && can_undo { - app.run_or_alert("edit.undo", json!({})); - } - let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); - egui::Popup::menu(&undo_list).show(|ui| { - let labels: Vec = if can_undo { - app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() - } else { - Vec::new() - }; - if labels.is_empty() { - ui.label("Can't Undo"); - } - for (i, l) in labels.iter().enumerate() { - if ui.button(format!("Undo {l}")).clicked() { - app.run_or_alert("edit.undo", json!({"steps": i + 1})); - } - } - }); - let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); - redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo")); - if redo.clicked() && can_redo { - app.run_or_alert("edit.redo", json!({})); - } - let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); - egui::Popup::menu(&more).show(|ui| { - for (label, id) in [ - ("New Workbook", "file.new"), - ("Open…", "file.open"), - ("Save As…", "file.saveAs"), - ("Sort A to Z", "data.sortAscending"), - ("Calculate Now", "formulas.calculateNow"), - ] { - if ui.button(label).clicked() { - app.run_or_alert(id, json!({})); - } - } - if ui.button("Print…").clicked() { - app.print_now(json!({})); - } - }); - let left_end = ui.min_rect().right(); - let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(8.0); - let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); - control_keytip(app, ui, &__k, "commandSearch"); - if __k.clicked() { - app.open_dialog("commandSearch", json!({})); - } - let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); - egui::Popup::menu(&share).show(|ui| { - if ui.button("Export as PDF…").clicked() { - app.run_or_alert("file.exportPdf", json!({})); - } - for (label, fmt) in - [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] - { - if ui.button(label).clicked() { - app.open_dialog("saveCopy", json!({"format": fmt})); - } - } - }); - let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)); - control_keytip(app, ui, &__k, "comments"); - if __k.clicked() { - app.open_dialog("comments", json!({})); - } - // Quick light/dark switch (#194); right-click offers System as well. The same - // choice lives in File › Display theme and the View tab. - let lang = app.ui.language; - let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0)); - let dark = ui.ctx().theme() == egui::Theme::Dark; - theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode"))); - if theme.clicked() { - act(app, "view.darkMode", json!({})); - } - theme.context_menu(|ui| theme_choices(app, ui)); - ui.min_rect().left() - }); - // Centred title, cut with "…" so it never runs under the controls on either side. - let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - let max_w = title_max_width(rect.center().x, left_end, right.inner); - paint_title(ui, rect.center(), &title, dirty, max_w, t.text); - }); - }); -} - -/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). -fn title_galleys( - painter: &egui::Painter, - title: &str, - dirty: bool, - max_w: f32, - color: Color32, -) -> (std::sync::Arc, Option>) { - let font = theme::ui_bold(13.0); - let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); - let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); - let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); - job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); - (painter.layout_job(job), dot) -} - -/// Gap kept between the title and the controls beside it. -const TITLE_GAP: f32 = 12.0; - -/// The widest the centred title may be without reaching the controls that end at `left_end` or -/// start at `right_start`. -fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { - let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; - (2.0 * half).max(0.0) -} - -/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved -/// marker stays visible however short the title gets. -fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { - let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); - let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); - let w = galley.size().x + dot_w; - let left = center.x - w / 2.0; - let top = center.y - galley.size().y / 2.0; - let galley_w = galley.size().x; - ui.painter().galley(pos2(left, top), galley, color); - if let Some(dot) = dot { - ui.painter().galley(pos2(left + galley_w, top), dot, color); - } -} - /// Draws the ribbon tabs and the active tab's horizontally scrollable card, resetting an unavailable contextual tab to Home. pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -459,2599 +291,3 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { } v } - -fn sep(ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), Sense::hover()); - ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); -} - -fn active_style(app: &SheetApp) -> Style { - app.session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - Some(d.wb.styles.get(sh.style_id(d.selection.active)).clone()) - }) - .unwrap_or_default() -} - -fn theme_colors(app: &SheetApp) -> [u32; 12] { - app.session.active().map(|d| d.wb.theme.colors).unwrap_or(gridcraft_engine::model::Theme::default().colors) -} - -/// Runs a command from a ribbon click (menu-style: no params may open a dialog). -fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { - app.run_or_alert(id, params); -} - -/// How many cells Formulas › Error Checking › Circular References lists. -const CIRCULAR_MENU_LIMIT: usize = 100; - -/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order -/// the calculation found them, as (sheet index, cell, "Sheet!A1"). -fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { - let Some(d) = app.session.active() else { return Vec::new() }; - let mut seen = std::collections::HashSet::new(); - d.calc - .circular - .iter() - .filter(|k| seen.insert(**k)) - .filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1())))) - .take(CIRCULAR_MENU_LIMIT) - .collect() -} - -/// Keytips are painted, not focusable widgets: the grid retains keyboard focus. -fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { - let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; - let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); - let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); - let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); - painter.rect_filled(rect, 2.0, t.input_bg); - painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); - painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); -} - -/// Paint the badge for a control that runs command `id`, when the armed prefix matches. -fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) { - if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } -} - -/// Paint the single badge for a menu anchor whose popup items run `targets`. -fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) { - if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } -} - -/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so -/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite. -fn checkbox_on(clicked: bool, shown: bool) -> bool { - shown == clicked -} - -/// Consume a one-shot keytip request for control `id` (set by `Action::Control`). -fn invoke(app: &mut SheetApp, id: &str) -> bool { - if app.ui.keytip_invoke == Some(id) { - app.ui.keytip_invoke = None; - true - } else { - false - } -} - -/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle). -fn popup_command(forced: bool, clicked: bool) -> Option { - if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) } -} - -/// Per-control state of an [`editable_combo`]: the filter text and highlighted row. -#[derive(Clone, Default)] -struct ComboState { - text: String, - sel: i32, - was_open: bool, -} - -/// What the user did with an [`editable_combo`]. -pub(crate) enum ComboResult { - None, - /// A row was applied: clicked or Enter on the highlighted row. - Picked(String), -} - -/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. -/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) -/// or clicked, the popup opens, focuses the filter field, and selects its text. -pub(crate) fn editable_combo( - ui: &mut Ui, - anchor: &egui::Response, - salt: &'static str, - current_value: &str, - items: &[(&str, String)], - forced: bool, -) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, forced, true, None) -} - -/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or -/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size -/// list under its box. -fn list_combo(ui: &mut Ui, anchor: &egui::Response, under: Rect, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under)) -} - -fn combo_popup( - ui: &mut Ui, - anchor: &egui::Response, - salt: &'static str, - current_value: &str, - items: &[(&str, String)], - forced: bool, - filter: bool, - under: Option, -) -> ComboResult { - let popup_id = anchor.id.with("popup"); - let state_id = anchor.id.with(salt).with("__combo_state"); - let mut st = ui.data_mut(|d| d.get_temp::(state_id)).unwrap_or_default(); - let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id); - if open_now && !st.was_open { - st.text = String::new(); - st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0); - // The reset lands on the frame AFTER the popup opens, which usually has no input of - // its own — without a repaint request the stale filtered list stays on screen. - ui.ctx().request_repaint(); - } - st.was_open = open_now; - let mut result = ComboResult::None; - let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())); - if let Some(r) = under { - popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width()); - } - let row_w = under.map_or(220.0, |r| r.width() - 12.0); - popup.show(|ui| { - // Arrows and Enter navigate the list, not the filter field's cursor. Capture them and - // strip them from the input so the TextEdit never sees them. - let (mut down, mut up, mut enter) = (false, false, false); - ui.input(|i| { - down = i.key_pressed(egui::Key::ArrowDown); - up = i.key_pressed(egui::Key::ArrowUp); - enter = i.key_pressed(egui::Key::Enter); - }); - if down || up || enter { - ui.ctx().input_mut(|i| { - i.events.retain(|e| { - !matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. }) - }); - }); - } - if filter { - let text_id = anchor.id.with(salt).with("__combo_text"); - let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); - if open_now && !te.has_focus() { - ui.ctx().memory_mut(|m| m.request_focus(text_id)); - // Select all once focus lands so typing replaces the filter. - if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { - let len = st.text.chars().count(); - s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); - s.store(ui.ctx(), text_id); - } - } - } - let lower = st.text.to_lowercase(); - let filtered: Vec = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect(); - if !filtered.is_empty() { - let n = filtered.len() as i32; - st.sel = st.sel.clamp(0, n - 1); - if down { - st.sel = (st.sel + 1).rem_euclid(n); - } - if up { - st.sel = (st.sel - 1).rem_euclid(n); - } - } - if enter && !filtered.is_empty() { - let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; - result = ComboResult::Picked(items[idx].0.to_string()); - } - if filter { - ui.separator(); - } - let button_h = ui.spacing().interact_size.y.max(20.0); - let spacing = ui.spacing().item_spacing.y; - let n = filtered.len() as f32; - - let content_h = n * button_h + (n - 1.0).max(0.0) * spacing; - - let max_rows = 9.0_f32; - let max_h = max_rows * button_h + (max_rows - 1.0) * spacing; - - let desired_h = content_h.min(max_h); - - ui.set_min_height(desired_h); - - egui::ScrollArea::vertical().max_height(desired_h).show(ui, |ui| { - for (pos, i) in filtered.iter().enumerate() { - let (value, disp) = &items[*i]; - - let row = ui.add(egui::Button::selectable(pos as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0))); - - if (down || up) && pos as i32 == st.sel && !ui.clip_rect().contains_rect(row.rect) { - row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); - } - - if row.clicked() { - result = ComboResult::Picked(value.to_string()); - } - - if row.hovered() { - st.sel = pos as i32; - } - } - }); - if !matches!(result, ComboResult::None) { - ui.close(); - } - }); - ui.data_mut(|d| d.insert_temp(state_id, st)); - result -} - -/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. -const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[ - ("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), - ("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"), - ("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"), - ("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"), - ("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"), - ("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"), - ("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"), - ("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"), - ("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"), - ("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"), - ("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"), -]; - -fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { - for (label, id, p) in items { - if *label == "-" { - ui.separator(); - continue; - } - let lang = app.ui.language; - let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); - if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); - } - if response.clicked() { - if let Some(d) = id.strip_prefix("dialog:") { - app.open_dialog(d, p.clone()); - } else if *id == "edit.paste" { - app.paste_from_clipboard(); - } else { - act(app, id, p.clone()); - } - } - } -} - -/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips. -fn home(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let st = active_style(app); - // Clipboard - let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); - paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste")); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) - { - keytip(app, ui, sequence, paste.rect.center_bottom()); - } - if paste.clicked() { - app.paste_from_clipboard(); - } - let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); - // Keep a keyboard-opened menu anchored to the button when a pointer click - // cancels keytips, so its items remain in place and can still be clicked. - let anchor_id = paste.id.with("keytip_anchor"); - // Query response state before taking the egui context write lock. - // Calling secondary_clicked() inside data_mut can deadlock. - let paste_secondary_clicked = paste.secondary_clicked(); - let anchored = ui.ctx().data_mut(|data| { - let anchored = if paste_keys { - true - } else if paste_secondary_clicked { - false - } else { - data.get_temp::(anchor_id).unwrap_or(false) - }; - data.insert_temp(anchor_id, anchored); - anchored - }); - let mut paste_popup = egui::Popup::context_menu(&paste); - if anchored { - paste_popup = paste_popup.anchor(&paste); - } - if paste_keys { - paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); - } - paste_popup.show(|ui| { - menu_items( - app, - ui, - &[ - ("Paste", "edit.paste", json!({})), - ("Paste Values", "edit.pasteSpecial", json!({"what": "values"})), - ("Paste Formulas", "edit.pasteSpecial", json!({"what": "formulas"})), - ("Paste Formatting", "edit.pasteSpecial", json!({"what": "formats"})), - ("Transpose", "edit.pasteSpecial", json!({"what": "all", "transpose": true})), - ("Paste Link", "edit.pasteSpecial", json!({"what": "all", "link": true})), - ("-", "", json!(null)), - ("Paste Special…", "dialog:pasteSpecial", json!({})), - ], - ); - }); - ui.vertical(|ui| { - let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "edit.cut"); - if __k.clicked() { - app.copy_to_clipboard(ui.ctx(), "edit.cut"); - } - let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "edit.copy"); - if __k.clicked() { - app.copy_to_clipboard(ui.ctx(), "edit.copy"); - } - let fp_on = app.session.format_painter.is_some(); - let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); - fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter")); - control_keytip(app, ui, &fp, "edit.formatPainter"); - if fp.double_clicked() { - act(app, "edit.formatPainter", json!({"sticky": true})); - } else if fp.clicked() { - if fp_on { - app.session.format_painter = None; - } else { - act(app, "edit.formatPainter", json!({})); - } - } - }); - sep(ui); - // Font - ui.vertical(|ui| { - ui.horizontal(|ui| { - font_combo(app, ui, &st); - size_combo(app, ui, &st); - let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseFontSize"); - if __k.clicked() { - act(app, "home.increaseFontSize", json!({})); - } - let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseFontSize"); - if __k.clicked() { - act(app, "home.decreaseFontSize", json!({})); - } - }); - ui.horizontal(|ui| { - let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); - control_keytip(app, ui, &__k, "home.bold"); - if __k.clicked() { - act(app, "home.bold", json!({})); - } - let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); - control_keytip(app, ui, &__k, "home.italic"); - if __k.clicked() { - act(app, "home.italic", json!({})); - } - let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); - control_keytip(app, ui, &ua, "home.underline"); - if u { - act(app, "home.underline", json!({})); - } - egui::Popup::menu(&ua).show(|ui| { - menu_items( - app, - ui, - &[ - ("Underline", "home.underline", json!({"style": "single"})), - ("Double Underline", "home.underline", json!({"style": "double"})), - ], - ); - }); - let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough"); - control_keytip(app, ui, &__k, "home.strikethrough"); - if __k.clicked() { - act(app, "home.strikethrough", json!({})); - } - let (b, ba) = split_button(ui, Icon::Borders, None, "Borders"); - control_keytip(app, ui, &ba, "home.borders"); - if b { - 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| { - for (label, preset) in [ - ("Bottom Border", "bottom"), - ("Top Border", "top"), - ("Left Border", "left"), - ("Right Border", "right"), - ("No Border", "none"), - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Thick Outside Borders", "thickOutside"), - ("Bottom Double Border", "doubleBottom"), - ("Thick Bottom Border", "thickBottom"), - ("Top and Bottom Border", "topBottom"), - ("Top and Thick Bottom Border", "topThickBottom"), - ("Top and Double Bottom Border", "topDoubleBottom"), - ("Inside Borders", "inside"), - ] { - if crate::border_preview::preset_button(ui, label, preset).clicked() { - app.grid.last_border = preset.to_string(); - act(app, "home.borders", json!({"preset": preset})); - } - } - ui.separator(); - if ui.button("More Borders…").clicked() { - app.open_dialog("formatCells", json!({"tab": "Border"})); - } - }); - let fill = app.grid.last_fill.clone(); - let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32); - let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color"); - control_keytip(app, ui, &fa, "home.fillColor"); - if f { - act(app, "home.fillColor", json!({"color": fill})); - } - let forced = invoke(app, "home.fillColor"); - egui::Popup::menu(&fa) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .open_memory(popup_command(forced, fa.clicked())) - .show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { - if c != "none" { - app.grid.last_fill = c.clone(); - } - act(app, "home.fillColor", json!({"color": c})); - ui.close(); - } - }); - let fc = app.grid.last_font_color.clone(); - let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); - let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); - control_keytip(app, ui, &ca, "home.fontColor"); - if c { - act(app, "home.fontColor", json!({"color": fc})); - } - let forced = invoke(app, "home.fontColor"); - egui::Popup::menu(&ca) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .open_memory(popup_command(forced, ca.clicked())) - .show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { - if c != "none" { - app.grid.last_font_color = c.clone(); - } - act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); - ui.close(); - } - }); - }); - }); - sep(ui); - // Alignment - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, v, id, tip) in [ - (Icon::AlignTop, VAlign::Top, "home.alignTop", "Top Align"), - (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), - (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), - ] { - let __k = toggle_button(ui, icon, st.align.v == v, tip); - control_keytip(app, ui, &__k, id); - if __k.clicked() { - act(app, id, json!({})); - } - } - let o = small_button(ui, Icon::Orientation, "", "Orientation", true); - control_keytip(app, ui, &o, "home.orientation"); - let forced_o = invoke(app, "home.orientation"); - egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| { - menu_items( - app, - ui, - &[ - ("Angle Counterclockwise", "home.orientation", json!({"angle": "counterclockwise"})), - ("Angle Clockwise", "home.orientation", json!({"angle": "clockwise"})), - ("Vertical Text", "home.orientation", json!({"angle": "vertical"})), - ("Rotate Text Up", "home.orientation", json!({"angle": "up"})), - ("Rotate Text Down", "home.orientation", json!({"angle": "down"})), - ("Horizontal", "home.orientation", json!({"angle": 0})), - ("-", "", json!(null)), - ("Format Cell Alignment…", "dialog:formatCells", json!({"tab": "Alignment"})), - ], - ); - }); - let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text"); - control_keytip(app, ui, &wrap, "home.wrapText"); - if wrap.clicked() { - act(app, "home.wrapText", json!({})); - } - ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5))); - }); - ui.horizontal(|ui| { - for (icon, h, id, tip) in [ - (Icon::AlignLeft, HAlign::Left, "home.alignLeft", "Align Left"), - (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), - (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), - ] { - let response = toggle_button(ui, icon, st.align.h == h, tip); - for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } - if response.clicked() { - act(app, id, json!({})); - } - } - let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseIndent"); - if __k.clicked() { - act(app, "home.decreaseIndent", json!({})); - } - let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseIndent"); - if __k.clicked() { - act(app, "home.increaseIndent", json!({})); - } - let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); - control_keytip(app, ui, &ma, "home.mergeCenter"); - if m { - 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, - ui, - &[ - ("Merge & Center", "home.mergeCenter", json!({})), - ("Merge Across", "home.mergeAcross", json!({})), - ("Merge Cells", "home.mergeCells", json!({})), - ("Unmerge Cells", "home.unmergeCells", json!({})), - ], - ); - }); - }); - }); - sep(ui); - // Number - ui.vertical(|ui| { - number_combo(app, ui, &st); - ui.horizontal(|ui| { - let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format"); - control_keytip(app, ui, &ca, "home.accounting"); - if c { - act(app, "home.accounting", json!({})); - } - egui::Popup::menu(&ca).show(|ui| { - let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect(); - items.push(("-", "", json!(null))); - items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"}))); - menu_items(app, ui, &items); - }); - let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.percent"); - if __k.clicked() { - act(app, "home.percent", json!({})); - } - let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.comma"); - if __k.clicked() { - act(app, "home.comma", json!({})); - } - let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseDecimal"); - if __k.clicked() { - act(app, "home.increaseDecimal", json!({})); - } - let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseDecimal"); - if __k.clicked() { - act(app, "home.decreaseDecimal", json!({})); - } - }); - }); - sep(ui); - // Styles - let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true); - anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]); - egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui)); - let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true); - anchor_keytip(app, ui, &ft, &["table.style"]); - egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable")); - let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true); - anchor_keytip(app, ui, &cs, &["home.newCellStyle"]); - egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui)); - sep(ui); - // Cells - ui.vertical(|ui| { - let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"], - ) { - keytip(app, ui, sequence, ins.rect.center_bottom()); - } - egui::Popup::menu(&ins).show(|ui| { - menu_items( - app, - ui, - &[ - ("Insert Cells…", "dialog:insertCells", json!({})), - ("Insert Sheet Rows", "home.insertRows", json!({})), - ("Insert Sheet Columns", "home.insertColumns", json!({})), - ("Insert Sheet", "home.insertSheet", json!({})), - ], - ); - }); - let del = small_button(ui, Icon::Delete, "Delete", "Delete", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"], - ) { - keytip(app, ui, sequence, del.rect.center_bottom()); - } - egui::Popup::menu(&del).show(|ui| { - menu_items( - app, - ui, - &[ - ("Delete Cells…", "dialog:deleteCells", json!({})), - ("Delete Sheet Rows", "home.deleteRows", json!({})), - ("Delete Sheet Columns", "home.deleteColumns", json!({})), - ("Delete Sheet", "home.deleteSheet", json!({})), - ], - ); - }); - let fmt = small_button(ui, Icon::Format, "Format", "Format", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &[ - "dialog:rowHeight", - "home.autofitRowHeight", - "dialog:columnWidth", - "home.autofitColumnWidth", - "dialog:defaultWidth", - "home.hideRows", - "home.hideColumns", - "home.unhideRows", - "home.unhideColumns", - "sheet.hide", - "sheet.unhide", - "dialog:renameSheet", - "dialog:moveSheet", - "dialog:protectSheet", - "home.lockCell", - "dialog:formatCells", - ], - ) { - keytip(app, ui, sequence, fmt.rect.center_bottom()); - } - let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); - let mut format_popup = egui::Popup::menu(&fmt); - if format_keys { - format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); - } - let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; - format_popup.show(|ui| { - menu_items( - app, - ui, - &[ - ("Row Height…", "dialog:rowHeight", json!({})), - ("AutoFit Row Height", "home.autofitRowHeight", json!({})), - ("Column Width…", "dialog:columnWidth", json!({})), - ("AutoFit Column Width", "home.autofitColumnWidth", json!({})), - ("Default Width…", "dialog:defaultWidth", json!({})), - ("-", "", json!(null)), - ("Hide Rows", "home.hideRows", json!({})), - ("Hide Columns", "home.hideColumns", json!({})), - ("Unhide Rows", "home.unhideRows", json!({})), - ("Unhide Columns", "home.unhideColumns", json!({})), - ("Hide Sheet", "sheet.hide", json!({})), - ("Unhide Sheet…", "sheet.unhide", json!({})), - ("-", "", json!(null)), - ("Rename Sheet", "dialog:renameSheet", json!({})), - ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), - ("-", "", json!(null)), - (protect_label, "dialog:protectSheet", json!({})), - ("Lock Cell", "home.lockCell", json!({})), - ("Format Cells…", "dialog:formatCells", json!({})), - ], - ); - }); - }); - sep(ui); - // Editing - ui.vertical(|ui| { - ui.horizontal(|ui| { - let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); - control_keytip(app, ui, &sa, "formulas.autoSum"); - if s { - act(app, "formulas.autoSum", json!({})); - } - egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); - }); - let fill = small_button(ui, Icon::FillDown, "", "Fill", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"], - ) { - keytip(app, ui, sequence, fill.rect.center_bottom()); - } - egui::Popup::menu(&fill).show(|ui| { - menu_items( - app, - ui, - &[ - ("Down", "edit.fillDown", json!({})), - ("Right", "edit.fillRight", json!({})), - ("Up", "edit.fillUp", json!({})), - ("Left", "edit.fillLeft", json!({})), - ("Series…", "dialog:series", json!({})), - ("Flash Fill", "edit.flashFill", json!({})), - ], - ); - }); - let clear = small_button(ui, Icon::Eraser, "", "Clear", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"], - ) { - keytip(app, ui, sequence, clear.rect.center_bottom()); - } - egui::Popup::menu(&clear).show(|ui| { - menu_items( - app, - ui, - &[ - ("Clear All", "edit.clearAll", json!({})), - ("Clear Formats", "edit.clearFormats", json!({})), - ("Clear Contents", "edit.clearContents", json!({})), - ("Clear Comments and Notes", "edit.clearComments", json!({})), - ("Clear Hyperlinks", "edit.clearHyperlinks", json!({})), - ], - ); - }); - }); - let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"], - ) { - keytip(app, ui, sequence, sf.rect.center_bottom()); - } - egui::Popup::menu(&sf).show(|ui| { - menu_items( - app, - ui, - &[ - ("Sort A to Z", "data.sortAscending", json!({})), - ("Sort Z to A", "data.sortDescending", json!({})), - ("Custom Sort…", "dialog:sort", json!({})), - ("-", "", json!(null)), - ("Filter", "data.filter", json!({})), - ("Clear", "data.clearFilter", json!({})), - ("Reapply", "data.reapply", json!({})), - ], - ); - }); - let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"], - ) { - keytip(app, ui, sequence, fs.rect.center_bottom()); - } - egui::Popup::menu(&fs).show(|ui| { - menu_items( - app, - ui, - &[ - ("Find…", "dialog:find", json!({})), - ("Replace…", "dialog:find", json!({"replace": true})), - ("Go To…", "dialog:goTo", json!({})), - ("Go To Special…", "dialog:goToSpecial", json!({})), - ("-", "", json!(null)), - ("Formulas", "edit.goToSpecial", json!({"kind": "formulas"})), - ("Notes", "edit.goToSpecial", json!({"kind": "notes"})), - ("Conditional Formatting", "edit.goToSpecial", json!({"kind": "conditionalFormats"})), - ("Constants", "edit.goToSpecial", json!({"kind": "constants"})), - ("Data Validation", "edit.goToSpecial", json!({"kind": "dataValidation"})), - ], - ); - }); -} - -/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert -/// Function dialog. -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)))); - control_keytip(app, ui, &resp, "home.fontName"); - let forced = invoke(app, "home.fontName"); - let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect(); - if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) { - act(app, "home.fontName", json!({"name": value})); - } -} - -pub const FONTS: &[&str] = &[ - "Aptos", - "Aptos Narrow", - "Arial", - "Calibri", - "Cambria", - "Candara", - "Consolas", - "Courier New", - "Garamond", - "Georgia", - "Helvetica", - "Helvetica Neue", - "Segoe UI", - "Tahoma", - "Times New Roman", - "Trebuchet MS", - "Verdana", -]; - -/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses -/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes. -fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let t = Tokens::get(ui.ctx()); - let size = st.font.size; - let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - let field = egui::Id::unique("gridcraft.font_size_box"); - let draft = field.with("draft"); - let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); - // One frame like the Font name box beside it (same fill, border, height and corners), with the - // text field and the dropdown arrow inside it, as in Excel. - let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover()); - // Painted once the field's focus and hover are known, but beneath the field and the arrow. - let bg = ui.painter().add(egui::Shape::Noop); - let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom())); - let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); - let mut field_ui = - ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let out = egui::TextEdit::singleline(&mut text) - .id(field) - .font(theme::ui_font(12.5)) - .frame(egui::Frame::NONE) - .desired_width(text_rect.width() - 12.0) - .show(&mut field_ui); - let resp = out.response; - ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size"))); - control_keytip(app, ui, &resp, "home.fontSize"); - if invoke(app, "home.fontSize") { - ui.memory_mut(|m| m.request_focus(field)); - } - if resp.gained_focus() { - // Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip. - let mut state = out.state; - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); - state.store(ui.ctx(), field); - } - if resp.lost_focus() { - ui.data_mut(|d| d.remove::(draft)); - // Consume the Enter so the grid behind does not also move the active cell down. - if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) - && let Some(v) = text.trim().parse::().ok().filter(|v| v.is_finite()) - { - act(app, "home.fontSize", json!({"size": v})); - } - } else if resp.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, text)); - } else { - ui.data_mut(|d| d.remove::(draft)); - } - let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect)); - let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size()); - let visuals = ui.style().visuals.clone(); - let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; - let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; - ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size")); - let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; - let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); - let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); - if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", ¤t, &items) - && let Ok(v) = value.parse::() - { - act(app, "home.fontSize", json!({"size": v})); - } -} - -fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let code = st.num_fmt.as_str().to_string(); - let kind = gridcraft_engine::display::number_format(&code).kind(); - let current = format!("{kind:?}"); - let lang = app.ui.language; - let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); - let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))); - control_keytip(app, ui, &resp, "home.numberFormat"); - let forced = invoke(app, "home.numberFormat"); - let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]; - let items: Vec<(&str, String)> = names - .iter() - .map(|n| { - let code = gridcraft_engine::cmd::format::format_code_for(n); - let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); - (*n, format!("{:<12} {preview}", lang.tr(n))) - }) - .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) - .collect(); - if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", ¤t, &items, forced) { - match value.as_str() { - "__more" => app.open_dialog("formatCells", json!({"tab": "Number"})), - n => act(app, "home.numberFormat", json!({"format": n})), - } - } -} - -fn cf_menu(app: &mut SheetApp, ui: &mut Ui) { - ui.menu_button("Highlight Cells Rules", |ui| { - for (label, op) in [("Greater Than…", "greater"), ("Less Than…", "less"), ("Between…", "between"), ("Equal To…", "equal")] { - if ui.button(label).clicked() { - app.open_dialog("cfQuick", json!({"type": "cellIs", "operator": op, "title": label})); - } - } - if ui.button("Text that Contains…").clicked() { - app.open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); - } - if ui.button("A Date Occurring…").clicked() { - app.open_dialog("cfQuick", json!({"type": "timePeriod", "title": "A Date Occurring"})); - } - if ui.button("Duplicate Values…").clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "duplicate"}})); - } - }); - ui.menu_button("Top/Bottom Rules", |ui| { - menu_items( - app, - ui, - &[ - ("Top 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10}})), - ("Top 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "percent": true}})), - ("Bottom 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true}})), - ("Bottom 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true, "percent": true}})), - ("Above Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage"}})), - ("Below Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage", "below": true}})), - ], - ); - }); - ui.menu_button("Data Bars", |ui| { - for (label, c) in - [("Blue", "#638EC6"), ("Green", "#63BE7B"), ("Red", "#F8696B"), ("Orange", "#FFB628"), ("Light Blue", "#008AEF"), ("Purple", "#D6007B")] - { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "dataBar", "color": c}})); - } - } - }); - ui.menu_button("Color Scales", |ui| { - for (label, cols) in [ - ("Green - Yellow - Red", vec!["#63BE7B", "#FFEB84", "#F8696B"]), - ("Red - Yellow - Green", vec!["#F8696B", "#FFEB84", "#63BE7B"]), - ("Green - White - Red", vec!["#63BE7B", "#FCFCFF", "#F8696B"]), - ("Blue - White - Red", vec!["#5A8AC6", "#FCFCFF", "#F8696B"]), - ("White - Green", vec!["#FCFCFF", "#63BE7B"]), - ("White - Red", vec!["#FCFCFF", "#F8696B"]), - ] { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "colorScale", "colors": cols}})); - } - } - }); - ui.menu_button("Icon Sets", |ui| { - for (label, set) in [ - ("3 Arrows", "3Arrows"), - ("3 Traffic Lights", "3TrafficLights1"), - ("3 Symbols", "3Symbols"), - ("4 Ratings", "4Rating"), - ("5 Arrows", "5Arrows"), - ("5 Quarters", "5Quarters"), - ] { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "iconSet", "set": set}})); - } - } - }); - ui.separator(); - if ui.button("New Rule…").clicked() { - app.open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); - } - ui.menu_button("Clear Rules", |ui| { - menu_items( - app, - ui, - &[ - ("Clear Rules from Selected Cells", "home.clearRules", json!({})), - ("Clear Rules from Entire Sheet", "home.clearRules", json!({"sheet": true})), - ], - ); - }); - if ui.button("Manage Rules…").clicked() { - app.open_dialog("manageRules", json!({})); - } -} - -/// A gallery of table styles drawn as mini tables. -fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) { - ui.set_width(430.0); - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - for (fam, n) in [("Light", 21u32), ("Medium", 28), ("Dark", 11)] { - ui.label(egui::RichText::new(fam).strong()); - egui::Grid::new(("tg", fam)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for i in 1..=n { - let name = format!("TableStyle{fam}{i}"); - let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click()); - paint_table_swatch(ui, r, &wb, &name); - if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); - } - if resp.on_hover_text(&name).clicked() { - act(app, cmd, json!({"style": name})); - ui.close(); - } - if i % 7 == 0 { - ui.end_row(); - } - } - }); - } -} - -fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, style: &str) { - let lk = gridcraft_engine::tables::look(style); - let res = |c: Option| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32); - let p = ui.painter(); - let t = Tokens::get(ui.ctx()); - p.rect_filled(r, 0.0, t.grid_bg); - let rows = 5; - let rh = r.height() / rows as f32; - for i in 0..rows { - let rr = Rect::from_min_size(pos2(r.left(), r.top() + i as f32 * rh), vec2(r.width(), rh)); - let fill = if i == 0 { - res(lk.header_fill) - } else if i % 2 == 1 { - res(lk.band_fill) - } else { - res(lk.body_fill) - }; - if let Some(f) = fill { - p.rect_filled(rr, 0.0, f); - } - if let Some(l) = res(lk.line) { - p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(1.0, l)); - } - for k in 0..3 { - let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0; - let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border }; - p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col)); - } - } - p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); -} - -fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.set_width(520.0); - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - let groups: &[(&str, &[&str])] = &[ - ("Good, Bad and Neutral", &["Normal", "Bad", "Good", "Neutral"]), - ("Data and Model", &["Calculation", "Check Cell", "Explanatory Text", "Input", "Linked Cell", "Note", "Output", "Warning Text"]), - ("Titles and Headings", &["Heading 1", "Heading 2", "Heading 3", "Heading 4", "Title", "Total"]), - ( - "Themed Cell Styles", - &[ - "20% - Accent1", - "20% - Accent2", - "20% - Accent3", - "20% - Accent4", - "20% - Accent5", - "20% - Accent6", - "40% - Accent1", - "40% - Accent2", - "40% - Accent3", - "40% - Accent4", - "40% - Accent5", - "40% - Accent6", - "60% - Accent1", - "60% - Accent2", - "60% - Accent3", - "60% - Accent4", - "60% - Accent5", - "60% - Accent6", - "Accent1", - "Accent2", - "Accent3", - "Accent4", - "Accent5", - "Accent6", - ], - ), - ("Number Format", &["Comma", "Comma [0]", "Currency", "Currency [0]", "Percent"]), - ]; - for (g, names) in groups { - ui.label(egui::RichText::new(*g).strong()); - egui::Grid::new(("csg", *g)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for (i, n) in names.iter().enumerate() { - let st = gridcraft_engine::cmd::format::builtin_cell_style(n, &wb.theme).unwrap_or_default(); - let (r, resp) = ui.allocate_exact_size(vec2(82.0, 24.0), Sense::click()); - let fill = st - .fill - .fg - .resolve(&wb.theme) - .filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None) - .map(theme::color32) - .unwrap_or(t.grid_bg); - ui.painter().rect_filled(r, 0.0, fill); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - if !st.borders.bottom.is_none() { - let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); - ui.painter().line_segment( - [r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)], - Stroke::new(st.borders.bottom.style.width(), c), - ); - } - let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); - let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); - ui.painter().text( - r.left_center() + vec2(4.0, 0.0), - Align2::LEFT_CENTER, - *n, - egui::FontId::new((st.font.size).clamp(10.0, 15.0), fam), - col, - ); - if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); - } - if resp.clicked() { - act(app, "home.cellStyle", json!({"name": n})); - ui.close(); - } - if (i + 1) % 6 == 0 { - ui.end_row(); - } - } - }); - } -} - -/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. -fn insert(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); - control_keytip(app, ui, &__k, "insert.pivotTable"); - if __k.clicked() { - act(app, "insert.pivotTable", json!({})); - } - let __k = big_button(ui, Icon::Table, "Table", "Table", false); - control_keytip(app, ui, &__k, "insert.table"); - if __k.clicked() { - act(app, "insert.table", json!({})); - } - sep(ui); - let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); - anchor_keytip(app, ui, &pic, &["insert.picture"]); - pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures")); - egui::Popup::menu(&pic).show(|ui| { - for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { - if ui.button(label).clicked() { - app.open_dialog("insertPicture", json!({"placement": placement})); - ui.close(); - } - } - }); - let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); - anchor_keytip(app, ui, &shapes, &["insert.shape"]); - egui::Popup::menu(&shapes).show(|ui| { - for (label, kind) in [ - ("Rectangle", "rectangle"), - ("Rounded Rectangle", "roundedRectangle"), - ("Oval", "ellipse"), - ("Triangle", "triangle"), - ("Line", "line"), - ("Arrow", "arrow"), - ("Text Box", "textBox"), - ] { - if ui.button(label).clicked() { - act(app, "insert.shape", json!({"kind": kind})); - } - } - }); - let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true); - anchor_keytip(app, ui, &icn, &["insert.icons"]); - egui::Popup::menu(&icn).show(|ui| { - ui.set_width(260.0); - egui::Grid::new("icon_lib").show(ui, |ui| { - for (i, (name, icon)) in icons::LIBRARY.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, Tokens::get(ui.ctx()).hover); - } - icons::paint(ui.painter(), r.shrink(6.0), *icon, Tokens::get(ui.ctx()).text); - if resp.on_hover_text(*name).clicked() { - act(app, "insert.icons", json!({"name": name})); - ui.close(); - } - if (i + 1) % 6 == 0 { - ui.end_row(); - } - } - }); - }); - sep(ui); - let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); - control_keytip(app, ui, &__k, "insert.recommendedCharts"); - if __k.clicked() { - // Menu-style: with no chart type, run_or_alert opens the Insert Chart dialog. - act(app, "insert.recommendedCharts", json!({})); - } - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, tip, cid, subs) in [ - ( - Icon::ChartBar, - "Column or Bar", - "insert.chart.column", - vec![ - ("Clustered Column", "column", ""), - ("Stacked Column", "column", "stacked"), - ("100% Stacked Column", "column", "stacked100"), - ("Clustered Bar", "bar", ""), - ("Stacked Bar", "bar", "stacked"), - ("100% Stacked Bar", "bar", "stacked100"), - ], - ), - ( - Icon::ChartLine, - "Line or Area", - "insert.chart.line", - vec![ - ("Line", "line", ""), - ("Line with Markers", "line", "markers"), - ("Stacked Line", "line", "stacked"), - ("Area", "area", ""), - ("Stacked Area", "area", "stacked"), - ("100% Stacked Area", "area", "stacked100"), - ], - ), - (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), - ] { - let r = small_button(ui, icon, "", tip, 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| { - for (label, k, s) in &subs { - if ui.button(*label).clicked() { - act(app, "insert.chart", json!({"type": k, "subtype": s})); - } - } - }); - } - }); - ui.horizontal(|ui| { - for (icon, tip, cid, subs) in [ - ( - Icon::ChartScatter, - "Scatter or Bubble", - "insert.chart.scatter", - vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")], - ), - (Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), - ( - Icon::Theme, - "Hierarchy & more", - "insert.chart.hierarchy", - vec![ - ("Treemap", "treemap", ""), - ("Sunburst", "sunburst", ""), - ("Waterfall", "waterfall", ""), - ("Funnel", "funnel", ""), - ("Radar", "radar", ""), - ("Stock", "stock", ""), - ("Combo", "combo", ""), - ], - ), - ] { - let r = small_button(ui, icon, "", tip, 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| { - for (label, k, s) in &subs { - if ui.button(*label).clicked() { - act(app, "insert.chart", json!({"type": k, "subtype": s})); - } - } - }); - } - }); - }); - sep(ui); - if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { - act(app, "insert.slicer", json!({})); - } - sep(ui); - let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); - anchor_keytip(app, ui, &sp, &["dialog:sparkline"]); - egui::Popup::menu(&sp).show(|ui| { - for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { - if ui.button(label).clicked() { - app.open_dialog("sparkline", json!({"type": kind})); - } - } - }); - sep(ui); - // Cell Controls. - if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { - act(app, "insert.checkbox", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); - control_keytip(app, ui, &__k, "dialog:insertLink"); - if __k.clicked() { - app.open_dialog("insertLink", json!({})); - } - let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false); - control_keytip(app, ui, &__k, "dialog:comment"); - if __k.clicked() { - app.open_dialog("comment", json!({"threaded": true})); - } - sep(ui); - let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false); - control_keytip(app, ui, &__k, "insert.textBox"); - if __k.clicked() { - act(app, "insert.textBox", json!({})); - } - let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); - control_keytip(app, ui, &__k, "dialog:headerFooter"); - if __k.clicked() { - // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). - crate::paper_view::start_editing(app); - } - let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); - control_keytip(app, ui, &sym, "insert.symbol"); - egui::Popup::menu(&sym).show(|ui| { - ui.set_width(260.0); - ui.horizontal_wrapped(|ui| { - for ch in [ - "€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓", - "✓", "★", "½", "¼", - ] { - // No bundled face has ✓: the button paints a code-drawn check instead of a tofu box. - if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() { - act(app, "insert.symbol", json!({"char": ch})); - } - } - }); - }); -} - -fn draw(app: &mut SheetApp, ui: &mut Ui) { - let tool = app.session.draw_tool.clone(); - let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false); - control_keytip(app, ui, &__k, "draw.lasso"); - if __k.clicked() { - act(app, "draw.lasso", json!({})); - } - sep(ui); - for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] { - let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color); - let r = big_button(ui, Icon::Pen, label, "Draw with ink", false); - if on { - ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if r.clicked() { - let width = if label == "Highlighter" { 12.0 } else { 2.0 }; - act(app, "draw.pen", json!({"on": !on, "color": color, "width": width})); - } - } - let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false); - if tool == "eraser" { - ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if er.clicked() { - act(app, "draw.eraser", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false); - control_keytip(app, ui, &__k, "draw.inkToShape"); - if __k.clicked() { - act(app, "draw.inkToShape", json!({})); - } -} - -fn page_layout(app: &mut SheetApp, ui: &mut Ui) { - let th = big_button(ui, Icon::Theme, "Themes", "Themes", true); - anchor_keytip(app, ui, &th, &["pageLayout.theme"]); - egui::Popup::menu(&th).show(|ui| { - for (name, colors) in gridcraft_engine::cmd::view::themes() { - ui.horizontal(|ui| { - for c in colors.iter().skip(4).take(6) { - let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); - ui.painter().rect_filled(r, 1.0, Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, *c as u8)); - } - if ui.button(name).clicked() { - act(app, "pageLayout.theme", json!({"name": name})); - } - }); - } - }); - sep(ui); - let m = big_button(ui, Icon::Margins, "Margins", "Margins", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) { - keytip(app, ui, sequence, m.rect.center_bottom()); - } - egui::Popup::menu(&m).show(|ui| { - menu_items( - app, - ui, - &[ - ("Normal", "pageLayout.margins", json!({"preset": "normal"})), - ("Wide", "pageLayout.margins", json!({"preset": "wide"})), - ("Narrow", "pageLayout.margins", json!({"preset": "narrow"})), - ("Custom Margins…", "dialog:pageSetup", json!({})), - ], - ); - }); - let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); - control_keytip(app, ui, &o, "pageLayout.orientation"); - egui::Popup::menu(&o).show(|ui| { - menu_items( - app, - ui, - &[ - ("Portrait", "pageLayout.orientation", json!({"orientation": "portrait"})), - ("Landscape", "pageLayout.orientation", json!({"orientation": "landscape"})), - ], - ); - }); - let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); - control_keytip(app, ui, &s, "pageLayout.size"); - egui::Popup::menu(&s).show(|ui| { - // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. - for p in [ - "Letter", - "Legal", - "Tabloid", - "Executive", - "A3", - "A4", - "A5", - "A6", - "B4", - "B5", - "B6", - "Japanese Postcard", - "Japanese Double Postcard", - "Japanese Envelope #3", - "Japanese Envelope #4", - "Japanese Envelope Kaku #2", - "Japanese Envelope Kaku #3", - "B4 (ISO)", - "B5 (ISO)", - "B6 (ISO)", - ] { - if ui.button(p).clicked() { - act(app, "pageLayout.size", json!({"paper": p})); - } - } - }); - let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true); - anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]); - egui::Popup::menu(&pa).show(|ui| { - menu_items( - app, - ui, - &[("Set Print Area", "pageLayout.printArea", json!({})), ("Clear Print Area", "pageLayout.printArea", json!({"clear": true}))], - ); - }); - let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) { - keytip(app, ui, sequence, br.rect.center_bottom()); - } - egui::Popup::menu(&br).show(|ui| { - menu_items( - app, - ui, - &[ - ("Insert Page Break", "pageLayout.breaks", json!({"insert": true})), - ("Remove Page Break", "pageLayout.breaks", json!({"remove": true})), - ("Reset All Page Breaks", "pageLayout.breaks", json!({"reset": true})), - ], - ); - }); - let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false); - control_keytip(app, ui, &__k, "pageLayout.printTitles"); - if __k.clicked() { - app.open_dialog("pageSetup", json!({"tab": "Sheet"})); - } - sep(ui); - let sh = app - .session - .active() - .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) - .unwrap_or((true, true, false, false)); - // These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in - // Excel), but the key is distinct from the "View" tab so languages can word them apart. - let screen = app.ui.language.tr("Sheet Options|View"); - let print = app.ui.language.tr("Print"); - ui.vertical(|ui| { - ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); - let mut v = sh.0; - let resp = ui.checkbox(&mut v, screen); - control_keytip(app, ui, &resp, "view.gridlines"); - if resp.changed() || invoke(app, "view.gridlines") { - act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)})); - } - let mut p = sh.2; - let resp = ui.checkbox(&mut p, print); - control_keytip(app, ui, &resp, "pageLayout.printGridlines"); - if resp.changed() || invoke(app, "pageLayout.printGridlines") { - act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)})); - } - }); - ui.vertical(|ui| { - ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); - let mut v = sh.1; - let resp = ui.checkbox(&mut v, screen); - control_keytip(app, ui, &resp, "view.headings"); - if resp.changed() || invoke(app, "view.headings") { - act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)})); - } - let mut p = sh.3; - let resp = ui.checkbox(&mut p, print); - control_keytip(app, ui, &resp, "pageLayout.printHeadings"); - if resp.changed() || invoke(app, "pageLayout.printHeadings") { - act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)})); - } - }); - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false); - if rtl { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - } - if r.clicked() { - act(app, "view.rightToLeft", json!({"on": !rtl})); - } -} - -/// 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. -fn formulas(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); - control_keytip(app, ui, &__k, "dialog:insertFunction"); - 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() { - 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(); - } - } - } - }); - }); - } - sep(ui); - let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false); - control_keytip(app, ui, &__k, "dialog:nameManager"); - if __k.clicked() { - app.open_dialog("nameManager", json!({})); - } - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false); - control_keytip(app, ui, &__k, "dialog:defineName"); - if __k.clicked() { - app.open_dialog("defineName", json!({})); - } - let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); - anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]); - egui::Popup::menu(&use_in).show(|ui| { - // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. - // Listed when the menu opens, and again only after the workbook changes. - let cache = egui::Id::unique("gridcraft.use_in_formula_names"); - let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet())); - let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc>)>(cache)).filter(|(k, _)| *k == key); - let names = match cached { - Some((_, names)) => names, - None => { - let names: std::sync::Arc> = std::sync::Arc::new( - app.session - .active() - .map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect()) - .unwrap_or_default(), - ); - ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone()))); - names - } - }; - for n in names.iter() { - if ui.button(n).clicked() { - app.run_or_alert("formulas.useInFormula", json!({"name": n})); - } - } - if !names.is_empty() { - ui.separator(); - } - if ui.button(app.ui.language.tr("Paste Names…")).clicked() { - app.open_dialog("pasteName", json!({})); - } - }); - let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); - control_keytip(app, ui, &__k, "formulas.createFromSelection"); - if __k.clicked() { - act(app, "formulas.createFromSelection", json!({})); - } - }); - sep(ui); - ui.vertical(|ui| { - if small_button(ui, Icon::Trace, "Trace Precedents", "Trace Precedents", false).clicked() - && let Ok(r) = app.run("formulas.tracePrecedents", json!({})) - { - app.grid.trace = crate::grid::Trace::new(app, r); - } - if small_button(ui, Icon::Trace, "Trace Dependents", "Trace Dependents", false).clicked() - && let Ok(r) = app.run("formulas.traceDependents", json!({})) - { - app.grid.trace = crate::grid::Trace::new(app, r); - } - if small_button(ui, Icon::Close, "Remove Arrows", "Remove Arrows", false).clicked() { - app.grid.trace = None; - } - }); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false); - control_keytip(app, ui, &__k, "formulas.showFormulas"); - if __k.clicked() { - act(app, "formulas.showFormulas", json!({})); - } - let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking"); - control_keytip(app, ui, &ec, "dialog:errorChecking"); - if ec.clicked() { - app.open_dialog("errorChecking", json!({})); - } - egui::Popup::menu(&ec_arrow).show(|ui| { - let lang = app.ui.language; - if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { - app.open_dialog("errorChecking", json!({})); - } - let circular = circular_references(app); - ui.add_enabled_ui(!circular.is_empty(), |ui| { - ui.menu_button(lang.tr("Circular References"), |ui| { - egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| { - for (sheet, cell, label) in circular { - if ui.button(label).clicked() { - act(app, "selection.set", json!({"range": cell, "sheet": sheet})); - app.grid.ensure_visible = true; - } - } - }); - }); - }); - }); - let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); - control_keytip(app, ui, &__k, "dialog:evaluateFormula"); - if __k.clicked() { - app.open_dialog("evaluateFormula", json!({})); - } - }); - if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { - app.grid.pane = Some("watch".into()); - } - sep(ui); - let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) { - keytip(app, ui, sequence, co.rect.center_bottom()); - } - egui::Popup::menu(&co).show(|ui| { - menu_items( - app, - ui, - &[ - ("Automatic", "formulas.calculationOptions", json!({"mode": "automatic"})), - ("Automatic Except for Data Tables", "formulas.calculationOptions", json!({"mode": "automaticExceptTables"})), - ("Manual", "formulas.calculationOptions", json!({"mode": "manual"})), - ], - ); - }); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false); - control_keytip(app, ui, &__k, "formulas.calculateNow"); - if __k.clicked() { - act(app, "formulas.calculateNow", json!({})); - } - let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false); - control_keytip(app, ui, &__k, "formulas.calculateSheet"); - if __k.clicked() { - act(app, "formulas.calculateSheet", json!({})); - } - }); -} - -fn data(app: &mut SheetApp, ui: &mut Ui) { - let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); - control_keytip(app, ui, &gd, "data.getData"); - if gd.clicked() { - app.open_dialog("open", json!({})); - } - sep(ui); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); - control_keytip(app, ui, &__k, "data.sortAscending"); - if __k.clicked() { - act(app, "data.sortAscending", json!({})); - } - let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false); - control_keytip(app, ui, &__k, "data.sortDescending"); - if __k.clicked() { - act(app, "data.sortDescending", json!({})); - } - }); - let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false); - control_keytip(app, ui, &__k, "dialog:sort"); - if __k.clicked() { - app.open_dialog("sort", json!({})); - } - let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); - control_keytip(app, ui, &__k, "data.filter"); - if __k.clicked() { - act(app, "data.filter", json!({})); - } - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false); - control_keytip(app, ui, &__k, "data.clearFilter"); - if __k.clicked() { - act(app, "data.clearFilter", json!({})); - } - let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false); - control_keytip(app, ui, &__k, "data.reapply"); - if __k.clicked() { - act(app, "data.reapply", json!({})); - } - let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false); - control_keytip(app, ui, &__k, "dialog:advancedFilter"); - if __k.clicked() { - app.open_dialog("advancedFilter", json!({})); - } - }); - sep(ui); - let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); - control_keytip(app, ui, &__k, "dialog:textToColumns"); - if __k.clicked() { - app.open_dialog("textToColumns", json!({})); - } - let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); - control_keytip(app, ui, &__k, "edit.flashFill"); - if __k.clicked() { - act(app, "edit.flashFill", json!({})); - } - let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); - control_keytip(app, ui, &__k, "dialog:removeDuplicates"); - if __k.clicked() { - app.open_dialog("removeDuplicates", json!({})); - } - let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) { - keytip(app, ui, sequence, dv.rect.center_bottom()); - } - egui::Popup::menu(&dv).show(|ui| { - menu_items( - app, - ui, - &[ - ("Data Validation…", "dialog:dataValidation", json!({})), - ("Circle Invalid Data", "data.circleInvalid", json!({})), - ("Clear Validation", "data.validation", json!({"clear": true})), - ], - ); - }); - sep(ui); - let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"]) - { - keytip(app, ui, sequence, wi.rect.center_bottom()); - } - egui::Popup::menu(&wi).show(|ui| { - menu_items( - app, - ui, - &[ - ("Goal Seek…", "dialog:goalSeek", json!({})), - ("Scenario Manager…", "data.scenarioManager", json!({})), - ("Data Table…", "data.dataTable", json!({})), - ], - ); - }); - if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { - app.open_dialog("forecastSheet", json!({})); - } - sep(ui); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Group, "Group", "Group", false); - control_keytip(app, ui, &__k, "data.group"); - if __k.clicked() { - act(app, "data.group", json!({})); - } - let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false); - control_keytip(app, ui, &__k, "data.ungroup"); - if __k.clicked() { - act(app, "data.ungroup", json!({})); - } - let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false); - control_keytip(app, ui, &__k, "dialog:subtotal"); - if __k.clicked() { - app.open_dialog("subtotal", json!({})); - } - }); - ui.vertical(|ui| { - if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() { - act(app, "data.showDetail", json!({})); - } - if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() { - act(app, "data.hideDetail", json!({})); - } - if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() { - app.open_dialog("outlineSettings", json!({})); - } - }); - sep(ui); - if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() { - app.open_dialog("analysisToolPak", json!({})); - } - sep(ui); - if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { - app.open_dialog("solver", json!({})); - } -} - -fn review(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false); - control_keytip(app, ui, &__k, "dialog:spelling"); - if __k.clicked() { - app.open_dialog("spelling", json!({})); - } - let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false); - control_keytip(app, ui, &__k, "statistics"); - if __k.clicked() { - app.open_dialog("statistics", json!({})); - } - let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false); - control_keytip(app, ui, &__k, "dialog:accessibility"); - if __k.clicked() { - app.open_dialog("accessibility", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false); - control_keytip(app, ui, &__k, "dialog:comment"); - if __k.clicked() { - app.open_dialog("comment", json!({"threaded": true})); - } - let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); - control_keytip(app, ui, &__k, "review.deleteComment"); - if __k.clicked() { - act(app, "review.deleteComment", json!({})); - } - ui.vertical(|ui| { - for (icon, label, tip, id) in - [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] - { - if small_button(ui, icon, label, tip, false).clicked() { - act(app, id, json!({})); - app.grid.ensure_visible = true; - } - } - }); - let shown = app.session.comments_pane; - let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); - if shown { - ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if r.clicked() { - act(app, "review.showComments", json!({"on": !shown})); - if app.session.comments_pane { - app.grid.pane = None; - } - } - let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); - anchor_keytip(app, ui, ¬es, &["review.nextComment"]); - egui::Popup::menu(¬es).show(|ui| { - menu_items( - app, - ui, - &[ - ("New Note", "dialog:comment", json!({"threaded": false})), - ("Show/Hide Note", "review.showNote", json!({})), - ("Show All Notes", "review.showAllNotes", json!({})), - ("-", "", json!({})), - ("Convert to Comments", "review.convertToComments", json!({})), - ], - ); - }); - sep(ui); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); - let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); - control_keytip(app, ui, &__k, "dialog:protectSheet"); - if __k.clicked() { - app.open_dialog("protectSheet", json!({})); - } - let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); - control_keytip(app, ui, &__k, "review.protectWorkbook"); - if __k.clicked() { - crate::backstage::protect_workbook(app); - } - if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { - app.open_dialog("allowEditRanges", json!({})); - } -} - -/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language. -fn view(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let _ = t; - for (icon, label, cmd) in [ - (Icon::Normal, "Normal", "view.normal"), - (Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"), - (Icon::PageLayout, "Page\nLayout", "view.pageLayout"), - ] { - let __k = big_button(ui, icon, label, label, false); - control_keytip(app, ui, &__k, cmd); - if __k.clicked() { - act(app, cmd, json!({})); - } - } - sep(ui); - let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); - let lang = app.ui.language; - let page_layout = - app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); - // Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body. - ui.vertical(|ui| { - // Rulers exist only in Page Layout view. - let mut ruler = !app.session.hide_ruler; - let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))); - control_keytip(app, ui, &ruler_resp, "view.ruler"); - if ruler_resp.changed() { - act(app, "view.ruler", json!({"on": ruler})); - } - let mut g = s.0; - let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); - control_keytip(app, ui, &resp, "view.gridlines"); - if resp.changed() { - act(app, "view.gridlines", json!({"on": g})); - } - }); - ui.add_space(6.0); - ui.vertical(|ui| { - let mut fb = app.session.prefs.formula_bar; - let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); - control_keytip(app, ui, &resp, "view.formulaBar"); - if resp.changed() || invoke(app, "view.formulaBar") { - act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)})); - } - let mut h = s.1; - let resp = ui.checkbox(&mut h, lang.tr("Headings")); - control_keytip(app, ui, &resp, "view.headings"); - if resp.changed() { - act(app, "view.headings", json!({"on": h})); - } - }); - sep(ui); - let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false); - control_keytip(app, ui, &__k, "dialog:zoom"); - if __k.clicked() { - app.open_dialog("zoom", json!({})); - } - let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false); - control_keytip(app, ui, &__k, "view.zoom"); - if __k.clicked() || invoke(app, "view.zoom") { - act(app, "view.zoom", json!({"percent": 100})); - } - let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false); - control_keytip(app, ui, &__k, "view.zoomToSelection"); - if __k.clicked() { - let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0))); - act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); - } - sep(ui); - let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); - control_keytip(app, ui, &__k, "view.newWindow"); - if __k.clicked() { - act(app, "view.newWindow", json!({})); - } - let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); - control_keytip(app, ui, &__k, "view.arrangeAll"); - if __k.clicked() { - app.open_dialog("arrangeAll", json!({})); - } - let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) - { - keytip(app, ui, sequence, fp.rect.center_bottom()); - } - egui::Popup::menu(&fp).show(|ui| { - menu_items( - app, - ui, - &[ - ("Freeze Panes", "view.freezePanes", json!({})), - ("Freeze Top Row", "view.freezeTopRow", json!({})), - ("Freeze First Column", "view.freezeFirstColumn", json!({})), - ("Unfreeze Panes", "view.unfreezePanes", json!({})), - ], - ); - }); - switch_windows(app, ui); - ui.vertical(|ui| { - let label_response = ui.label(lang.tr("Display theme")); - let mode = app.ui.theme_mode(); - let label = match mode { - "system" => "System", - "dark" => "Dark", - _ => "Light", - }; - egui::ComboBox::from_id_salt("display_theme") - .selected_text(lang.tr(label)) - .width(88.0) - .show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - act(app, "view.theme", json!({"mode": value})); - } - } - }) - .response - .labelled_by(label_response.id); - let mut animations = app.ui.animations; - let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); - if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { - act(app, "view.animations", json!({"on": animations})); - } - }); - sep(ui); - // Interface size (issue #230): scales the whole window on top of the desktop's scale. - ui.vertical(|ui| { - let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); - let current = app.ui.interface_scale; - let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; - egui::ComboBox::from_id_salt("ui_interface_size") - .width(120.0) - .selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5))) - .show_ui(ui, |ui| { - for v in [100, 75, 90, 110, 125, 150, 175, 200] { - if ui.selectable_label(v == current, text(v)).clicked() { - act(app, "view.interfaceScale", json!({"percent": v})); - } - } - }) - .response - .labelled_by(label.id); - }); - // Interface language: settings live in the View tab, like Excel's Options. The label follows - // the current language so it stays readable; the choices are always shown in their own script. - ui.vertical(|ui| { - let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); - egui::ComboBox::from_id_salt("ui_language") - .width(120.0) - .selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))) - .show_ui(ui, |ui| { - for l in crate::i18n::Language::ALL { - if ui.selectable_label(l == lang, l.name()).clicked() { - act(app, "app.language.set", json!({"language": l.code()})); - } - } - }) - .response - .labelled_by(label.id); - }); -} - -/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; -/// choosing one brings it forward. -fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { - let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); - anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); - control_keytip(app, ui, &anchor, "view.switchWindows"); - let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); - egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { - for w in app.open_windows() { - let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); - if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { - act(app, "view.switchWindows", json!({"window": w.window})); - ui.close(); - } - } - }); -} - -fn automate(app: &mut SheetApp, ui: &mut Ui) { - if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { - app.open_dialog("macros", json!({})); - } - let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); - control_keytip(app, ui, &__k, "commandSearch"); - if __k.clicked() { - app.open_dialog("commandSearch", json!({})); - } - let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false); - control_keytip(app, ui, &__k, "agents"); - if __k.clicked() { - app.open_dialog("agents", json!({})); - } - let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false); - control_keytip(app, ui, &__k, "journal"); - if __k.clicked() { - app.open_dialog("journal", json!({})); - } - let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false); - control_keytip(app, ui, &__k, "checkForUpdates"); - if __k.clicked() { - app.run_or_alert("app.checkForUpdates", json!({})); - } - let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); - control_keytip(app, ui, &__k, "about"); - if __k.clicked() { - app.open_dialog("about", json!({})); - } -} - -/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip. -fn table_design(app: &mut SheetApp, ui: &mut Ui) { - let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { - let sh = d.wb.active()?; - let t = sh.table_at(d.selection.active)?; - Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone())) - }) else { - return; - }; - ui.vertical(|ui| { - ui.label(egui::RichText::new("Table Name:").small()); - let field = egui::Id::unique(("gridcraft.table_name", &tname)); - if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) - && name != tname - { - act(app, "table.rename", json!({"table": tname, "name": name})); - } - }); - let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false); - control_keytip(app, ui, &__k, "dialog:tableResize"); - if __k.clicked() { - app.open_dialog("tableResize", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); - control_keytip(app, ui, &__k, "data.removeDuplicates"); - if __k.clicked() { - act(app, "data.removeDuplicates", json!({})); - } - let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false); - control_keytip(app, ui, &__k, "table.convertToRange"); - if __k.clicked() { - act(app, "table.convertToRange", json!({"table": tname})); - } - sep(ui); - if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { - act(app, "table.insertSlicer", json!({"table": tname})); - } - sep(ui); - ui.vertical(|ui| { - for (label, cmd, v) in [ - ("Header Row", "table.headerRow", header && filter), - ("Total Row", "table.totalRow", totals), - ("Banded Rows", "table.bandedRows", banded), - ] { - let mut on = v; - if ui.checkbox(&mut on, label).changed() { - act(app, cmd, json!({"table": tname, "on": on})); - } - } - }); - ui.vertical(|ui| { - for (label, cmd, v) in [ - ("First Column", "table.firstColumn", first), - ("Last Column", "table.lastColumn", last), - ("Banded Columns", "table.bandedColumns", banded_c), - ] { - let mut on = v; - if ui.checkbox(&mut on, label).changed() { - act(app, cmd, json!({"table": tname, "on": on})); - } - } - }); - sep(ui); - table_style_strip(app, ui, &tname, &style); -} - -/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the -/// full gallery (Light, Medium, Dark) as a popup. -const RIBBON_TABLE_STYLES: [&str; 7] = - ["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"]; - -fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) { - let t = Tokens::get(ui.ctx()); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - // Borrow the workbook only while painting; the session must be free for `act` afterwards. - let clicked = { - let Some(d) = app.session.active() else { return }; - let wb = &d.wb; - let mut clicked = None; - for name in RIBBON_TABLE_STYLES { - let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click()); - paint_table_swatch(ui, r, wb, name); - if name == current { - ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); - } - if resp.on_hover_text(name).clicked() { - clicked = Some(name); - } - } - clicked - }; - if let Some(name) = clicked { - act(app, "table.style", json!({"table": tname, "style": name})); - } - let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0)); - anchor_keytip(app, ui, &more, &["table.style"]); - egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style")); - }); -} - -fn chart_design(app: &mut SheetApp, ui: &mut Ui) { - let Some(id) = app.selected_chart else { return }; - let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { - keytip(app, ui, sequence, ae.rect.center_bottom()); - } - egui::Popup::menu(&ae).show(|ui| { - menu_items( - app, - ui, - &[ - ("Legend: Bottom", "chart.set", json!({"chart": id, "legend": "bottom"})), - ("Legend: Right", "chart.set", json!({"chart": id, "legend": "right"})), - ("Legend: Top", "chart.set", json!({"chart": id, "legend": "top"})), - ("Legend: None", "chart.set", json!({"chart": id, "legend": "none"})), - ("-", "", json!(null)), - ("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})), - ("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})), - ("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})), - ("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})), - ], - ); - }); - let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false); - control_keytip(app, ui, &__k, "chart.switchRowColumn"); - if __k.clicked() { - act(app, "chart.switchRowColumn", json!({"chart": id})); - } - if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { - app.open_dialog("selectData", json!({"chart": id})); - } - let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"], - ) { - keytip(app, ui, sequence, ct.rect.center_bottom()); - } - egui::Popup::menu(&ct).show(|ui| { - for (label, k, s) in [ - ("Clustered Column", "column", ""), - ("Stacked Column", "column", "stacked"), - ("Bar", "bar", ""), - ("Line", "line", "markers"), - ("Area", "area", ""), - ("Pie", "pie", ""), - ("Doughnut", "doughnut", ""), - ("Scatter", "scatter", ""), - ("Radar", "radar", ""), - ("Waterfall", "waterfall", ""), - ("Funnel", "funnel", ""), - ("Treemap", "treemap", ""), - ] { - if ui.button(label).clicked() { - act(app, "chart.set", json!({"chart": id, "type": k, "subtype": s})); - } - } - }); - if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() { - app.grid.pane = Some("formatChart".into()); - } - let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false); - control_keytip(app, ui, &__k, "chartTitle"); - if __k.clicked() { - app.open_dialog("chartTitle", json!({"chart": id})); - } - let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false); - control_keytip(app, ui, &__k, "chart.delete"); - if __k.clicked() { - act(app, "chart.delete", json!({"chart": id})); - app.selected_chart = None; - } -} - -/// Command-key shortcuts when the grid has focus. -pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { - let shift = m.shift; - let id: Option<(&str, serde_json::Value)> = match key { - Key::B => Some(("home.bold", json!({}))), - Key::I => Some(("home.italic", json!({}))), - Key::U => Some(("home.underline", json!({}))), - Key::Z if shift => Some(("edit.redo", json!({}))), - Key::Z => Some(("edit.undo", json!({}))), - Key::Y => Some(("edit.redo", json!({}))), - Key::A => Some(("edit.selectAll", json!({}))), - Key::D => Some(("edit.fillDown", json!({}))), - Key::R => Some(("edit.fillRight", json!({}))), - Key::S if shift => Some(("file.saveAs", json!({}))), - Key::S => Some(("file.save", json!({}))), - Key::O => Some(("file.open", json!({}))), - Key::N => Some(("file.new", json!({}))), - Key::W => Some(("file.close", json!({}))), - Key::P => { - app.print_now(json!({})); - None - } - Key::K => { - app.open_dialog("insertLink", json!({})); - None - } - Key::F if shift => Some(("data.filter", json!({}))), - Key::F => { - app.open_dialog("find", json!({})); - None - } - Key::H => { - app.open_dialog("find", json!({"replace": true})); - None - } - Key::G => { - app.open_dialog("goTo", json!({})); - None - } - Key::T if shift => Some(("formulas.autoSum", json!({}))), - Key::T => Some(("insert.table", json!({}))), - Key::E => Some(("edit.flashFill", json!({}))), - Key::Num1 => { - app.open_dialog("formatCells", json!({})); - None - } - Key::Num9 if shift => Some(("home.unhideRows", json!({}))), - Key::Num9 => Some(("home.hideRows", json!({}))), - Key::Num0 if shift => Some(("home.unhideColumns", json!({}))), - Key::Num0 => Some(("home.hideColumns", json!({}))), - // As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or - // delete them at once; any other selection asks which way to shift. - Key::Minus => match whole_lines(app) { - Some(true) => Some(("home.deleteRows", json!({}))), - Some(false) => Some(("home.deleteColumns", json!({}))), - None => { - app.open_dialog("deleteCells", json!({})); - None - } - }, - // Insert works on one area at a time, so several areas still go through the dialog. - Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) { - Some(true) => Some(("home.insertRows", json!({}))), - Some(false) => Some(("home.insertColumns", json!({}))), - None => { - app.open_dialog("insertCells", json!({})); - None - } - }, - Key::Semicolon => { - // Insert today's date. - let text = app - .session - .active() - .map(|d| { - let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text - }) - .unwrap_or_default(); - app.begin_edit(Some(text), false); - None - } - Key::Backtick => Some(("formulas.showFormulas", json!({}))), - _ => None, - }; - if let Some((id, p)) = id { - app.run_or_alert(id, p); - app.grid.ensure_visible = true; - } -} - -#[cfg(test)] -mod title_bar_tests { - use super::*; - - #[test] - fn mac_title_bar_centres_and_clears_the_traffic_lights() { - // The lights are centred in the bar, and content keeps the same gap after them. - let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; - let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; - assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); - } - - #[test] - fn title_fits_between_the_controls() { - // Room limited by the nearer side, symmetric about the centre. - assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); - assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); - // Controls overlapping the centre leave no room rather than a negative width. - assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); - } - - /// Lay out a title in a real egui frame, returning (title text, title width, dot width). - fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { - let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); - let mut out = (String::new(), 0.0, 0.0); - let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { - let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); - // The glyphs actually drawn: `galley.text()` is the untruncated source. - let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); - out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); - }); - frame.textures_delta.clear(); // nothing consumes the atlas in a headless test - out - } - - #[test] - fn long_title_is_truncated_with_an_ellipsis() { - let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); - let (text, w, _) = layout_title(&long, false, 300.0); - assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); - assert!(text.chars().count() < long.chars().count()); - assert!(w <= 300.0, "title stays within its room: {w}"); - } - - #[test] - fn short_title_is_left_whole() { - let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); - assert_eq!(text, "Book1.xlsx"); - } - - #[test] - fn unsaved_dot_survives_truncation() { - let long = "a very long workbook name ".repeat(20); - let (text, w, dot_w) = layout_title(&long, true, 200.0); - assert!(dot_w > 0.0, "the unsaved marker is laid out"); - assert!(text.ends_with('…')); - assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); - } - - #[test] - fn no_room_draws_nothing_but_does_not_crash() { - let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); - assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); - } -} - -#[cfg(test)] -mod checkbox_on_tests { - use super::*; - - #[test] - fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() { - // Click: the checkbox bool already holds the new choice. - assert!(!checkbox_on(true, false)); - assert!(checkbox_on(true, true)); - // Key tip: the bool still holds the current setting, so toggle it. - assert!(checkbox_on(false, false)); - assert!(!checkbox_on(false, true)); - } -} - -/// Whether the selection is a single area. -fn single_area(app: &SheetApp) -> bool { - app.session.active().is_some_and(|d| d.selection.ranges.len() == 1) -} - -/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole -/// columns, `None` otherwise (including the whole sheet). -fn whole_lines(app: &SheetApp) -> Option { - let sel = &app.session.active()?.selection; - let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols()); - let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows()); - if rows { - Some(true) - } else if cols { - Some(false) - } else { - None - } -} - -#[cfg(test)] -mod accounting_symbol_tests { - use super::*; - use gridcraft_engine::core::{DateSystem, Value}; - use gridcraft_engine::numfmt::{NumberFormat, format_value}; - - #[test] - fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() { - // #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the - // `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals. - for (label, tag, shown) in [ - ("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"), - ("₹ English (India)", "[$₹-4009]", "₹1,234.50"), - ("₩ Korean", "[$₩-412]", "₩1,235"), - ("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"), - ("¥ Japanese", "[$¥-411]", "¥1,235"), - ] { - let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c); - let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") }; - assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}"); - let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900); - assert_eq!(out.text.trim(), shown, "{label}"); - let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default(); - assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}"); - } - } -} diff --git a/crates/ui-egui/src/ribbon/automate.rs b/crates/ui-egui/src/ribbon/automate.rs new file mode 100644 index 000000000..f2546658d --- /dev/null +++ b/crates/ui-egui/src/ribbon/automate.rs @@ -0,0 +1,98 @@ +use super::controls::{control_keytip, sep}; +use super::{Icon, SheetApp, Ui, big_button, json}; +use crate::theme::Tokens; +use crate::widgets::{big_split_button, small_button, small_toggle_button}; +use egui::vec2; + +pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) { + // Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative + // References; Run Macro, whose main part runs the last macro and whose menu lists them all. + if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { + app.open_dialog("macros", json!({})); + } + let recording = app.session.is_recording(); + ui.vertical(|ui| { + let (label, tip) = + if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") }; + let r = small_button(ui, Icon::Record, label, tip, false); + control_keytip(app, ui, &r, "macro.record"); + if r.clicked() { + crate::macro_ui::toggle_recording(app); + } + let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data"; + let rel = app.macros.relative; + let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner; + if r.clicked() && !recording { + app.macros.relative = !rel; + } + if small_button(ui, Icon::Macro, "New Macro", "Write a macro step by step", false).clicked() { + app.open_dialog("macroEditor", json!({})); + } + }); + let macros = crate::macro_ui::macro_list(app); + let recent: Vec = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect(); + let (main, arrow) = big_split_button(ui, Icon::Macro, "Run\nMacro", "Run a macro on the selection"); + control_keytip(app, ui, &arrow, "macro.run"); + if main.clicked() { + // The last macro run, or the only one; otherwise choose in the Macros dialog. + match recent.first().or(macros.first().map(|m| &m.0)) { + Some(name) if macros.len() == 1 || !recent.is_empty() => { + let name = name.clone(); + crate::macro_ui::run_macro(app, &name); + } + _ => app.open_dialog("macros", json!({})), + } + } + egui::Popup::menu(&arrow).show(|ui| { + let t = Tokens::get(ui.ctx()); + if !recent.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("Recent")).small().color(t.text_dim)); + for n in &recent { + if ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + } + if macros.is_empty() { + ui.label(egui::RichText::new("No macros in this workbook").italics().color(t.text_dim)); + } + for (n, desc, _) in macros.iter().take(30) { + let r = ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))); + let r = if desc.is_empty() { r } else { r.on_hover_text(desc) }; + if r.clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + if ui.add(egui::Button::new(app.ui.language.tr("Macros…")).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + app.open_dialog("macros", json!({})); + } + }); + sep(ui); + let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { + app.open_dialog("commandSearch", json!({})); + } + let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false); + control_keytip(app, ui, &__k, "agents"); + if __k.clicked() { + app.open_dialog("agents", json!({})); + } + let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false); + control_keytip(app, ui, &__k, "journal"); + if __k.clicked() { + app.open_dialog("journal", json!({})); + } + let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false); + control_keytip(app, ui, &__k, "checkForUpdates"); + if __k.clicked() { + app.run_or_alert("app.checkForUpdates", json!({})); + } + let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); + control_keytip(app, ui, &__k, "about"); + if __k.clicked() { + app.open_dialog("about", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/contextual.rs b/crates/ui-egui/src/ribbon/contextual.rs new file mode 100644 index 000000000..469e76cf0 --- /dev/null +++ b/crates/ui-egui/src/ribbon/contextual.rs @@ -0,0 +1,183 @@ +use super::controls::{act, anchor_keytip, control_keytip, keytip, menu_items, sep}; +use super::galleries::{paint_table_swatch, table_gallery}; +use super::{Color32, Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icon_button, json, keytip_table, vec2}; + +/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip. +pub(super) fn table_design(app: &mut SheetApp, ui: &mut Ui) { + let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { + let sh = d.wb.active()?; + let t = sh.table_at(d.selection.active)?; + Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone())) + }) else { + return; + }; + ui.vertical(|ui| { + ui.label(egui::RichText::new("Table Name:").small()); + let field = egui::Id::unique(("gridcraft.table_name", &tname)); + if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) + && name != tname + { + act(app, "table.rename", json!({"table": tname, "name": name})); + } + }); + let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false); + control_keytip(app, ui, &__k, "dialog:tableResize"); + if __k.clicked() { + app.open_dialog("tableResize", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "data.removeDuplicates"); + if __k.clicked() { + act(app, "data.removeDuplicates", json!({})); + } + let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false); + control_keytip(app, ui, &__k, "table.convertToRange"); + if __k.clicked() { + act(app, "table.convertToRange", json!({"table": tname})); + } + sep(ui); + if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { + act(app, "table.insertSlicer", json!({"table": tname})); + } + sep(ui); + ui.vertical(|ui| { + for (label, cmd, v) in [ + ("Header Row", "table.headerRow", header && filter), + ("Total Row", "table.totalRow", totals), + ("Banded Rows", "table.bandedRows", banded), + ] { + let mut on = v; + if ui.checkbox(&mut on, label).changed() { + act(app, cmd, json!({"table": tname, "on": on})); + } + } + }); + ui.vertical(|ui| { + for (label, cmd, v) in [ + ("First Column", "table.firstColumn", first), + ("Last Column", "table.lastColumn", last), + ("Banded Columns", "table.bandedColumns", banded_c), + ] { + let mut on = v; + if ui.checkbox(&mut on, label).changed() { + act(app, cmd, json!({"table": tname, "on": on})); + } + } + }); + sep(ui); + table_style_strip(app, ui, &tname, &style); +} + +/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the +/// full gallery (Light, Medium, Dark) as a popup. +const RIBBON_TABLE_STYLES: [&str; 7] = + ["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"]; + +fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + // Borrow the workbook only while painting; the session must be free for `act` afterwards. + let clicked = { + let Some(d) = app.session.active() else { return }; + let wb = &d.wb; + let mut clicked = None; + for name in RIBBON_TABLE_STYLES { + let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click()); + paint_table_swatch(ui, r, wb, name); + if name == current { + ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(name).clicked() { + clicked = Some(name); + } + } + clicked + }; + if let Some(name) = clicked { + act(app, "table.style", json!({"table": tname, "style": name})); + } + let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0)); + anchor_keytip(app, ui, &more, &["table.style"]); + egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style")); + }); +} + +pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) { + let Some(id) = app.selected_chart else { return }; + let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { + keytip(app, ui, sequence, ae.rect.center_bottom()); + } + egui::Popup::menu(&ae).show(|ui| { + menu_items( + app, + ui, + &[ + ("Legend: Bottom", "chart.set", json!({"chart": id, "legend": "bottom"})), + ("Legend: Right", "chart.set", json!({"chart": id, "legend": "right"})), + ("Legend: Top", "chart.set", json!({"chart": id, "legend": "top"})), + ("Legend: None", "chart.set", json!({"chart": id, "legend": "none"})), + ("-", "", json!(null)), + ("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})), + ("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})), + ("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})), + ("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})), + ], + ); + }); + let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false); + control_keytip(app, ui, &__k, "chart.switchRowColumn"); + if __k.clicked() { + act(app, "chart.switchRowColumn", json!({"chart": id})); + } + if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { + app.open_dialog("selectData", json!({"chart": id})); + } + let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"], + ) { + keytip(app, ui, sequence, ct.rect.center_bottom()); + } + egui::Popup::menu(&ct).show(|ui| { + for (label, k, s) in [ + ("Clustered Column", "column", ""), + ("Stacked Column", "column", "stacked"), + ("Bar", "bar", ""), + ("Line", "line", "markers"), + ("Area", "area", ""), + ("Pie", "pie", ""), + ("Doughnut", "doughnut", ""), + ("Scatter", "scatter", ""), + ("Radar", "radar", ""), + ("Waterfall", "waterfall", ""), + ("Funnel", "funnel", ""), + ("Treemap", "treemap", ""), + ] { + if ui.button(label).clicked() { + act(app, "chart.set", json!({"chart": id, "type": k, "subtype": s})); + } + } + }); + if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() { + app.grid.pane = Some("formatChart".into()); + } + let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false); + control_keytip(app, ui, &__k, "chartTitle"); + if __k.clicked() { + app.open_dialog("chartTitle", json!({"chart": id})); + } + let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false); + control_keytip(app, ui, &__k, "chart.delete"); + if __k.clicked() { + act(app, "chart.delete", json!({"chart": id})); + app.selected_chart = None; + } +} diff --git a/crates/ui-egui/src/ribbon/controls.rs b/crates/ui-egui/src/ribbon/controls.rs new file mode 100644 index 000000000..774a2b40b --- /dev/null +++ b/crates/ui-egui/src/ribbon/controls.rs @@ -0,0 +1,304 @@ +use super::{Rect, Sense, SheetApp, Stroke, StrokeKind, Style, Tokens, Ui, keytip_table, pos2, theme, vec2}; + +pub(super) fn sep(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); +} + +pub(super) fn active_style(app: &SheetApp) -> Style { + app.session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + Some(d.wb.styles.get(sh.style_id(d.selection.active)).clone()) + }) + .unwrap_or_default() +} + +pub(super) fn theme_colors(app: &SheetApp) -> [u32; 12] { + app.session.active().map(|d| d.wb.theme.colors).unwrap_or(gridcraft_engine::model::Theme::default().colors) +} + +/// Runs a command from a ribbon click (menu-style: no params may open a dialog). +pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { + app.run_or_alert(id, params); +} + +/// Keytips are painted, not focusable widgets: the grid retains keyboard focus. +pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { + let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); + let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); + painter.rect_filled(rect, 2.0, t.input_bg); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); +} + +/// Paint the badge for a control that runs command `id`, when the armed prefix matches. +pub(super) fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) { + if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// Paint the single badge for a menu anchor whose popup items run `targets`. +pub(super) fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) { + if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so +/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite. +pub(super) fn checkbox_on(clicked: bool, shown: bool) -> bool { + shown == clicked +} + +/// Consume a one-shot keytip request for control `id` (set by `Action::Control`). +pub(super) fn invoke(app: &mut SheetApp, id: &str) -> bool { + if app.ui.keytip_invoke == Some(id) { + app.ui.keytip_invoke = None; + true + } else { + false + } +} + +/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle). +pub(super) fn popup_command(forced: bool, clicked: bool) -> Option { + if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) } +} + +/// Per-control state of an [`editable_combo`]: the filter text and highlighted row. +#[derive(Clone, Default)] +struct ComboState { + text: String, + sel: i32, + was_open: bool, +} + +/// What the user did with an [`editable_combo`]. +pub(crate) enum ComboResult { + None, + /// A row was applied: clicked or Enter on the highlighted row. + Picked(String), +} + +/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. +/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) +/// or clicked, the popup opens, focuses the filter field, and selects its text. +pub(crate) fn editable_combo( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + forced: bool, +) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, ComboPopupOptions { forced, filter: true, under: None }) +} + +/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or +/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size +/// list under its box. +pub(super) fn list_combo( + ui: &mut Ui, + anchor: &egui::Response, + under: Rect, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], +) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, ComboPopupOptions { forced: false, filter: false, under: Some(under) }) +} + +/// What the header of a combo popup hands to its list: the rows to show and the highlighted one. +struct ComboHead { + filtered: Vec, + sel: i32, + down: bool, + up: bool, + enter: bool, +} + +struct ComboPopupOptions { + forced: bool, + filter: bool, + under: Option, +} + +/// Arrow and Enter keys (taken out of the input so a filter field never sees them), the filter +/// field when `text` is given, and the rows its text matches. +fn combo_head(ui: &mut Ui, text: Option<(&mut String, egui::Id)>, open_now: bool, sel: i32, items: &[(&str, String)]) -> ComboHead { + let (mut down, mut up, mut enter) = (false, false, false); + ui.input(|i| { + down = i.key_pressed(egui::Key::ArrowDown); + up = i.key_pressed(egui::Key::ArrowUp); + enter = i.key_pressed(egui::Key::Enter); + }); + if down || up || enter { + ui.ctx().input_mut(|i| { + i.events + .retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. })); + }); + } + let mut lower = String::new(); + if let Some((text, text_id)) = text { + let te = ui.add(egui::TextEdit::singleline(text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); + if open_now && !te.has_focus() { + ui.ctx().memory_mut(|m| m.request_focus(text_id)); + // Select all once focus lands so typing replaces the filter. + if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { + let len = text.chars().count(); + s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + s.store(ui.ctx(), text_id); + } + } + ui.separator(); + lower = text.to_lowercase(); + } + let filtered: Vec = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect(); + let mut sel = sel; + if !filtered.is_empty() { + let n = filtered.len() as i32; + sel = sel.clamp(0, n - 1); + if down { + sel = (sel + 1).rem_euclid(n); + } + if up { + sel = (sel - 1).rem_euclid(n); + } + } + ComboHead { filtered, sel, down, up, enter } +} + +/// The rows of a combo popup. Returns the picked value and the highlighted row. +fn combo_rows(ui: &mut Ui, head: ComboHead, items: &[(&str, String)], row_w: f32) -> (Option, i32) { + let ComboHead { filtered, mut sel, down, up, enter } = head; + let mut picked = None; + if enter && !filtered.is_empty() { + let idx = filtered[sel.clamp(0, filtered.len() as i32 - 1) as usize]; + picked = Some(items[idx].0.to_string()); + } + for (pos, i) in filtered.iter().enumerate() { + let (value, disp) = &items[*i]; + let row = ui.add(egui::Button::selectable(pos as i32 == sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0))); + if (down || up) && pos as i32 == sel && !ui.clip_rect().contains_rect(row.rect) { + row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + if row.clicked() { + picked = Some(value.to_string()); + } + if row.hovered() { + sel = pos as i32; + } + } + if picked.is_some() { + ui.close(); + } + (picked, sel) +} + +fn combo_popup( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + options: ComboPopupOptions, +) -> ComboResult { + let ComboPopupOptions { forced, filter, under } = options; + let popup_id = egui::Popup::default_response_id(anchor); + let state_id = anchor.id.with(salt).with("__combo_state"); + let mut st = ui.data_mut(|d| d.get_temp::(state_id)).unwrap_or_default(); + if forced { + egui::Popup::open_id(ui.ctx(), popup_id); + } + let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id); + if open_now && !st.was_open { + st.text = String::new(); + st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0); + // The reset lands on the frame AFTER the popup opens, which usually has no input of + // its own — without a repaint request the stale filtered list stays on screen. + ui.ctx().request_repaint(); + } + st.was_open = open_now; + // Below `under` and as wide as it, or below the anchor itself. + let mut field = anchor.clone(); + if let Some(r) = under { + field.rect = r; + } + let row_w = under.map_or(220.0, |r| r.width() - 12.0); + // Nine rows as today, plus the filter field and its separator, plus the popup frame. + let button_h = ui.spacing().interact_size.y.max(20.0); + let spacing = ui.spacing().item_spacing.y; + let max_rows = 9.0_f32; + let mut height = max_rows * button_h + (max_rows - 1.0) * spacing + egui::Frame::popup(ui.style()).total_margin().sum().y; + if filter { + height += ui.spacing().interact_size.y + 4.0 * spacing + 1.0; + } + let menu = craft_ui::combo::Menu::new(ui, &field).height(height); + let text_id = anchor.id.with(salt).with("__combo_text"); + let sel0 = st.sel; + let shown = if filter { + menu.show_ui_with_header( + |ui| combo_head(ui, Some((&mut st.text, text_id)), open_now, sel0, items), + |ui, head| combo_rows(ui, head, items, row_w), + ) + } else { + menu.show_ui(|ui| { + let head = combo_head(ui, None, open_now, sel0, items); + combo_rows(ui, head, items, row_w) + }) + }; + let mut result = ComboResult::None; + if let Some((picked, sel)) = shown { + st.sel = sel; + if let Some(value) = picked { + result = ComboResult::Picked(value); + } + } + ui.data_mut(|d| d.insert_temp(state_id, st)); + result +} + +pub(super) fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { + for (label, id, p) in items { + if *label == "-" { + ui.separator(); + continue; + } + let lang = app.ui.language; + let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); + if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); + } + if response.clicked() { + if let Some(d) = id.strip_prefix("dialog:") { + app.open_dialog(d, p.clone()); + } else if *id == "edit.paste" { + app.paste_from_clipboard(); + } else { + act(app, id, p.clone()); + } + } + } +} + +#[cfg(test)] +mod checkbox_on_tests { + use super::*; + + #[test] + fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() { + // Click: the checkbox bool already holds the new choice. + assert!(!checkbox_on(true, false)); + assert!(checkbox_on(true, true)); + // Key tip: the bool still holds the current setting, so toggle it. + assert!(checkbox_on(false, false)); + assert!(!checkbox_on(false, true)); + } +} diff --git a/crates/ui-egui/src/ribbon/data.rs b/crates/ui-egui/src/ribbon/data.rs new file mode 100644 index 000000000..1cecdb2db --- /dev/null +++ b/crates/ui-egui/src/ribbon/data.rs @@ -0,0 +1,139 @@ +use super::controls::{act, control_keytip, keytip, menu_items, sep}; +use super::{Icon, SheetApp, Ui, big_button, json, keytip_table, small_button}; + +pub(super) fn data(app: &mut SheetApp, ui: &mut Ui) { + let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); + control_keytip(app, ui, &gd, "data.getData"); + if gd.clicked() { + app.open_dialog("open", json!({})); + } + sep(ui); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); + control_keytip(app, ui, &__k, "data.sortAscending"); + if __k.clicked() { + act(app, "data.sortAscending", json!({})); + } + let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false); + control_keytip(app, ui, &__k, "data.sortDescending"); + if __k.clicked() { + act(app, "data.sortDescending", json!({})); + } + }); + let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false); + control_keytip(app, ui, &__k, "dialog:sort"); + if __k.clicked() { + app.open_dialog("sort", json!({})); + } + let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); + control_keytip(app, ui, &__k, "data.filter"); + if __k.clicked() { + act(app, "data.filter", json!({})); + } + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false); + control_keytip(app, ui, &__k, "data.clearFilter"); + if __k.clicked() { + act(app, "data.clearFilter", json!({})); + } + let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false); + control_keytip(app, ui, &__k, "data.reapply"); + if __k.clicked() { + act(app, "data.reapply", json!({})); + } + let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false); + control_keytip(app, ui, &__k, "dialog:advancedFilter"); + if __k.clicked() { + app.open_dialog("advancedFilter", json!({})); + } + }); + sep(ui); + let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); + control_keytip(app, ui, &__k, "dialog:textToColumns"); + if __k.clicked() { + app.open_dialog("textToColumns", json!({})); + } + let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); + control_keytip(app, ui, &__k, "edit.flashFill"); + if __k.clicked() { + act(app, "edit.flashFill", json!({})); + } + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "dialog:removeDuplicates"); + if __k.clicked() { + app.open_dialog("removeDuplicates", json!({})); + } + let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) { + keytip(app, ui, sequence, dv.rect.center_bottom()); + } + egui::Popup::menu(&dv).show(|ui| { + menu_items( + app, + ui, + &[ + ("Data Validation…", "dialog:dataValidation", json!({})), + ("Circle Invalid Data", "data.circleInvalid", json!({})), + ("Clear Validation", "data.validation", json!({"clear": true})), + ], + ); + }); + sep(ui); + let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"]) + { + keytip(app, ui, sequence, wi.rect.center_bottom()); + } + egui::Popup::menu(&wi).show(|ui| { + menu_items( + app, + ui, + &[ + ("Goal Seek…", "dialog:goalSeek", json!({})), + ("Scenario Manager…", "data.scenarioManager", json!({})), + ("Data Table…", "data.dataTable", json!({})), + ], + ); + }); + if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { + app.open_dialog("forecastSheet", json!({})); + } + sep(ui); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Group, "Group", "Group", false); + control_keytip(app, ui, &__k, "data.group"); + if __k.clicked() { + act(app, "data.group", json!({})); + } + let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false); + control_keytip(app, ui, &__k, "data.ungroup"); + if __k.clicked() { + act(app, "data.ungroup", json!({})); + } + let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false); + control_keytip(app, ui, &__k, "dialog:subtotal"); + if __k.clicked() { + app.open_dialog("subtotal", json!({})); + } + }); + ui.vertical(|ui| { + if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() { + act(app, "data.showDetail", json!({})); + } + if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() { + act(app, "data.hideDetail", json!({})); + } + if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() { + app.open_dialog("outlineSettings", json!({})); + } + }); + sep(ui); + if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() { + app.open_dialog("analysisToolPak", json!({})); + } + sep(ui); + if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { + app.open_dialog("solver", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/formulas.rs b/crates/ui-egui/src/ribbon/formulas.rs new file mode 100644 index 000000000..4f757bdac --- /dev/null +++ b/crates/ui-egui/src/ribbon/formulas.rs @@ -0,0 +1,202 @@ +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}; + +/// How many cells Formulas › Error Checking › Circular References lists. +const CIRCULAR_MENU_LIMIT: usize = 100; + +/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order +/// the calculation found them, as (sheet index, cell, "Sheet!A1"). +fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { + let Some(d) = app.session.active() else { return Vec::new() }; + let mut seen = std::collections::HashSet::new(); + d.calc + .circular + .iter() + .filter(|k| seen.insert(**k)) + .filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1())))) + .take(CIRCULAR_MENU_LIMIT) + .collect() +} + +/// 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) { + let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); + control_keytip(app, ui, &__k, "dialog:insertFunction"); + 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() { + 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(); + } + } + } + }); + }); + } + sep(ui); + let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false); + control_keytip(app, ui, &__k, "dialog:nameManager"); + if __k.clicked() { + app.open_dialog("nameManager", json!({})); + } + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false); + control_keytip(app, ui, &__k, "dialog:defineName"); + if __k.clicked() { + app.open_dialog("defineName", json!({})); + } + let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); + anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]); + egui::Popup::menu(&use_in).show(|ui| { + // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. + // Listed when the menu opens, and again only after the workbook changes. + let cache = egui::Id::unique("gridcraft.use_in_formula_names"); + let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet())); + let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc>)>(cache)).filter(|(k, _)| *k == key); + let names = match cached { + Some((_, names)) => names, + None => { + let names: std::sync::Arc> = std::sync::Arc::new( + app.session + .active() + .map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default(), + ); + ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone()))); + names + } + }; + for n in names.iter() { + if ui.button(n).clicked() { + app.run_or_alert("formulas.useInFormula", json!({"name": n})); + } + } + if !names.is_empty() { + ui.separator(); + } + if ui.button(app.ui.language.tr("Paste Names…")).clicked() { + app.open_dialog("pasteName", json!({})); + } + }); + let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); + control_keytip(app, ui, &__k, "formulas.createFromSelection"); + if __k.clicked() { + act(app, "formulas.createFromSelection", json!({})); + } + }); + sep(ui); + ui.vertical(|ui| { + if small_button(ui, Icon::Trace, "Trace Precedents", "Trace Precedents", false).clicked() + && let Ok(r) = app.run("formulas.tracePrecedents", json!({})) + { + app.grid.trace = crate::grid::Trace::new(app, r); + } + if small_button(ui, Icon::Trace, "Trace Dependents", "Trace Dependents", false).clicked() + && let Ok(r) = app.run("formulas.traceDependents", json!({})) + { + app.grid.trace = crate::grid::Trace::new(app, r); + } + if small_button(ui, Icon::Close, "Remove Arrows", "Remove Arrows", false).clicked() { + app.grid.trace = None; + } + }); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false); + control_keytip(app, ui, &__k, "formulas.showFormulas"); + if __k.clicked() { + act(app, "formulas.showFormulas", json!({})); + } + let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking"); + control_keytip(app, ui, &ec, "dialog:errorChecking"); + if ec.clicked() { + app.open_dialog("errorChecking", json!({})); + } + egui::Popup::menu(&ec_arrow).show(|ui| { + let lang = app.ui.language; + if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { + app.open_dialog("errorChecking", json!({})); + } + let circular = circular_references(app); + ui.add_enabled_ui(!circular.is_empty(), |ui| { + ui.menu_button(lang.tr("Circular References"), |ui| { + egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } + } + }); + }); + }); + }); + let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); + control_keytip(app, ui, &__k, "dialog:evaluateFormula"); + if __k.clicked() { + app.open_dialog("evaluateFormula", json!({})); + } + }); + if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { + app.grid.pane = Some("watch".into()); + } + sep(ui); + let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) { + keytip(app, ui, sequence, co.rect.center_bottom()); + } + egui::Popup::menu(&co).show(|ui| { + menu_items( + app, + ui, + &[ + ("Automatic", "formulas.calculationOptions", json!({"mode": "automatic"})), + ("Automatic Except for Data Tables", "formulas.calculationOptions", json!({"mode": "automaticExceptTables"})), + ("Manual", "formulas.calculationOptions", json!({"mode": "manual"})), + ], + ); + }); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateNow"); + if __k.clicked() { + act(app, "formulas.calculateNow", json!({})); + } + let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateSheet"); + if __k.clicked() { + act(app, "formulas.calculateSheet", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs new file mode 100644 index 000000000..6aa5bcd6b --- /dev/null +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -0,0 +1,145 @@ +use super::controls::act; +use super::{Align2, Cat, Rect, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, json, pos2, theme, vec2}; + +/// A gallery of table styles drawn as mini tables. +pub(super) fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) { + ui.set_width(430.0); + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + for (fam, n) in [("Light", 21u32), ("Medium", 28), ("Dark", 11)] { + ui.label(egui::RichText::new(fam).strong()); + egui::Grid::new(("tg", fam)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for i in 1..=n { + let name = format!("TableStyle{fam}{i}"); + let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click()); + paint_table_swatch(ui, r, &wb, &name); + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); + } + if resp.on_hover_text(&name).clicked() { + act(app, cmd, json!({"style": name})); + ui.close(); + } + if i % 7 == 0 { + ui.end_row(); + } + } + }); + } +} + +pub(super) fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, style: &str) { + let lk = gridcraft_engine::tables::look(style); + let res = |c: Option| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32); + let p = ui.painter(); + let t = Tokens::get(ui.ctx()); + p.rect_filled(r, 0.0, t.grid_bg); + let rows = 5; + let rh = r.height() / rows as f32; + for i in 0..rows { + let rr = Rect::from_min_size(pos2(r.left(), r.top() + i as f32 * rh), vec2(r.width(), rh)); + let fill = if i == 0 { + res(lk.header_fill) + } else if i % 2 == 1 { + res(lk.band_fill) + } else { + res(lk.body_fill) + }; + if let Some(f) = fill { + p.rect_filled(rr, 0.0, f); + } + if let Some(l) = res(lk.line) { + p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(1.0, l)); + } + for k in 0..3 { + let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0; + let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border }; + p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col)); + } + } + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); +} + +pub(super) fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.set_width(520.0); + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let groups: &[(&str, &[&str])] = &[ + ("Good, Bad and Neutral", &["Normal", "Bad", "Good", "Neutral"]), + ("Data and Model", &["Calculation", "Check Cell", "Explanatory Text", "Input", "Linked Cell", "Note", "Output", "Warning Text"]), + ("Titles and Headings", &["Heading 1", "Heading 2", "Heading 3", "Heading 4", "Title", "Total"]), + ( + "Themed Cell Styles", + &[ + "20% - Accent1", + "20% - Accent2", + "20% - Accent3", + "20% - Accent4", + "20% - Accent5", + "20% - Accent6", + "40% - Accent1", + "40% - Accent2", + "40% - Accent3", + "40% - Accent4", + "40% - Accent5", + "40% - Accent6", + "60% - Accent1", + "60% - Accent2", + "60% - Accent3", + "60% - Accent4", + "60% - Accent5", + "60% - Accent6", + "Accent1", + "Accent2", + "Accent3", + "Accent4", + "Accent5", + "Accent6", + ], + ), + ("Number Format", &["Comma", "Comma [0]", "Currency", "Currency [0]", "Percent"]), + ]; + for (g, names) in groups { + ui.label(egui::RichText::new(*g).strong()); + egui::Grid::new(("csg", *g)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, n) in names.iter().enumerate() { + let st = gridcraft_engine::cmd::format::builtin_cell_style(n, &wb.theme).unwrap_or_default(); + let (r, resp) = ui.allocate_exact_size(vec2(82.0, 24.0), Sense::click()); + let fill = st + .fill + .fg + .resolve(&wb.theme) + .filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None) + .map(theme::color32) + .unwrap_or(t.grid_bg); + ui.painter().rect_filled(r, 0.0, fill); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + if !st.borders.bottom.is_none() { + let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); + ui.painter().line_segment( + [r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)], + Stroke::new(st.borders.bottom.style.width(), c), + ); + } + let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); + let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + Align2::LEFT_CENTER, + *n, + egui::FontId::new((st.font.size).clamp(10.0, 15.0), fam), + col, + ); + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); + } + if resp.clicked() { + act(app, "home.cellStyle", json!({"name": n})); + ui.close(); + } + if (i + 1) % 6 == 0 { + ui.end_row(); + } + } + }); + } +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs new file mode 100644 index 000000000..c061dfac3 --- /dev/null +++ b/crates/ui-egui/src/ribbon/home.rs @@ -0,0 +1,815 @@ +use super::controls::{ + ComboResult, act, active_style, anchor_keytip, control_keytip, editable_combo, invoke, keytip, list_combo, menu_items, popup_command, sep, + theme_colors, +}; +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, small_split_button, split_button, theme, toggle_button, vec2, +}; + +/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. +const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[ + ("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), + ("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"), + ("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"), + ("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"), + ("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"), + ("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"), + ("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"), + ("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"), + ("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"), + ("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"), + ("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"), +]; + +/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips. +pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let st = active_style(app); + // Clipboard + let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); + paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste")); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) + { + keytip(app, ui, sequence, paste.rect.center_bottom()); + } + if paste.clicked() { + app.paste_from_clipboard(); + } + let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); + // Keep a keyboard-opened menu anchored to the button when a pointer click + // cancels keytips, so its items remain in place and can still be clicked. + let anchor_id = paste.id.with("keytip_anchor"); + // Query response state before taking the egui context write lock. + // Calling secondary_clicked() inside data_mut can deadlock. + let paste_secondary_clicked = paste.secondary_clicked(); + let anchored = ui.ctx().data_mut(|data| { + let anchored = if paste_keys { + true + } else if paste_secondary_clicked { + false + } else { + data.get_temp::(anchor_id).unwrap_or(false) + }; + data.insert_temp(anchor_id, anchored); + anchored + }); + let mut paste_popup = egui::Popup::context_menu(&paste); + if anchored { + paste_popup = paste_popup.anchor(&paste); + } + if paste_keys { + paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + paste_popup.show(|ui| { + menu_items( + app, + ui, + &[ + ("Paste", "edit.paste", json!({})), + ("Paste Values", "edit.pasteSpecial", json!({"what": "values"})), + ("Paste Formulas", "edit.pasteSpecial", json!({"what": "formulas"})), + ("Paste Formatting", "edit.pasteSpecial", json!({"what": "formats"})), + ("Transpose", "edit.pasteSpecial", json!({"what": "all", "transpose": true})), + ("Paste Link", "edit.pasteSpecial", json!({"what": "all", "link": true})), + ("-", "", json!(null)), + ("Paste Special…", "dialog:pasteSpecial", json!({})), + ], + ); + }); + ui.vertical(|ui| { + let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.cut"); + if __k.clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.cut"); + } + let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.copy"); + if __k.clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.copy"); + } + let fp_on = app.session.format_painter.is_some(); + let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter")); + control_keytip(app, ui, &fp, "edit.formatPainter"); + if fp.double_clicked() { + act(app, "edit.formatPainter", json!({"sticky": true})); + } else if fp.clicked() { + if fp_on { + app.session.format_painter = None; + } else { + act(app, "edit.formatPainter", json!({})); + } + } + }); + sep(ui); + // Font + ui.vertical(|ui| { + ui.horizontal(|ui| { + font_combo(app, ui, &st); + size_combo(app, ui, &st); + let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseFontSize"); + if __k.clicked() { + act(app, "home.increaseFontSize", json!({})); + } + let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseFontSize"); + if __k.clicked() { + act(app, "home.decreaseFontSize", json!({})); + } + }); + ui.horizontal(|ui| { + let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); + control_keytip(app, ui, &__k, "home.bold"); + if __k.clicked() { + act(app, "home.bold", json!({})); + } + let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); + control_keytip(app, ui, &__k, "home.italic"); + if __k.clicked() { + act(app, "home.italic", json!({})); + } + let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); + control_keytip(app, ui, &ua, "home.underline"); + if u { + act(app, "home.underline", json!({})); + } + egui::Popup::menu(&ua).show(|ui| { + menu_items( + app, + ui, + &[ + ("Underline", "home.underline", json!({"style": "single"})), + ("Double Underline", "home.underline", json!({"style": "double"})), + ], + ); + }); + let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough"); + control_keytip(app, ui, &__k, "home.strikethrough"); + if __k.clicked() { + act(app, "home.strikethrough", json!({})); + } + let (b, ba) = split_button(ui, Icon::Borders, None, "Borders"); + control_keytip(app, ui, &ba, "home.borders"); + if b { + 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| { + for (label, preset) in [ + ("Bottom Border", "bottom"), + ("Top Border", "top"), + ("Left Border", "left"), + ("Right Border", "right"), + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Thick Outside Borders", "thickOutside"), + ("Bottom Double Border", "doubleBottom"), + ("Thick Bottom Border", "thickBottom"), + ("Top and Bottom Border", "topBottom"), + ("Top and Thick Bottom Border", "topThickBottom"), + ("Top and Double Bottom Border", "topDoubleBottom"), + ("Inside Borders", "inside"), + ] { + if crate::border_preview::preset_button(ui, label, preset).clicked() { + app.grid.last_border = preset.to_string(); + act(app, "home.borders", json!({"preset": preset})); + } + } + ui.separator(); + if ui.button("More Borders…").clicked() { + app.open_dialog("formatCells", json!({"tab": "Border"})); + } + }); + let fill = app.grid.last_fill.clone(); + let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32); + let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color"); + control_keytip(app, ui, &fa, "home.fillColor"); + if f { + act(app, "home.fillColor", json!({"color": fill})); + } + let forced = invoke(app, "home.fillColor"); + egui::Popup::menu(&fa) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, fa.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { + if c != "none" { + app.grid.last_fill = c.clone(); + } + act(app, "home.fillColor", json!({"color": c})); + ui.close(); + } + }); + let fc = app.grid.last_font_color.clone(); + let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); + let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); + control_keytip(app, ui, &ca, "home.fontColor"); + if c { + act(app, "home.fontColor", json!({"color": fc})); + } + let forced = invoke(app, "home.fontColor"); + egui::Popup::menu(&ca) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, ca.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { + if c != "none" { + app.grid.last_font_color = c.clone(); + } + act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); + ui.close(); + } + }); + }); + }); + sep(ui); + // Alignment + ui.vertical(|ui| { + ui.horizontal(|ui| { + for (icon, v, id, tip) in [ + (Icon::AlignTop, VAlign::Top, "home.alignTop", "Top Align"), + (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), + (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), + ] { + let __k = toggle_button(ui, icon, st.align.v == v, tip); + control_keytip(app, ui, &__k, id); + if __k.clicked() { + act(app, id, json!({})); + } + } + let o = small_button(ui, Icon::Orientation, "", "Orientation", true); + control_keytip(app, ui, &o, "home.orientation"); + let forced_o = invoke(app, "home.orientation"); + egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| { + menu_items( + app, + ui, + &[ + ("Angle Counterclockwise", "home.orientation", json!({"angle": "counterclockwise"})), + ("Angle Clockwise", "home.orientation", json!({"angle": "clockwise"})), + ("Vertical Text", "home.orientation", json!({"angle": "vertical"})), + ("Rotate Text Up", "home.orientation", json!({"angle": "up"})), + ("Rotate Text Down", "home.orientation", json!({"angle": "down"})), + ("Horizontal", "home.orientation", json!({"angle": 0})), + ("-", "", json!(null)), + ("Format Cell Alignment…", "dialog:formatCells", json!({"tab": "Alignment"})), + ], + ); + }); + let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text"); + control_keytip(app, ui, &wrap, "home.wrapText"); + if wrap.clicked() { + act(app, "home.wrapText", json!({})); + } + ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5))); + }); + ui.horizontal(|ui| { + for (icon, h, id, tip) in [ + (Icon::AlignLeft, HAlign::Left, "home.alignLeft", "Align Left"), + (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), + (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), + ] { + let response = toggle_button(ui, icon, st.align.h == h, tip); + for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { + act(app, id, json!({})); + } + } + let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseIndent"); + if __k.clicked() { + act(app, "home.decreaseIndent", json!({})); + } + let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseIndent"); + if __k.clicked() { + act(app, "home.increaseIndent", json!({})); + } + let (m, ma) = small_split_button(ui, Icon::Merge, "Merge & Center", "Merge & Center"); + control_keytip(app, ui, &ma, "home.mergeCenter"); + if m.clicked() { + act(app, "home.mergeCenter", json!({})); + } + egui::Popup::menu(&ma).show(|ui| { + menu_items( + app, + ui, + &[ + ("Merge & Center", "home.mergeCenter", json!({})), + ("Merge Across", "home.mergeAcross", json!({})), + ("Merge Cells", "home.mergeCells", json!({})), + ("Unmerge Cells", "home.unmergeCells", json!({})), + ], + ); + }); + }); + }); + sep(ui); + // Number + ui.vertical(|ui| { + number_combo(app, ui, &st); + ui.horizontal(|ui| { + let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format"); + control_keytip(app, ui, &ca, "home.accounting"); + if c { + act(app, "home.accounting", json!({})); + } + egui::Popup::menu(&ca).show(|ui| { + let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect(); + items.push(("-", "", json!(null))); + items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"}))); + menu_items(app, ui, &items); + }); + let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.percent"); + if __k.clicked() { + act(app, "home.percent", json!({})); + } + let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.comma"); + if __k.clicked() { + act(app, "home.comma", json!({})); + } + let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseDecimal"); + if __k.clicked() { + act(app, "home.increaseDecimal", json!({})); + } + let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseDecimal"); + if __k.clicked() { + act(app, "home.decreaseDecimal", json!({})); + } + }); + }); + sep(ui); + // Styles + let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true); + anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]); + egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui)); + let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true); + anchor_keytip(app, ui, &ft, &["table.style"]); + egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable")); + let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true); + anchor_keytip(app, ui, &cs, &["home.newCellStyle"]); + egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui)); + sep(ui); + // Cells + ui.vertical(|ui| { + let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"], + ) { + keytip(app, ui, sequence, ins.rect.center_bottom()); + } + egui::Popup::menu(&ins).show(|ui| { + menu_items( + app, + ui, + &[ + ("Insert Cells…", "dialog:insertCells", json!({})), + ("Insert Sheet Rows", "home.insertRows", json!({})), + ("Insert Sheet Columns", "home.insertColumns", json!({})), + ("Insert Sheet", "home.insertSheet", json!({})), + ], + ); + }); + let del = small_button(ui, Icon::Delete, "Delete", "Delete", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"], + ) { + keytip(app, ui, sequence, del.rect.center_bottom()); + } + egui::Popup::menu(&del).show(|ui| { + menu_items( + app, + ui, + &[ + ("Delete Cells…", "dialog:deleteCells", json!({})), + ("Delete Sheet Rows", "home.deleteRows", json!({})), + ("Delete Sheet Columns", "home.deleteColumns", json!({})), + ("Delete Sheet", "home.deleteSheet", json!({})), + ], + ); + }); + let fmt = small_button(ui, Icon::Format, "Format", "Format", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &[ + "dialog:rowHeight", + "home.autofitRowHeight", + "dialog:columnWidth", + "home.autofitColumnWidth", + "dialog:defaultWidth", + "home.hideRows", + "home.hideColumns", + "home.unhideRows", + "home.unhideColumns", + "sheet.hide", + "sheet.unhide", + "dialog:renameSheet", + "dialog:moveSheet", + "dialog:protectSheet", + "home.lockCell", + "dialog:formatCells", + ], + ) { + keytip(app, ui, sequence, fmt.rect.center_bottom()); + } + let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); + let mut format_popup = egui::Popup::menu(&fmt); + if format_keys { + format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; + format_popup.show(|ui| { + menu_items( + app, + ui, + &[ + ("Row Height…", "dialog:rowHeight", json!({})), + ("AutoFit Row Height", "home.autofitRowHeight", json!({})), + ("Column Width…", "dialog:columnWidth", json!({})), + ("AutoFit Column Width", "home.autofitColumnWidth", json!({})), + ("Default Width…", "dialog:defaultWidth", json!({})), + ("-", "", json!(null)), + ("Hide Rows", "home.hideRows", json!({})), + ("Hide Columns", "home.hideColumns", json!({})), + ("Unhide Rows", "home.unhideRows", json!({})), + ("Unhide Columns", "home.unhideColumns", json!({})), + ("Hide Sheet", "sheet.hide", json!({})), + ("Unhide Sheet…", "sheet.unhide", json!({})), + ("-", "", json!(null)), + ("Rename Sheet", "dialog:renameSheet", json!({})), + ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), + ("-", "", json!(null)), + (protect_label, "dialog:protectSheet", json!({})), + ("Lock Cell", "home.lockCell", json!({})), + ("Format Cells…", "dialog:formatCells", json!({})), + ], + ); + }); + }); + sep(ui); + // Editing + ui.vertical(|ui| { + ui.horizontal(|ui| { + let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); + control_keytip(app, ui, &sa, "formulas.autoSum"); + if s { + act(app, "formulas.autoSum", json!({})); + } + egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui)); + }); + let fill = small_button(ui, Icon::FillDown, "", "Fill", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"], + ) { + keytip(app, ui, sequence, fill.rect.center_bottom()); + } + egui::Popup::menu(&fill).show(|ui| { + menu_items( + app, + ui, + &[ + ("Down", "edit.fillDown", json!({})), + ("Right", "edit.fillRight", json!({})), + ("Up", "edit.fillUp", json!({})), + ("Left", "edit.fillLeft", json!({})), + ("Series…", "dialog:series", json!({})), + ("Flash Fill", "edit.flashFill", json!({})), + ], + ); + }); + let clear = small_button(ui, Icon::Eraser, "", "Clear", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"], + ) { + keytip(app, ui, sequence, clear.rect.center_bottom()); + } + egui::Popup::menu(&clear).show(|ui| { + menu_items( + app, + ui, + &[ + ("Clear All", "edit.clearAll", json!({})), + ("Clear Formats", "edit.clearFormats", json!({})), + ("Clear Contents", "edit.clearContents", json!({})), + ("Clear Comments and Notes", "edit.clearComments", json!({})), + ("Clear Hyperlinks", "edit.clearHyperlinks", json!({})), + ], + ); + }); + }); + let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"], + ) { + keytip(app, ui, sequence, sf.rect.center_bottom()); + } + egui::Popup::menu(&sf).show(|ui| { + menu_items( + app, + ui, + &[ + ("Sort A to Z", "data.sortAscending", json!({})), + ("Sort Z to A", "data.sortDescending", json!({})), + ("Custom Sort…", "dialog:sort", json!({})), + ("-", "", json!(null)), + ("Filter", "data.filter", json!({})), + ("Clear", "data.clearFilter", json!({})), + ("Reapply", "data.reapply", json!({})), + ], + ); + }); + let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"], + ) { + keytip(app, ui, sequence, fs.rect.center_bottom()); + } + egui::Popup::menu(&fs).show(|ui| { + menu_items( + app, + ui, + &[ + ("Find…", "dialog:find", json!({})), + ("Replace…", "dialog:find", json!({"replace": true})), + ("Go To…", "dialog:goTo", json!({})), + ("Go To Special…", "dialog:goToSpecial", json!({})), + ("-", "", json!(null)), + ("Formulas", "edit.goToSpecial", json!({"kind": "formulas"})), + ("Notes", "edit.goToSpecial", json!({"kind": "notes"})), + ("Conditional Formatting", "edit.goToSpecial", json!({"kind": "conditionalFormats"})), + ("Constants", "edit.goToSpecial", json!({"kind": "constants"})), + ("Data Validation", "edit.goToSpecial", json!({"kind": "dataValidation"})), + ], + ); + }); +} + +/// 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)))); + control_keytip(app, ui, &resp, "home.fontName"); + let forced = invoke(app, "home.fontName"); + let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) { + act(app, "home.fontName", json!({"name": value})); + } +} + +pub const FONTS: &[&str] = &[ + "Aptos", + "Aptos Narrow", + "Arial", + "Calibri", + "Cambria", + "Candara", + "Consolas", + "Courier New", + "Garamond", + "Georgia", + "Helvetica", + "Helvetica Neue", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana", +]; + +/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses +/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes. +fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { + let t = Tokens::get(ui.ctx()); + let size = st.font.size; + let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; + let field = egui::Id::unique("gridcraft.font_size_box"); + let draft = field.with("draft"); + let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); + // One frame like the Font name box beside it (same fill, border, height and corners), with the + // text field and the dropdown arrow inside it, as in Excel. + let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover()); + // Painted once the field's focus and hover are known, but beneath the field and the arrow. + let bg = ui.painter().add(egui::Shape::Noop); + let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom())); + let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); + let mut field_ui = + ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let out = egui::TextEdit::singleline(&mut text) + .id(field) + .font(theme::ui_font(12.5)) + .frame(egui::Frame::NONE) + .desired_width(text_rect.width() - 12.0) + .show(&mut field_ui); + let resp = out.response; + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size"))); + control_keytip(app, ui, &resp, "home.fontSize"); + if invoke(app, "home.fontSize") { + ui.memory_mut(|m| m.request_focus(field)); + } + if resp.gained_focus() { + // Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip. + let mut state = out.state; + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), field); + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + // Consume the Enter so the grid behind does not also move the active cell down. + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) + && let Some(v) = text.trim().parse::().ok().filter(|v| v.is_finite()) + { + act(app, "home.fontSize", json!({"size": v})); + } + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, text)); + } else { + ui.data_mut(|d| d.remove::(draft)); + } + let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect)); + let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size()); + let visuals = ui.style().visuals.clone(); + let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; + let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; + ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size")); + let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; + let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); + let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); + if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", ¤t, &items) + && let Ok(v) = value.parse::() + { + act(app, "home.fontSize", json!({"size": v})); + } +} + +fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { + let code = st.num_fmt.as_str().to_string(); + let kind = gridcraft_engine::display::number_format(&code).kind(); + let current = format!("{kind:?}"); + let lang = app.ui.language; + let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); + let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))); + control_keytip(app, ui, &resp, "home.numberFormat"); + let forced = invoke(app, "home.numberFormat"); + let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]; + let items: Vec<(&str, String)> = names + .iter() + .map(|n| { + let code = gridcraft_engine::cmd::format::format_code_for(n); + let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); + (*n, format!("{:<12} {preview}", lang.tr(n))) + }) + .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) + .collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", ¤t, &items, forced) { + match value.as_str() { + "__more" => app.open_dialog("formatCells", json!({"tab": "Number"})), + n => act(app, "home.numberFormat", json!({"format": n})), + } + } +} + +fn cf_menu(app: &mut SheetApp, ui: &mut Ui) { + ui.menu_button("Highlight Cells Rules", |ui| { + for (label, op) in [("Greater Than…", "greater"), ("Less Than…", "less"), ("Between…", "between"), ("Equal To…", "equal")] { + if ui.button(label).clicked() { + app.open_dialog("cfQuick", json!({"type": "cellIs", "operator": op, "title": label})); + } + } + if ui.button("Text that Contains…").clicked() { + app.open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); + } + if ui.button("A Date Occurring…").clicked() { + app.open_dialog("cfQuick", json!({"type": "timePeriod", "title": "A Date Occurring"})); + } + if ui.button("Duplicate Values…").clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "duplicate"}})); + } + }); + ui.menu_button("Top/Bottom Rules", |ui| { + menu_items( + app, + ui, + &[ + ("Top 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10}})), + ("Top 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "percent": true}})), + ("Bottom 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true}})), + ("Bottom 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true, "percent": true}})), + ("Above Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage"}})), + ("Below Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage", "below": true}})), + ], + ); + }); + ui.menu_button("Data Bars", |ui| { + for (label, c) in + [("Blue", "#638EC6"), ("Green", "#63BE7B"), ("Red", "#F8696B"), ("Orange", "#FFB628"), ("Light Blue", "#008AEF"), ("Purple", "#D6007B")] + { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "dataBar", "color": c}})); + } + } + }); + ui.menu_button("Color Scales", |ui| { + for (label, cols) in [ + ("Green - Yellow - Red", vec!["#63BE7B", "#FFEB84", "#F8696B"]), + ("Red - Yellow - Green", vec!["#F8696B", "#FFEB84", "#63BE7B"]), + ("Green - White - Red", vec!["#63BE7B", "#FCFCFF", "#F8696B"]), + ("Blue - White - Red", vec!["#5A8AC6", "#FCFCFF", "#F8696B"]), + ("White - Green", vec!["#FCFCFF", "#63BE7B"]), + ("White - Red", vec!["#FCFCFF", "#F8696B"]), + ] { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "colorScale", "colors": cols}})); + } + } + }); + ui.menu_button("Icon Sets", |ui| { + for (label, set) in [ + ("3 Arrows", "3Arrows"), + ("3 Traffic Lights", "3TrafficLights1"), + ("3 Symbols", "3Symbols"), + ("4 Ratings", "4Rating"), + ("5 Arrows", "5Arrows"), + ("5 Quarters", "5Quarters"), + ] { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "iconSet", "set": set}})); + } + } + }); + ui.separator(); + if ui.button("New Rule…").clicked() { + app.open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); + } + ui.menu_button("Clear Rules", |ui| { + menu_items( + app, + ui, + &[ + ("Clear Rules from Selected Cells", "home.clearRules", json!({})), + ("Clear Rules from Entire Sheet", "home.clearRules", json!({"sheet": true})), + ], + ); + }); + if ui.button("Manage Rules…").clicked() { + app.open_dialog("manageRules", json!({})); + } +} + +#[cfg(test)] +mod accounting_symbol_tests { + use super::*; + use gridcraft_engine::core::{DateSystem, Value}; + use gridcraft_engine::numfmt::{NumberFormat, format_value}; + + #[test] + fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() { + // #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the + // `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals. + for (label, tag, shown) in [ + ("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"), + ("₹ English (India)", "[$₹-4009]", "₹1,234.50"), + ("₩ Korean", "[$₩-412]", "₩1,235"), + ("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"), + ("¥ Japanese", "[$¥-411]", "¥1,235"), + ] { + let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c); + let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") }; + assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}"); + let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900); + assert_eq!(out.text.trim(), shown, "{label}"); + let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default(); + assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs new file mode 100644 index 000000000..63f61f755 --- /dev/null +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -0,0 +1,245 @@ +use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep}; +use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, vec2}; + +/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. +pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { + let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); + control_keytip(app, ui, &__k, "insert.pivotTable"); + if __k.clicked() { + act(app, "insert.pivotTable", json!({})); + } + let __k = big_button(ui, Icon::Table, "Table", "Table", false); + control_keytip(app, ui, &__k, "insert.table"); + if __k.clicked() { + act(app, "insert.table", json!({})); + } + sep(ui); + let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); + anchor_keytip(app, ui, &pic, &["insert.picture"]); + pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures")); + egui::Popup::menu(&pic).show(|ui| { + for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { + if ui.button(label).clicked() { + app.open_dialog("insertPicture", json!({"placement": placement})); + ui.close(); + } + } + }); + let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); + anchor_keytip(app, ui, &shapes, &["insert.shape"]); + egui::Popup::menu(&shapes).show(|ui| { + for (label, kind) in [ + ("Rectangle", "rectangle"), + ("Rounded Rectangle", "roundedRectangle"), + ("Oval", "ellipse"), + ("Triangle", "triangle"), + ("Line", "line"), + ("Arrow", "arrow"), + ("Text Box", "textBox"), + ] { + if ui.button(label).clicked() { + act(app, "insert.shape", json!({"kind": kind})); + } + } + }); + let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true); + anchor_keytip(app, ui, &icn, &["insert.icons"]); + egui::Popup::menu(&icn).show(|ui| { + ui.set_width(260.0); + egui::Grid::new("icon_lib").show(ui, |ui| { + for (i, (name, icon)) in icons::LIBRARY.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, Tokens::get(ui.ctx()).hover); + } + icons::paint(ui.painter(), r.shrink(6.0), *icon, Tokens::get(ui.ctx()).text); + if resp.on_hover_text(*name).clicked() { + act(app, "insert.icons", json!({"name": name})); + ui.close(); + } + if (i + 1) % 6 == 0 { + ui.end_row(); + } + } + }); + }); + sep(ui); + let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); + control_keytip(app, ui, &__k, "insert.recommendedCharts"); + if __k.clicked() { + // Menu-style: with no chart type, run_or_alert opens the Insert Chart dialog. + act(app, "insert.recommendedCharts", json!({})); + } + ui.vertical(|ui| { + ui.horizontal(|ui| { + for (icon, tip, cid, subs) in [ + ( + Icon::ChartBar, + "Column or Bar", + "insert.chart.column", + vec![ + ("Clustered Column", "column", ""), + ("Stacked Column", "column", "stacked"), + ("100% Stacked Column", "column", "stacked100"), + ("Clustered Bar", "bar", ""), + ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), + ], + ), + ( + Icon::ChartLine, + "Line or Area", + "insert.chart.line", + vec![ + ("Line", "line", ""), + ("Line with Markers", "line", "markers"), + ("Stacked Line", "line", "stacked"), + ("Area", "area", ""), + ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), + ], + ), + (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), + ] { + let r = small_button(ui, icon, "", tip, 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| { + for (label, k, s) in &subs { + if ui.button(*label).clicked() { + act(app, "insert.chart", json!({"type": k, "subtype": s})); + } + } + }); + } + }); + ui.horizontal(|ui| { + for (icon, tip, cid, subs) in [ + ( + Icon::ChartScatter, + "Scatter or Bubble", + "insert.chart.scatter", + vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")], + ), + (Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), + ( + Icon::Theme, + "Hierarchy & more", + "insert.chart.hierarchy", + vec![ + ("Treemap", "treemap", ""), + ("Sunburst", "sunburst", ""), + ("Waterfall", "waterfall", ""), + ("Funnel", "funnel", ""), + ("Radar", "radar", ""), + ("Stock", "stock", ""), + ("Combo", "combo", ""), + ], + ), + ] { + let r = small_button(ui, icon, "", tip, 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| { + for (label, k, s) in &subs { + if ui.button(*label).clicked() { + act(app, "insert.chart", json!({"type": k, "subtype": s})); + } + } + }); + } + }); + }); + sep(ui); + if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { + act(app, "insert.slicer", json!({})); + } + sep(ui); + let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); + anchor_keytip(app, ui, &sp, &["dialog:sparkline"]); + egui::Popup::menu(&sp).show(|ui| { + for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { + if ui.button(label).clicked() { + app.open_dialog("sparkline", json!({"type": kind})); + } + } + }); + sep(ui); + // Cell Controls. + if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { + act(app, "insert.checkbox", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); + control_keytip(app, ui, &__k, "dialog:insertLink"); + if __k.clicked() { + app.open_dialog("insertLink", json!({})); + } + let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { + app.open_dialog("comment", json!({"threaded": true})); + } + sep(ui); + let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false); + control_keytip(app, ui, &__k, "insert.textBox"); + if __k.clicked() { + act(app, "insert.textBox", json!({})); + } + let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); + control_keytip(app, ui, &__k, "dialog:headerFooter"); + if __k.clicked() { + // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). + crate::paper_view::start_editing(app); + } + let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); + control_keytip(app, ui, &sym, "insert.symbol"); + egui::Popup::menu(&sym).show(|ui| { + ui.set_width(260.0); + ui.horizontal_wrapped(|ui| { + for ch in [ + "€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓", + "✓", "★", "½", "¼", + ] { + // No bundled face has ✓: the button paints a code-drawn check instead of a tofu box. + if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() { + act(app, "insert.symbol", json!({"char": ch})); + } + } + }); + }); +} + +pub(super) fn draw(app: &mut SheetApp, ui: &mut Ui) { + let tool = app.session.draw_tool.clone(); + let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false); + control_keytip(app, ui, &__k, "draw.lasso"); + if __k.clicked() { + act(app, "draw.lasso", json!({})); + } + sep(ui); + for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] { + let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color); + let r = big_button(ui, Icon::Pen, label, "Draw with ink", false); + if on { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + let width = if label == "Highlighter" { 12.0 } else { 2.0 }; + act(app, "draw.pen", json!({"on": !on, "color": color, "width": width})); + } + } + let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false); + if tool == "eraser" { + ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if er.clicked() { + act(app, "draw.eraser", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false); + control_keytip(app, ui, &__k, "draw.inkToShape"); + if __k.clicked() { + act(app, "draw.inkToShape", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/page_layout.rs b/crates/ui-egui/src/ribbon/page_layout.rs new file mode 100644 index 000000000..c0f5230ef --- /dev/null +++ b/crates/ui-egui/src/ribbon/page_layout.rs @@ -0,0 +1,158 @@ +use super::controls::{act, anchor_keytip, checkbox_on, control_keytip, invoke, keytip, menu_items, sep}; +use super::{Color32, Icon, Sense, SheetApp, Tokens, Ui, big_button, json, keytip_table, vec2}; + +pub(super) fn page_layout(app: &mut SheetApp, ui: &mut Ui) { + let th = big_button(ui, Icon::Theme, "Themes", "Themes", true); + anchor_keytip(app, ui, &th, &["pageLayout.theme"]); + egui::Popup::menu(&th).show(|ui| { + for (name, colors) in gridcraft_engine::cmd::view::themes() { + ui.horizontal(|ui| { + for c in colors.iter().skip(4).take(6) { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 1.0, Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, *c as u8)); + } + if ui.button(name).clicked() { + act(app, "pageLayout.theme", json!({"name": name})); + } + }); + } + }); + sep(ui); + let m = big_button(ui, Icon::Margins, "Margins", "Margins", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) { + keytip(app, ui, sequence, m.rect.center_bottom()); + } + egui::Popup::menu(&m).show(|ui| { + menu_items( + app, + ui, + &[ + ("Normal", "pageLayout.margins", json!({"preset": "normal"})), + ("Wide", "pageLayout.margins", json!({"preset": "wide"})), + ("Narrow", "pageLayout.margins", json!({"preset": "narrow"})), + ("Custom Margins…", "dialog:pageSetup", json!({})), + ], + ); + }); + let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); + control_keytip(app, ui, &o, "pageLayout.orientation"); + egui::Popup::menu(&o).show(|ui| { + menu_items( + app, + ui, + &[ + ("Portrait", "pageLayout.orientation", json!({"orientation": "portrait"})), + ("Landscape", "pageLayout.orientation", json!({"orientation": "landscape"})), + ], + ); + }); + let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); + control_keytip(app, ui, &s, "pageLayout.size"); + egui::Popup::menu(&s).show(|ui| { + // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. + for p in [ + "Letter", + "Legal", + "Tabloid", + "Executive", + "A3", + "A4", + "A5", + "A6", + "B4", + "B5", + "B6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + "B4 (ISO)", + "B5 (ISO)", + "B6 (ISO)", + ] { + if ui.button(p).clicked() { + act(app, "pageLayout.size", json!({"paper": p})); + } + } + }); + let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true); + anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]); + egui::Popup::menu(&pa).show(|ui| { + menu_items( + app, + ui, + &[("Set Print Area", "pageLayout.printArea", json!({})), ("Clear Print Area", "pageLayout.printArea", json!({"clear": true}))], + ); + }); + let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) { + keytip(app, ui, sequence, br.rect.center_bottom()); + } + egui::Popup::menu(&br).show(|ui| { + menu_items( + app, + ui, + &[ + ("Insert Page Break", "pageLayout.breaks", json!({"insert": true})), + ("Remove Page Break", "pageLayout.breaks", json!({"remove": true})), + ("Reset All Page Breaks", "pageLayout.breaks", json!({"reset": true})), + ], + ); + }); + let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false); + control_keytip(app, ui, &__k, "pageLayout.printTitles"); + if __k.clicked() { + app.open_dialog("pageSetup", json!({"tab": "Sheet"})); + } + sep(ui); + let sh = app + .session + .active() + .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) + .unwrap_or((true, true, false, false)); + // These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in + // Excel), but the key is distinct from the "View" tab so languages can word them apart. + let screen = app.ui.language.tr("Sheet Options|View"); + let print = app.ui.language.tr("Print"); + ui.vertical(|ui| { + ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); + let mut v = sh.0; + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() || invoke(app, "view.gridlines") { + act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)})); + } + let mut p = sh.2; + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printGridlines"); + if resp.changed() || invoke(app, "pageLayout.printGridlines") { + act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)})); + } + }); + ui.vertical(|ui| { + ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); + let mut v = sh.1; + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() || invoke(app, "view.headings") { + act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)})); + } + let mut p = sh.3; + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printHeadings"); + if resp.changed() || invoke(app, "pageLayout.printHeadings") { + act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)})); + } + }); + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false); + if rtl { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + } + if r.clicked() { + act(app, "view.rightToLeft", json!({"on": !rtl})); + } +} diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs new file mode 100644 index 000000000..2f76148ed --- /dev/null +++ b/crates/ui-egui/src/ribbon/review.rs @@ -0,0 +1,82 @@ +use super::controls::{act, anchor_keytip, control_keytip, menu_items, sep}; +use super::{Icon, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, json, small_button}; + +pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) { + let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false); + control_keytip(app, ui, &__k, "dialog:spelling"); + if __k.clicked() { + app.open_dialog("spelling", json!({})); + } + let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false); + control_keytip(app, ui, &__k, "statistics"); + if __k.clicked() { + app.open_dialog("statistics", json!({})); + } + let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false); + control_keytip(app, ui, &__k, "dialog:accessibility"); + if __k.clicked() { + app.open_dialog("accessibility", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { + app.open_dialog("comment", json!({"threaded": true})); + } + let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); + control_keytip(app, ui, &__k, "review.deleteComment"); + if __k.clicked() { + act(app, "review.deleteComment", json!({})); + } + ui.vertical(|ui| { + for (icon, label, tip, id) in + [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] + { + if small_button(ui, icon, label, tip, false).clicked() { + act(app, id, json!({})); + app.grid.ensure_visible = true; + } + } + }); + let shown = app.session.comments_pane; + let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); + if shown { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + act(app, "review.showComments", json!({"on": !shown})); + if app.session.comments_pane { + app.grid.pane = None; + } + } + let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); + anchor_keytip(app, ui, ¬es, &["review.nextComment"]); + egui::Popup::menu(¬es).show(|ui| { + menu_items( + app, + ui, + &[ + ("New Note", "dialog:comment", json!({"threaded": false})), + ("Show/Hide Note", "review.showNote", json!({})), + ("Show All Notes", "review.showAllNotes", json!({})), + ("-", "", json!({})), + ("Convert to Comments", "review.convertToComments", json!({})), + ], + ); + }); + sep(ui); + let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); + control_keytip(app, ui, &__k, "dialog:protectSheet"); + if __k.clicked() { + app.open_dialog("protectSheet", json!({})); + } + let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); + control_keytip(app, ui, &__k, "review.protectWorkbook"); + if __k.clicked() { + crate::backstage::protect_workbook(app); + } + if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { + app.open_dialog("allowEditRanges", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/shortcuts.rs b/crates/ui-egui/src/ribbon/shortcuts.rs new file mode 100644 index 000000000..cd862cbd9 --- /dev/null +++ b/crates/ui-egui/src/ribbon/shortcuts.rs @@ -0,0 +1,113 @@ +use super::{Key, Modifiers, SheetApp, json}; + +/// Command-key shortcuts when the grid has focus. +pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { + let shift = m.shift; + let id: Option<(&str, serde_json::Value)> = match key { + Key::B => Some(("home.bold", json!({}))), + Key::I => Some(("home.italic", json!({}))), + Key::U => Some(("home.underline", json!({}))), + Key::Z if shift => Some(("edit.redo", json!({}))), + Key::Z => Some(("edit.undo", json!({}))), + Key::Y => Some(("edit.redo", json!({}))), + Key::A => Some(("edit.selectAll", json!({}))), + Key::D => Some(("edit.fillDown", json!({}))), + Key::R => Some(("edit.fillRight", json!({}))), + Key::S if shift => Some(("file.saveAs", json!({}))), + Key::S => Some(("file.save", json!({}))), + Key::O => Some(("file.open", json!({}))), + Key::N => Some(("file.new", json!({}))), + Key::W => Some(("file.close", json!({}))), + Key::P => { + app.print_now(json!({})); + None + } + Key::K => { + app.open_dialog("insertLink", json!({})); + None + } + Key::F if shift => Some(("data.filter", json!({}))), + Key::F => { + app.open_dialog("find", json!({})); + None + } + Key::H => { + app.open_dialog("find", json!({"replace": true})); + None + } + Key::G => { + app.open_dialog("goTo", json!({})); + None + } + Key::T if shift => Some(("formulas.autoSum", json!({}))), + Key::T => Some(("insert.table", json!({}))), + Key::E => Some(("edit.flashFill", json!({}))), + Key::Num1 => { + app.open_dialog("formatCells", json!({})); + None + } + Key::Num9 if shift => Some(("home.unhideRows", json!({}))), + Key::Num9 => Some(("home.hideRows", json!({}))), + Key::Num0 if shift => Some(("home.unhideColumns", json!({}))), + Key::Num0 => Some(("home.hideColumns", json!({}))), + // As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or + // delete them at once; any other selection asks which way to shift. + Key::Minus => match whole_lines(app) { + Some(true) => Some(("home.deleteRows", json!({}))), + Some(false) => Some(("home.deleteColumns", json!({}))), + None => { + app.open_dialog("deleteCells", json!({})); + None + } + }, + // Insert works on one area at a time, so several areas still go through the dialog. + // The numpad's + arrives as Plus without Shift; Excel takes Ctrl with it as well. + Key::Plus | Key::Equals if shift || key == Key::Plus => match whole_lines(app).filter(|_| single_area(app)) { + Some(true) => Some(("home.insertRows", json!({}))), + Some(false) => Some(("home.insertColumns", json!({}))), + None => { + app.open_dialog("insertCells", json!({})); + None + } + }, + Key::Semicolon => { + // Insert today's date. + let text = app + .session + .active() + .map(|d| { + let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text + }) + .unwrap_or_default(); + app.begin_edit(Some(text), false); + None + } + Key::Backtick => Some(("formulas.showFormulas", json!({}))), + _ => None, + }; + if let Some((id, p)) = id { + app.run_or_alert(id, p); + app.grid.ensure_visible = true; + } +} + +/// Whether the selection is a single area. +fn single_area(app: &SheetApp) -> bool { + app.session.active().is_some_and(|d| d.selection.ranges.len() == 1) +} + +/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole +/// columns, `None` otherwise (including the whole sheet). +fn whole_lines(app: &SheetApp) -> Option { + let sel = &app.session.active()?.selection; + let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols()); + let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows()); + if rows { + Some(true) + } else if cols { + Some(false) + } else { + None + } +} diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs new file mode 100644 index 000000000..9f7c2197b --- /dev/null +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -0,0 +1,274 @@ +use super::controls::{act, control_keytip}; +use super::{Color32, Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, icon_button, json, pos2, small_button, theme, theme_choices, vec2}; + +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit +/// vertically centred in it, with the controls beside them (#170). +const MAC: bool = cfg!(target_os = "macos"); + +pub const MAC_TITLE_BAR: f32 = 28.0; + +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; + +const MAC_LIGHT_PITCH: f32 = 20.0; + +const MAC_LIGHTS_LEFT: f32 = 10.0; + +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// Leave room for the macOS traffic lights when the content extends into the title bar. +pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; + +pub(super) const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; + +/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. +pub(super) const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; + +/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title. +pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let ch = TITLE_CONTROL_H; + egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { + let rect = ui.max_rect(); + // Dragging the empty title area moves the window. + let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag()); + if bg.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if bg.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + ui.horizontal_centered(|ui| { + ui.add_space(TITLE_LEFT_PAD); + // AutoSave toggle (saves after each change when the workbook has a file). + ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); + let on = app.ui.autosave; + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); + ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); + let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; + ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim }); + if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { + app.toggle_autosave(); + } + ui.add_space(6.0); + let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); + control_keytip(app, ui, &__k, "start"); + if __k.clicked() { + app.open_dialog("start", json!({})); + } + if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + && app.commit_edit(0, 0, false, false) + { + app.run_or_alert("file.save", json!({})); + } + // The commands' own availability: there must be a step, and no undo group may be open on + // the document (an action that spans several calls is still running). + let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); + let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); + // Named and marked enabled or not for assistive technology (and the UI tests). + let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); + undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo")); + if undo.clicked() && can_undo { + app.run_or_alert("edit.undo", json!({})); + } + let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); + egui::Popup::menu(&undo_list).show(|ui| { + let labels: Vec = if can_undo { + app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() + } else { + Vec::new() + }; + if labels.is_empty() { + ui.label("Can't Undo"); + } + for (i, l) in labels.iter().enumerate() { + if ui.button(format!("Undo {l}")).clicked() { + app.run_or_alert("edit.undo", json!({"steps": i + 1})); + } + } + }); + let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); + redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo")); + if redo.clicked() && can_redo { + app.run_or_alert("edit.redo", json!({})); + } + let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); + egui::Popup::menu(&more).show(|ui| { + for (label, id) in [ + ("New Workbook", "file.new"), + ("Open…", "file.open"), + ("Save As…", "file.saveAs"), + ("Sort A to Z", "data.sortAscending"), + ("Calculate Now", "formulas.calculateNow"), + ] { + if ui.button(label).clicked() { + app.run_or_alert(id, json!({})); + } + } + if ui.button("Print…").clicked() { + app.print_now(json!({})); + } + }); + let left_end = ui.min_rect().right(); + let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { + app.open_dialog("commandSearch", json!({})); + } + let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); + egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } + for (label, fmt) in + [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] + { + if ui.button(label).clicked() { + app.open_dialog("saveCopy", json!({"format": fmt})); + } + } + }); + let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "comments"); + if __k.clicked() { + app.open_dialog("comments", json!({})); + } + // Quick light/dark switch (#194); right-click offers System as well. The same + // choice lives in File › Display theme and the View tab. + let lang = app.ui.language; + let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0)); + let dark = ui.ctx().theme() == egui::Theme::Dark; + theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode"))); + if theme.clicked() { + act(app, "view.darkMode", json!({})); + } + theme.context_menu(|ui| theme_choices(app, ui)); + ui.min_rect().left() + }); + // Centred title, cut with "…" so it never runs under the controls on either side. + let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + let max_w = title_max_width(rect.center().x, left_end, right.inner); + paint_title(ui, rect.center(), &title, dirty, max_w, t.text); + }); + }); +} + +/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). +fn title_galleys( + painter: &egui::Painter, + title: &str, + dirty: bool, + max_w: f32, + color: Color32, +) -> (std::sync::Arc, Option>) { + let font = theme::ui_bold(13.0); + let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); + (painter.layout_job(job), dot) +} + +/// Gap kept between the title and the controls beside it. +const TITLE_GAP: f32 = 12.0; + +/// The widest the centred title may be without reaching the controls that end at `left_end` or +/// start at `right_start`. +fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { + let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; + (2.0 * half).max(0.0) +} + +/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved +/// marker stays visible however short the title gets. +fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let w = galley.size().x + dot_w; + let left = center.x - w / 2.0; + let top = center.y - galley.size().y / 2.0; + let galley_w = galley.size().x; + ui.painter().galley(pos2(left, top), galley, color); + if let Some(dot) = dot { + ui.painter().galley(pos2(left + galley_w, top), dot, color); + } +} + +#[cfg(test)] +mod title_bar_tests { + use super::*; + + #[test] + fn mac_title_bar_centres_and_clears_the_traffic_lights() { + // The lights are centred in the bar, and content keeps the same gap after them. + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + } + + #[test] + fn title_fits_between_the_controls() { + // Room limited by the nearer side, symmetric about the centre. + assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); + assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); + // Controls overlapping the centre leave no room rather than a negative width. + assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); + } + + /// Lay out a title in a real egui frame, returning (title text, title width, dot width). + fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = (String::new(), 0.0, 0.0); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); + // The glyphs actually drawn: `galley.text()` is the untruncated source. + let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); + out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); + }); + frame.textures_delta.clear(); // nothing consumes the atlas in a headless test + out + } + + #[test] + fn long_title_is_truncated_with_an_ellipsis() { + let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); + let (text, w, _) = layout_title(&long, false, 300.0); + assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); + assert!(text.chars().count() < long.chars().count()); + assert!(w <= 300.0, "title stays within its room: {w}"); + } + + #[test] + fn short_title_is_left_whole() { + let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); + assert_eq!(text, "Book1.xlsx"); + } + + #[test] + fn unsaved_dot_survives_truncation() { + let long = "a very long workbook name ".repeat(20); + let (text, w, dot_w) = layout_title(&long, true, 200.0); + assert!(dot_w > 0.0, "the unsaved marker is laid out"); + assert!(text.ends_with('…')); + assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); + } + + #[test] + fn no_room_draws_nothing_but_does_not_crash() { + let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); + assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); + } +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs new file mode 100644 index 000000000..34fa582ea --- /dev/null +++ b/crates/ui-egui/src/ribbon/view.rs @@ -0,0 +1,182 @@ +use super::controls::{act, checkbox_on, control_keytip, invoke, keytip, menu_items, popup_command, sep}; +use super::{Icon, Rect, SheetApp, Tokens, Ui, big_button, json, keytip_table, pos2, theme, vec2}; + +/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language. +pub(super) fn view(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let _ = t; + for (icon, label, cmd) in [ + (Icon::Normal, "Normal", "view.normal"), + (Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"), + (Icon::PageLayout, "Page\nLayout", "view.pageLayout"), + ] { + let __k = big_button(ui, icon, label, label, false); + control_keytip(app, ui, &__k, cmd); + if __k.clicked() { + act(app, cmd, json!({})); + } + } + sep(ui); + let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); + let lang = app.ui.language; + let page_layout = + app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); + // Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body. + ui.vertical(|ui| { + // Rulers exist only in Page Layout view. + let mut ruler = !app.session.hide_ruler; + let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))); + control_keytip(app, ui, &ruler_resp, "view.ruler"); + if ruler_resp.changed() { + act(app, "view.ruler", json!({"on": ruler})); + } + let mut g = s.0; + let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() { + act(app, "view.gridlines", json!({"on": g})); + } + }); + ui.add_space(6.0); + ui.vertical(|ui| { + let mut fb = app.session.prefs.formula_bar; + let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); + control_keytip(app, ui, &resp, "view.formulaBar"); + if resp.changed() || invoke(app, "view.formulaBar") { + act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)})); + } + let mut h = s.1; + let resp = ui.checkbox(&mut h, lang.tr("Headings")); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() { + act(app, "view.headings", json!({"on": h})); + } + }); + sep(ui); + let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false); + control_keytip(app, ui, &__k, "dialog:zoom"); + if __k.clicked() { + app.open_dialog("zoom", json!({})); + } + let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false); + control_keytip(app, ui, &__k, "view.zoom"); + if __k.clicked() || invoke(app, "view.zoom") { + act(app, "view.zoom", json!({"percent": 100})); + } + let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false); + control_keytip(app, ui, &__k, "view.zoomToSelection"); + if __k.clicked() { + let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0))); + act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); + } + sep(ui); + let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); + control_keytip(app, ui, &__k, "view.newWindow"); + if __k.clicked() { + act(app, "view.newWindow", json!({})); + } + let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); + control_keytip(app, ui, &__k, "view.arrangeAll"); + if __k.clicked() { + app.open_dialog("arrangeAll", json!({})); + } + let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) + { + keytip(app, ui, sequence, fp.rect.center_bottom()); + } + egui::Popup::menu(&fp).show(|ui| { + menu_items( + app, + ui, + &[ + ("Freeze Panes", "view.freezePanes", json!({})), + ("Freeze Top Row", "view.freezeTopRow", json!({})), + ("Freeze First Column", "view.freezeFirstColumn", json!({})), + ("Unfreeze Panes", "view.unfreezePanes", json!({})), + ], + ); + }); + switch_windows(app, ui); + ui.vertical(|ui| { + let label_response = ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let label = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + craft_ui::ComboBox::from_id_salt("display_theme") + .selected_text(lang.tr(label)) + .width(88.0) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + act(app, "view.theme", json!({"mode": value})); + } + } + }) + .response + .labelled_by(label_response.id); + let mut animations = app.ui.animations; + let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); + if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { + act(app, "view.animations", json!({"on": animations})); + } + }); + sep(ui); + // Interface size (issue #230): scales the whole window on top of the desktop's scale. + ui.vertical(|ui| { + let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); + let current = app.ui.interface_scale; + let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; + craft_ui::ComboBox::from_id_salt("ui_interface_size") + .width(120.0) + .selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { + for v in [100, 75, 90, 110, 125, 150, 175, 200] { + if ui.selectable_label(v == current, text(v)).clicked() { + act(app, "view.interfaceScale", json!({"percent": v})); + } + } + }) + .response + .labelled_by(label.id); + }); + // Interface language: settings live in the View tab, like Excel's Options. The label follows + // the current language so it stays readable; the choices are always shown in their own script. + ui.vertical(|ui| { + let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); + craft_ui::ComboBox::from_id_salt("ui_language") + .width(120.0) + .selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { + for l in crate::i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + act(app, "app.language.set", json!({"language": l.code()})); + } + } + }) + .response + .labelled_by(label.id); + }); +} + +/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; +/// choosing one brings it forward. +fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { + let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); + anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); + control_keytip(app, ui, &anchor, "view.switchWindows"); + let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + for w in app.open_windows() { + let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); + if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { + act(app, "view.switchWindows", json!({"window": w.window})); + ui.close(); + } + } + }); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 000000000..748e23ab3 --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,67 @@ +use super::printing; + +type PickSave = Box Option>; +type OpenUrl = Box; +type CopyHtml = Box Result<(), String>>; +type Download = Box; +type PrintPdf = Box; + +/// Host services the platform shell provides (file pickers, opening URLs). +#[derive(Default)] +pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, + pub pick_open: Option Option>>, + pub pick_save: Option, + /// Native picture picker (PNG/JPEG), separate from workbook opening. + pub pick_picture: Option Option>>, + /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot + /// insert into a subsequent dialog or open the image as a workbook. + pub pick_picture_async: Option>, + pub open_url: Option, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option, + /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the + /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from + /// the host pastes the plain text alone. + pub paste_html: Option Option>>, + /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V + /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes + /// GridCraft's own clipboard. + pub paste_text: Option Option>>, + /// Web: deliver a file to the user (name, bytes). + pub download: Option, + /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. + pub open_async: Option>, + /// Files delivered asynchronously (name, bytes), opened on the next frame. + pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option, +} + +/// Shared queue of files read asynchronously (browser file picker, drag and drop). +pub type Inbox = std::sync::Arc)>>>; + +/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host +/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar +/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the +/// host, not a user preference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Chrome { + /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and + /// the theme switch. + pub title_bar: bool, + /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and + /// commands such as `ui.backstage`). + pub file_tab: bool, +} + +impl Default for Chrome { + fn default() -> Self { + Chrome { title_bar: true, file_tab: true } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 000000000..d9e6ceaaa --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,40 @@ +/// Per-sheet view state (scroll position in sheet points). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SheetView { + pub scroll: egui::Vec2, +} + +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// An answer to the Save Changes question. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAnswer { + Save, + DontSave, + Cancel, +} + +/// One open window, as View › Switch Windows and the macOS Window menu list it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenWindow { + /// The window's id, the `window` param of `view.switchWindows`. + pub window: u64, + /// Its workbook's `DocState::uid`. + pub doc: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +impl OpenWindow { + /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it + /// while the workbook has unsaved changes. + pub fn label(&self) -> String { + if self.dirty { format!("{} •", self.title) } else { self.title.clone() } + } +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index d49f09e90..a730e77d8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -340,6 +340,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Point => "Point", }; text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + crate::macro_ui::status(app, ui); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); @@ -412,8 +413,20 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } ui.add_space(16.0); // The layout runs right to left, so the last statistic is drawn first. + // Clicking a statistic copies its value (without the label) to the clipboard. for p in selection_statistics(app).into_iter().rev() { - text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)); + let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned(); + let resp = ui.add( + egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim)) + .selectable(false) + .sense(Sense::click()), + ); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(app.ui.language.tr("Click to copy")); + if resp.clicked() { + ui.ctx().copy_text(value); + } + // The label senses clicks, so it also has to open the statistics menu on right-click. + resp.context_menu(|ui| status_bar_menu(app, ui)); ui.add_space(10.0); } }); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 478b8b86b..f37eb0c35 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -20,27 +20,22 @@ use std::sync::Arc; use egui::epaint::text::Blob; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; -/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see -/// and its `docs/integration.md`). The slice is empty -/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. -#[derive(Debug)] -pub struct CraftFont { - pub family: &'static str, - pub style: &'static str, - /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. - pub scripts: &'static [&'static str], - pub bytes: &'static [u8], -} +mod font_fallback; +mod font_setup; -mod craft_fonts { - use super::CraftFont; - include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); -} - -/// The light or dark value of a colour, by mode. -fn pick(dark: bool, light: T, dark_value: T) -> T { - if dark { dark_value } else { light } -} +pub use font_fallback::symbol_face_installed; +#[cfg(test)] +use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai}; +use font_setup::pick; +pub use font_setup::{ + CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width, + chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions, + font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font, +}; +#[cfg(test)] +use font_setup::{candidates, craft_font_faces, try_load}; +#[cfg(test)] +pub(crate) use font_setup::{drop_unparseable_fonts, font_parses}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { @@ -109,6 +104,16 @@ pub struct Tokens { pub sheet_control_text: Color32, /// A placeholder fill on the sheet (a picture that has not loaded). pub sheet_track: Color32, + /// The macro recording dot (Record Macro, the status bar). + pub recording: Color32, + /// A breakpoint dot in the macro step editor's gutter. + pub breakpoint: Color32, + /// Macro overlays on the sheet (Check and debugging): the inputs, the cells the macro writes, + /// the cells it would write over, and the current step's target. Sheet colours. + pub sheet_macro_input: Color32, + pub sheet_macro_footprint: Color32, + pub sheet_macro_conflict: Color32, + pub sheet_macro_target: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -136,6 +141,12 @@ const fn rgb(hex: u32) -> Color32 { /// theme). The light theme's formula bar is white too, so it uses them as well. const SHEET_REF_COLORS: [Color32; 8] = [rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)]; +/// Macro overlays on the sheet, the same in both themes (the sheet stays white). +const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8); +const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D); +const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C); +const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0); + /// Code colours for one background; plain text uses the surface's own text colour. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Syntax { @@ -227,6 +238,12 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)), + breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)), + sheet_macro_input: SHEET_MACRO_INPUT, + sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, + sheet_macro_conflict: SHEET_MACRO_CONFLICT, + sheet_macro_target: SHEET_MACRO_TARGET, } } @@ -261,816 +278,6 @@ pub fn contrast(a: Color32, b: Color32) -> f32 { (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } -pub const UI: &str = "ui"; -pub const UI_BOLD: &str = "ui-bold"; -pub const CELL: &str = "cell"; -pub const CELL_BOLD: &str = "cell-bold"; -pub const CELL_ITALIC: &str = "cell-italic"; -pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; -pub const SERIF: &str = "serif"; -pub const MONO: &str = "mono"; - -pub fn ui_font(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI.into())) -} -pub fn ui_bold(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI_BOLD.into())) -} - -/// The font family to use for a cell font name and weight. -/// -/// When the name is an installed font whose faces were bound by [`font_definitions`], that family -/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui -/// panics on a family that was never bound, so the installed branch is gated on -/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. -pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { - let role = crate::fonts::role_key(name, bold, italic); - if crate::fonts::is_bound(&role) { - return FontFamily::Name(role.into()); - } - let lower = name.to_ascii_lowercase(); - if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { - return FontFamily::Name(MONO.into()); - } - if lower.contains("times") - || lower.contains("georgia") - || lower.contains("cambria") - || lower.contains("serif") && !lower.contains("sans") - || lower.contains("garamond") - { - return FontFamily::Name(SERIF.into()); - } - FontFamily::Name( - match (bold, italic) { - (true, true) => CELL_BOLD_ITALIC, - (true, false) => CELL_BOLD, - (false, true) => CELL_ITALIC, - (false, false) => CELL, - } - .into(), - ) -} - -/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the -/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower -/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the -/// next frame) falls back to the engine's estimate rather than letting egui panic on it. -/// -/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph -/// advances are summed without kerning, which is cheap and never narrower. This takes the -/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. -pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { - let family = cell_family(&font.name, font.bold, font.italic); - let id = FontId::new(font.size * 96.0 / 72.0, family); - ctx.fonts_mut(|f| { - f.families().contains(&id.family).then(|| { - if exact { - f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x - } else { - // Half a point covers the galley's rounding of its width up to the pixel grid. - text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 - } - }) - }) - .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) -} - -/// Whether epaint can open face `index` of this font file and it maps characters to glyphs. -/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font -/// file we did not ship is checked first with the same parser and the same call epaint makes -/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a -/// collection index past the end is rejected instead of crashing the app (#226). A face whose -/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused -/// too and the next candidate gets its turn. -pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { - use skrifa::MetadataProvider; - skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map()) -} - -/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is -/// skipped with a warning and the next candidate is tried. -#[cfg(not(target_arch = "wasm32"))] -fn try_load(paths: &[(&str, u32)]) -> Option { - for (p, index) in paths { - if let Some(blob) = font_file(p, *index) { - return Some(FontData::from_blob(blob, *index)); - } - } - None -} - -/// The interface and script fallback font files, each read once per run and kept as a shared -/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text -/// engine, because every one of these files is loaded by every font rebuild for as long as the -/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next -/// time. -#[cfg(not(target_arch = "wasm32"))] -fn font_file(path: &str, index: u32) -> Option { - static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); - let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(blob) = files.get(path) { - let bytes: &[u8] = (**blob).as_ref(); - return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); - } - let bytes = std::fs::read(path).ok()?; - if !font_parses(&bytes, index) { - log::warn!("skipping font {path} (face {index}): not a font file egui can read"); - return None; - } - let blob: Blob = std::sync::Arc::new(bytes); - files.insert(path.to_string(), blob.clone()); - Some(blob) -} - -/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not -/// parse (and its key from every family), so a bad font costs a fallback, never a crash. -pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { - let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect(); - for key in &bad { - log::warn!("dropping font {key:?}: egui can't parse it"); - fonts.font_data.remove(key); - } - if !bad.is_empty() { - for chain in fonts.families.values_mut() { - chain.retain(|k| !bad.contains(k)); - } - } -} - -/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` -/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. -const HACK: &str = "Hack"; - -/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty -/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale -/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. -fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { - let wasm = cfg!(target_arch = "wasm32"); - craft_fonts::CRAFT_FONTS - .iter() - .enumerate() - .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) - .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) - .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) - .collect() -} - -/// Adds the optional craft-fonts faces to `fonts` and returns their keys. -fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { - let mut keys = Vec::new(); - for (key, font) in craft_font_faces() { - fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); - keys.push(key); - } - keys -} - -/// Whether the optional craft-fonts input was embedded in this build. -pub fn craft_fonts_loaded() -> bool { - !craft_fonts::CRAFT_FONTS.is_empty() -} - -/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web -/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. -pub fn chinese_font_loaded() -> bool { - craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") -} - -/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather -/// than skip when this is true and the faces are absent. -pub fn craft_fonts_required() -> bool { - craft_fonts::CRAFT_FONTS_REQUIRED -} - -/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol -/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a -/// display fallback and never changes the characters a command inserts or stores. -pub fn symbol_fallback(c: char) -> Option { - match c { - '✕' => Some('×'), - '✓' => Some('✔'), - '⌃' => Some('^'), - _ => None, - } -} - -/// `text` with every character the current font set cannot draw replaced by its semantic fallback -/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints -/// a tofu box; it is not applied to workbook text. -pub fn available_text(ui: &egui::Ui, text: &str) -> String { - let font = ui_font(13.0); - text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() -} - -/// The family, style and scripts of every embedded craft-fonts face (empty without the input). -pub fn craft_font_scripts() -> impl Iterator { - craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) -} - -/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use -/// this to tell "the pinned fixture covers this script" from "this host has no face for it", -/// without trusting a manifest script tag alone. -pub fn craft_fonts_cover(text: &str) -> bool { - craft_font_faces().iter().any(|(_, f)| { - use skrifa::MetadataProvider; - let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { - return false; - }; - let map = font.charmap(); - text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) - }) -} - -#[cfg(target_arch = "wasm32")] -fn try_load(_paths: &[(&str, u32)]) -> Option { - None -} - -fn home_fonts(file: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{home}/Library/Fonts/{file}") -} - -/// Every file in `files` under every directory in `dirs`, directory first, in the same order a -/// nested loop over the directories would add them. -fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { - dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() -} - -/// Candidate font files per role, in preference order. -fn candidates(role: &str) -> Vec<(String, u32)> { - let mac_sys = "/System/Library/Fonts"; - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; - let mut v: Vec<(String, u32)> = Vec::new(); - let mut add = |s: String, i: u32| v.push((s, i)); - match role { - "ui" => { - add(format!("{mac_sys}/SFNS.ttf"), 0); - add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); - add(format!("{win}\\segoeui.ttf"), 0); - for p in in_dirs( - &lin, - &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], - ) { - add(p, 0); - } - // Fedora keeps each family in its own package folder (#54). - add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); - add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); - } - "ui-bold" => { - add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); - add(format!("{win}\\segoeuib.ttf"), 0); - for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); - } - "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { - let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { - "cell" => { - ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") - } - "cell-bold" => ( - "Carlito-Bold.ttf", - "calibrib.ttf", - "Calibri Bold.ttf", - "Arial Bold.ttf", - "arialbd.ttf", - "LiberationSans-Bold.ttf", - "DejaVuSans-Bold.ttf", - ), - "cell-italic" => ( - "Carlito-Italic.ttf", - "calibrii.ttf", - "Calibri Italic.ttf", - "Arial Italic.ttf", - "ariali.ttf", - "LiberationSans-Italic.ttf", - "DejaVuSans-Oblique.ttf", - ), - _ => ( - "Carlito-BoldItalic.ttf", - "calibriz.ttf", - "Calibri Bold Italic.ttf", - "Arial Bold Italic.ttf", - "arialbi.ttf", - "LiberationSans-BoldItalic.ttf", - "DejaVuSans-BoldOblique.ttf", - ), - }; - // Calibri if the user has it installed system-wide (never from an Office bundle). - add(format!("/Library/Fonts/{calibri_mac}"), 0); - add(home_fonts(calibri_mac), 0); - add(format!("{win}\\{calibri_win}"), 0); - for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { - add(p, 0); - } - // Fedora keeps each family in its own package folder, as for the UI fonts (#54). - add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); - add(home_fonts(carlito), 0); - add(format!("{mac_sup}/{arial_mac}"), 0); - add(format!("{win}\\{arial_win}"), 0); - for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { - add(p, 0); - } - add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); - add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); - } - "serif" => { - add(format!("{mac_sup}/Times New Roman.ttf"), 0); - add(format!("{win}\\times.ttf"), 0); - for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); - } - "mono" => { - add(format!("{mac_sys}/SFNSMono.ttf"), 0); - add(format!("{mac_sys}/Menlo.ttc"), 0); - add(format!("{win}\\consola.ttf"), 0); - for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); - // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice - // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. - for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); - add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); - } - _ => {} - } - v -} - -/// Builds the font set from system fonts (with egui's defaults as fallback), with the default -/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. -pub fn font_definitions() -> FontDefinitions { - font_definitions_for_language(crate::i18n::Language::En) -} - -/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only -/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two -/// languages with the same order needs no font rebuild. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HanOrder { - /// Japanese glyph shapes first (the interface is Japanese). - Japanese, - /// Simplified Chinese first: the widest Han coverage, for every other interface language. - Chinese, -} - -impl HanOrder { - pub fn of(language: crate::i18n::Language) -> Self { - if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } - } -} - -/// Builds the font set with the Han fallback faces ordered for the interface language. -pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { - font_definitions_with(language, &[]) -} - -/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed -/// workbook-font binding. -/// -/// Integration tests use this through their shared harness, so a minimal CI image and a developer -/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository -/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. -pub fn pinned_font_definitions() -> FontDefinitions { - font_definitions_impl(crate::i18n::Language::En, &[], false) -} - -/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are -/// installed, so cells set in them render with them; the rest fall back to the roles. The bound -/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. -pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { - font_definitions_impl(language, used, true) -} - -/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. -fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { - let mut fonts = FontDefinitions::default(); - let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); - let mut bound: Vec = Vec::new(); - // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, - // and epaint picks the first face that has the glyph, so they only catch their own scripts. - let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; - let hack = fonts.font_data.contains_key(HACK).then_some(HACK); - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); - fonts.families.insert(FontFamily::Name(role.into()), chain); - bound.push(role.to_string()); - } - // Installed workbook fonts, so a cell's chosen font actually renders in it. - if system { - for family in crate::fonts::installed_among(used) { - bound.extend(crate::fonts::bind_family(&mut fonts, &family)); - } - } - // Interface text (formula bar, name box, dialogs) falls back to the cell font before the - // script faces: whatever a cell can draw, the formula bar can draw too, even where no script - // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). - bind_cell_fallback(&mut fonts, &fallback); - // Default proportional text in widgets uses the UI font. - if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { - fonts.families.insert(FontFamily::Proportional, ui); - } - // The optional craft-fonts faces go at the very end of every family: a system or bundled face - // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and - // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). - // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still - // leads, and the barred Noto CJK collection never enters. - append_craft_faces(&mut fonts); - drop_unparseable_fonts(&mut fonts); - // Only families egui still has data for count as bound (a dropped face can empty a chain). - bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); - crate::fonts::set_bound(bound); - fonts -} - -/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled -/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data -/// are dropped, as before. -fn build_role_chain( - fonts: &mut FontDefinitions, - role: &str, - base_prop: &[String], - base_mono: &[String], - fallback: &[String], - hack: Option<&str>, - system: bool, -) -> Vec { - let paths = candidates(role); - let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - let mut chain: Vec = Vec::new(); - let loaded = if system { try_load(&refs) } else { None }; - if let Some(mut fd) = loaded { - // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina - // screen (#1184). Core Text never runs them, so this draws it the way macOS does. - if cfg!(target_os = "macos") && role == UI_BOLD { - fd.tweak.hinting = Some(false); - } - let key = format!("sys-{role}"); - fonts.font_data.insert(key.clone(), Arc::new(fd)); - chain.push(key); - } else if role == UI_BOLD { - chain.push(format!("sys-{UI}")); - } else if role.starts_with("cell-") { - chain.push(format!("sys-{CELL}")); - } - chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); - // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but - // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as - // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it - // only catches characters those do not have. - if let Some(hack) = hack - && !chain.iter().any(|k| k.as_str() == hack) - { - chain.push(hack.to_string()); - } - chain.extend(fallback.iter().cloned()); - chain.retain(|k| fonts.font_data.contains_key(k)); - chain -} - -/// Inserts the loaded cell face before the script fallbacks of the interface families, so the -/// formula bar and dialogs can draw whatever a cell can (#349). -fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { - let cell_key = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell_key) { - return; - } - for role in [UI, UI_BOLD, SERIF, MONO] { - if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) - && !chain.contains(&cell_key) - { - let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); - chain.insert(at, cell_key.clone()); - } - } -} - -/// Appends the permitted craft-fonts faces to every family, preserving the leading system or -/// bundled face's glyph shapes. -fn append_craft_faces(fonts: &mut FontDefinitions) { - let craft = add_craft_fonts(fonts); - if craft.is_empty() { - return; - } - for chain in fonts.families.values_mut() { - for key in &craft { - if !chain.contains(key) { - chain.push(key.clone()); - } - } - } -} - -/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so -/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so -/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text -/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing -/// is bundled, and a machine without the face keeps the old behaviour. -#[cfg(not(target_arch = "wasm32"))] -fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { - let mut keys = Vec::new(); - // Files already loaded as a fallback face. A file serves at most one key: the region-merged - // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and - // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. - let mut loaded: Vec = Vec::new(); - for (key, cands) in fallback_faces(han) { - for (path, index) in &cands { - if loaded.contains(path) { - break; // a face loaded for an earlier script already covers this one - } - if let Some(fd) = try_load(&[(path.as_str(), *index)]) { - fonts.font_data.insert(key.to_string(), Arc::new(fd)); - keys.push(key.to_string()); - loaded.push(path.clone()); - break; - } - } - } - keys -} - -#[cfg(target_arch = "wasm32")] -fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { - Vec::new() -} - -/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script -/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one -/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so -/// the interface language reorders the two Han entries and nothing else. -#[cfg(not(target_arch = "wasm32"))] -fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { - let ja = ("sys-cjk-han-ja", cjk_han_japanese()); - let zh = ("sys-cjk-han-zh", cjk_han_simplified()); - let (first, second) = match han { - HanOrder::Japanese => (ja, zh), - HanOrder::Chinese => (zh, ja), - }; - vec![ - first, - second, - ("sys-cjk-hangul", cjk_hangul()), - ("sys-thai", thai()), - ("sys-lao", lao()), - ("sys-hebrew", hebrew()), - ("sys-symbols", symbols()), - ("sys-symbols-2", symbols_more()), - ] -} - -/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux -/// container (`chromeos/notocjk`, #1762), plus local and -/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. -#[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 22] = [ - // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same - // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-cjk", - "/usr/share/fonts/noto-cjk", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts/TTF", - "/usr/share/fonts", - "/usr/local/share/fonts", - "/usr/local/share/fonts/noto-cjk", - "/usr/share/fonts/chromeos/notocjk", -]; - -/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). -#[cfg(not(target_arch = "wasm32"))] -fn windows_fonts() -> String { - std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) -} - -/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the -/// region-merged TTC at `face`, in every Linux font directory. -#[cfg(not(target_arch = "wasm32"))] -fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { - let mut v = Vec::new(); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); - } - v -} - -/// Japanese Han + Kana faces (Japanese kanji glyph shapes). -/// -/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, -/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list -/// comes first loads it; [`load_fallback`] does not load the same file again for the other. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_japanese() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic - v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic - v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans - v.extend(noto_cjk("jp", 0)); - v -} - -/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks -/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_simplified() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei - (format!("{win}\\simsun.ttc"), 0), // SimSun - (format!("{win}\\simhei.ttf"), 0), // SimHei - (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS - (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset - ]; - v.extend(noto_cjk("sc", 2)); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wqy-microhei.ttc"), 0)); - } - v -} - -/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file -/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_hangul() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic - v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); - v.extend(noto_cjk("kr", 1)); - v -} - -/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); -/// Linux font directories searched by the Thai and Hebrew faces, in priority order. -#[cfg(not(target_arch = "wasm32"))] -fn linux_noto_dirs() -> [&'static str; 5] { - [ - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts", - "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) - ] -} - -/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the -/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. -#[cfg(not(target_arch = "wasm32"))] -fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { - let w = windows_fonts(); - let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); - v.extend(mac.iter().map(|p| (p.to_string(), 0))); - v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); - v.push((variable.into(), 0)); - v -} - -/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. -#[cfg(not(target_arch = "wasm32"))] -fn thai() -> Vec<(String, u32)> { - script_fonts( - // Leelawadee UI, Tahoma, Microsoft Sans Serif - &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], - &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], - &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], - // Fedora ships the variable Noto Sans Thai; its default instance is Regular. - "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", - ) -} - -/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam -/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. -#[cfg(not(target_arch = "wasm32"))] -fn lao() -> Vec<(String, u32)> { - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = windows_fonts(); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI - (format!("{win}\\dokchamp.ttf"), 0), // DokChampa - (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), - (format!("{mac_sup}/Lao MN.ttc"), 0), - ]; - for d in lin { - v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); - v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); - v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); - } - v -} - -/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has -/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. -#[cfg(not(target_arch = "wasm32"))] -fn hebrew() -> Vec<(String, u32)> { - script_fonts( - // David, FrankRuehl, Tahoma - &["david.ttf", "frank.ttf", "tahoma.ttf"], - &[ - "/System/Library/Fonts/ArialHB.ttc", - "/System/Library/Fonts/SFHebrew.ttf", - "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", - "/System/Library/Fonts/Supplemental/David.ttf", - "/System/Library/Fonts/LucidaGrande.ttc", - "/System/Library/Fonts/Lucida Grande.ttc", - ], - &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], - "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", - ) -} - -/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's -/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), -/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover -/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already -/// loaded it. -#[cfg(not(target_arch = "wasm32"))] -fn symbols() -> Vec<(String, u32)> { - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; - for d in [ - "/usr/share/fonts/truetype/dejavu", - "/usr/share/fonts/dejavu-sans-fonts", - "/usr/share/fonts/TTF", - "/usr/share/fonts/dejavu", - "/usr/share/fonts/truetype/ttf-dejavu", - "/usr/local/share/fonts/dejavu", - "/usr/local/share/fonts", - ] { - v.push((format!("{d}/DejaVuSans.ttf"), 0)); - } - v -} - -/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans -/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. -#[cfg(not(target_arch = "wasm32"))] -fn symbols_more() -> Vec<(String, u32)> { - let mut v: Vec<(String, u32)> = Vec::new(); - for d in [ - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-sans-symbols-2-fonts", - "/usr/share/fonts/google-noto", - "/usr/local/share/fonts/noto", - "/usr/share/fonts/chromeos/noto", - ] { - v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); - } - v -} - -/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). -pub fn symbol_face_installed() -> bool { - #[cfg(not(target_arch = "wasm32"))] - { - symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) - } - #[cfg(target_arch = "wasm32")] - { - false - } -} - /// Installs light and dark token visuals in both theme slots, keeps the 2-3 px widget and 8-10 px window/menu corner radii from 0.36, and sets spacing, text styles and the active theme. pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. @@ -1120,54 +327,6 @@ pub fn color32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } -#[cfg(test)] -mod font_binding_tests { - use super::*; - - /// The families [`font_definitions`] always binds, whatever is installed. - fn always_bound() -> Vec { - [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() - } - - #[test] - fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // Nothing installed: every name buckets to a role that font_definitions bound. - crate::fonts::set_bound(always_bound()); - for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { - for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { - let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; - assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); - } - } - } - - #[test] - fn a_bound_installed_font_is_used_by_name() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let role = crate::fonts::role_key("Ubuntu", true, false); - crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); - assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); - // The same family in a style that wasn't bound falls back to a built-in role that is. - let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; - assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); - crate::fonts::set_bound(always_bound()); - } - - #[test] - fn arbitrary_names_never_yield_an_unbound_family() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // A workbook can hold any font name; none may name a family egui has no data for. - crate::fonts::set_bound(always_bound()); - for seed in 0..500u32 { - let name: String = - (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); - let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; - assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); - } - } -} - #[cfg(test)] mod contrast_tests { use super::*; @@ -1226,6 +385,22 @@ mod contrast_tests { add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); + add("recording dot on status bar", t.recording, t.status_bar, GLYPH); + for (name, c) in [ + ("macro input", t.sheet_macro_input), + ("macro footprint", t.sheet_macro_footprint), + ("macro conflict", t.sheet_macro_conflict), + ("macro target", t.sheet_macro_target), + ] { + add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH); + } + // The macro step editor: breakpoint dots in the gutter, error markers, the current step. + add("breakpoint on window", t.breakpoint, t.window, GLYPH); + add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH); + add("text on current step", t.text, t.accent_soft, TEXT); + add("current step arrow", t.accent, t.accent_soft, GLYPH); + add("error marker on window", t.danger, t.window, GLYPH); v } @@ -1260,6 +435,10 @@ mod contrast_tests { (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track), (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track) ); + assert_eq!( + (l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target), + (d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target) + ); for c in Cat::ALL { assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own"); } @@ -1267,363 +446,4 @@ mod contrast_tests { } #[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { - use super::*; - use crate::i18n::Language; - - #[test] - /// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. - fn fallback_faces_are_appended_to_every_family() { - // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain - // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. - for han in [HanOrder::Chinese, HanOrder::Japanese] { - let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; - let fonts = font_definitions_for_language(language); - let fb_keys: Vec = - fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); - if fb_keys.is_empty() { - return; // no fallback face on this machine (minimal Linux) - } - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - // The optional craft-fonts faces follow the system fallbacks at the very end. - let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); - assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); - assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); - } - } - } - - #[test] - /// Every font family ends with exactly the permitted craft faces in manifest order. - fn craft_fonts_are_the_last_fallback_in_every_family() { - // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. - // A craft face must never displace a system face that already covers a glyph. - let fonts = font_definitions_for_language(Language::En); - let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); - if expected.is_empty() { - return; // optional input absent - } - for (family, chain) in &fonts.families { - let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); - assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); - } - } - - #[test] - /// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. - fn a_required_native_build_embeds_the_chinese_face() { - if cfg!(target_arch = "wasm32") || !craft_fonts_required() { - return; - } - assert!( - chinese_font_loaded(), - "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" - ); - let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); - assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); - } - - #[test] - /// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. - fn barred_faces_never_enter_the_font_set() { - for (family, _, _) in craft_font_scripts() { - assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); - assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); - } - } - - #[test] - /// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. - fn the_required_craft_faces_keep_their_exact_scripts() { - if !craft_fonts_loaded() { - assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); - return; - } - let faces: Vec<_> = craft_font_scripts().collect(); - for (family, style, scripts) in [ - ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), - ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), - ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), - ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), - ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), - ] { - assert!( - faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), - "{family} {style} with {scripts:?} must be embedded; got {faces:?}" - ); - } - } - - #[test] - /// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. - fn available_text_maps_unbundled_chrome_symbols() { - // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is - // never rewritten: this helper is only used for interface tooltips. - let ctx = egui::Context::default(); - ctx.set_fonts(FontDefinitions::default()); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); - }); - out.textures_delta.clear(); - } - - #[test] - fn interface_families_fall_back_to_the_cell_font() { - // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, - // so each interface chain carries the cell face, ahead of the script fallback faces. - let fonts = font_definitions_for_language(Language::En); - let cell = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell) { - return; // no system cell font on this machine - } - let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); - for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { - let chain = fonts.families.get(&family).cloned().unwrap_or_default(); - let at = chain.iter().position(|k| *k == cell); - assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); - let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); - assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); - } - } - - #[test] - fn language_reorders_only_the_han_faces() { - let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!( - keys(HanOrder::Japanese), - ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!( - keys(HanOrder::Chinese), - ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { - assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); - } - } - - #[test] - fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { - // Exercise the candidates the fallback loader actually consumes, not just the directory - // constant. This must work on CI without an installed CJK font or a Flatpak runtime. - for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { - for directory in [ - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - ] { - let path = format!("{directory}/NotoSansCJK-Regular.ttc"); - assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); - } - } - let chinese = cjk_han_simplified(); - assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); - assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); - } - - #[test] - /// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. - fn fallback_faces_load_each_file_once() { - // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded - // once, not once per script (tens of MB each). - for language in [Language::En, Language::Ja] { - let fonts = font_definitions_for_language(language); - let present: Vec<&Arc> = - fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); - for (i, a) in present.iter().enumerate() { - for b in present.iter().skip(i + 1) { - assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); - } - } - } - } - - #[test] - /// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. - fn fallback_covers_its_script() { - // Real glyph check, per script: the proportional family (what cells and the UI use) must - // have a glyph for every non-space character in the sample. A script runs when a system - // face or the optional craft-fonts input provides one; the printed skip is a recorded - // gate, and a present face that is not wired in fails. - let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ - ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), - // Chinese uses a system Han face or the native-only permitted LXGW craft face; the - // barred Noto CJK collection never supplies it. - ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), - ("sys-cjk-hangul", cjk_hangul(), "한글"), - ("sys-thai", thai(), "ทดสอบ"), - ("sys-lao", lao(), "ພາສາລາວ"), - ("sys-hebrew", hebrew(), "עברית"), - ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), - ("craft-noto-sans-georgian", Vec::new(), "ქართული"), - ("ui", candidates(UI), "Русский"), - // Czech interface labels: every Czech accented letter, in both cases. - ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), - ]; - for language in [Language::En, Language::Ja] { - let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions_for_language(language)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { - let font = FontId::new(14.0, family.clone()); - for (key, cands, sample) in &cases { - // A script runs when a system face or the optional craft-fonts input provides - // one. The skip is printed: a missing fixture is a recorded gate, not a pass. - let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); - if !system && !craft_fonts_cover(sample) { - eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); - continue; - } - let missing: String = - sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); - assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); - } - } - } - } - - #[test] - fn chromeos_font_layout_is_probed() { - // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per - // family set; a script whose list misses it renders as tofu with the font installed. - let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); - let cros = "/usr/share/fonts/chromeos"; - assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); - assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); - assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); - assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); - assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); - } - - #[test] - /// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. - fn unparseable_font_files_are_rejected_not_fatal() { - // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of - // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - assert!(font_parses(&real, 0), "egui's own font must parse"); - assert!(!font_parses(b"", 0)); - assert!(!font_parses(&[0xAB; 64], 0)); - assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); - assert!(!font_parses(&real, 3), "a single-face file has no face 3"); - - // A bad face that slipped into the definitions is dropped (key and family entries), and the - // context builds its fonts without panicking. - let mut fonts = font_definitions(); - fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); - for chain in fonts.families.values_mut() { - chain.push("sys-hebrew".into()); - } - drop_unparseable_fonts(&mut fonts); - assert!(!fonts.font_data.contains_key("sys-hebrew")); - assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); - let ctx = egui::Context::default(); - ctx.set_fonts(fonts); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.label("Hebrew עברית"); - }); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// A broken existing font candidate is skipped in favor of a later readable candidate. - fn try_load_skips_a_broken_candidate() { - // A candidate that exists but is not a readable font must not win over a good one behind it. - let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let bad = dir.join("broken.ttf"); - let good = dir.join("good.ttf"); - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); - let _ = std::fs::write(&good, &real); - let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); - assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); - let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); - assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. - fn interface_fonts_are_read_once_and_shared_with_egui() { - // egui copies owned font bytes into its text engine and keeps the original too; borrowed - // bytes it shares. A rebuild of the font set reuses the file read the first time. - let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); - std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); - let p = path.to_string_lossy().into_owned(); - let first = try_load(&[(p.as_str(), 0)]).unwrap(); - std::fs::remove_file(&path).unwrap(); - let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); - assert!( - std::sync::Arc::ptr_eq(&first.font, &second.font), - "interface font bytes must be one shared blob, so egui shares them instead of copying" - ); - assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); - assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); - } - - #[test] - fn macos_bold_interface_face_is_drawn_unhinted() { - // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a - // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every - // other app draws it; the regular face and the other platforms keep egui's default. - let fonts = font_definitions_for_language(Language::En); - let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); - if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { - assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); - } - if let Some(regular) = hinting(&format!("sys-{UI}")) { - assert_eq!(regular, None, "only the bold face is affected"); - } - } - - #[test] - fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { - // #1694: Fedora keeps each font family in its own package folder, so without these entries - // no cell face is found there and bold and italic cells draw as regular text. Each comes - // after the existing Linux folders for the same font, so wherever one of those matched, - // nothing changes. - let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); - let cases: [(&str, Vec, &str, &str); 9] = [ - (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), - (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), - (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), - (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), - (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), - ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), - ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), - ]; - for (role, list, fedora_file, earlier) in &cases { - let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); - let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); - assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); - assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); - assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); - } - // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. - let mono = paths(candidates(MONO)); - let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); - let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); - assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); - let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); - assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/theme/font_fallback.rs b/crates/ui-egui/src/theme/font_fallback.rs new file mode 100644 index 000000000..1f5d70150 --- /dev/null +++ b/crates/ui-egui/src/theme/font_fallback.rs @@ -0,0 +1,298 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::Arc; +use super::FontDefinitions; +use super::font_setup::HanOrder; +#[cfg(not(target_arch = "wasm32"))] +use super::font_setup::{in_dirs, try_load}; + +/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so +/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so +/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text +/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing +/// is bundled, and a machine without the face keeps the old behaviour. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { + let mut keys = Vec::new(); + // Files already loaded as a fallback face. A file serves at most one key: the region-merged + // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and + // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. + let mut loaded: Vec = Vec::new(); + for (key, cands) in fallback_faces(han) { + for (path, index) in &cands { + if loaded.contains(path) { + break; // a face loaded for an earlier script already covers this one + } + if let Some(fd) = try_load(&[(path.as_str(), *index)]) { + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + keys.push(key.to_string()); + loaded.push(path.clone()); + break; + } + } + } + keys +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { + Vec::new() +} + +/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script +/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one +/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so +/// the interface language reorders the two Han entries and nothing else. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { + let ja = ("sys-cjk-han-ja", cjk_han_japanese()); + let zh = ("sys-cjk-han-zh", cjk_han_simplified()); + let (first, second) = match han { + HanOrder::Japanese => (ja, zh), + HanOrder::Chinese => (zh, ja), + }; + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-lao", lao()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] +} + +/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and +/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. +#[cfg(not(target_arch = "wasm32"))] +const LINUX_CJK_DIRS: [&str; 22] = [ + // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same + // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts/TTF", + "/usr/share/fonts", + "/usr/local/share/fonts", + "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", +]; + +/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). +#[cfg(not(target_arch = "wasm32"))] +fn windows_fonts() -> String { + std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) +} + +/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the +/// region-merged TTC at `face`, in every Linux font directory. +#[cfg(not(target_arch = "wasm32"))] +fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { + let mut v = Vec::new(); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); + } + v +} + +/// Japanese Han + Kana faces (Japanese kanji glyph shapes). +/// +/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, +/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list +/// comes first loads it; [`load_fallback`] does not load the same file again for the other. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_japanese() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic + v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic + v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans + v.extend(noto_cjk("jp", 0)); + v +} + +/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks +/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_simplified() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei + (format!("{win}\\simsun.ttc"), 0), // SimSun + (format!("{win}\\simhei.ttf"), 0), // SimHei + (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS + (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset + ]; + v.extend(noto_cjk("sc", 2)); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wqy-microhei.ttc"), 0)); + } + v +} + +/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file +/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_hangul() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic + v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); + v.extend(noto_cjk("kr", 1)); + v +} + +/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); +/// Linux font directories searched by the Thai and Hebrew faces, in priority order. +#[cfg(not(target_arch = "wasm32"))] +fn linux_noto_dirs() -> [&'static str; 5] { + [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ] +} + +/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the +/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. +#[cfg(not(target_arch = "wasm32"))] +fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { + let w = windows_fonts(); + let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); + v.extend(mac.iter().map(|p| (p.to_string(), 0))); + v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); + v.push((variable.into(), 0)); + v +} + +/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn thai() -> Vec<(String, u32)> { + script_fonts( + // Leelawadee UI, Tahoma, Microsoft Sans Serif + &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], + &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], + &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], + // Fedora ships the variable Noto Sans Thai; its default instance is Regular. + "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", + ) +} + +/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam +/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn lao() -> Vec<(String, u32)> { + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = windows_fonts(); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI + (format!("{win}\\dokchamp.ttf"), 0), // DokChampa + (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), + (format!("{mac_sup}/Lao MN.ttc"), 0), + ]; + for d in lin { + v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); + v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); + v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); + } + v +} + +/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has +/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn hebrew() -> Vec<(String, u32)> { + script_fonts( + // David, FrankRuehl, Tahoma + &["david.ttf", "frank.ttf", "tahoma.ttf"], + &[ + "/System/Library/Fonts/ArialHB.ttc", + "/System/Library/Fonts/SFHebrew.ttf", + "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", + "/System/Library/Fonts/Supplemental/David.ttf", + "/System/Library/Fonts/LucidaGrande.ttc", + "/System/Library/Fonts/Lucida Grande.ttc", + ], + &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], + "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", + ) +} + +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} diff --git a/crates/ui-egui/src/theme/font_setup.rs b/crates/ui-egui/src/theme/font_setup.rs new file mode 100644 index 000000000..916ba4026 --- /dev/null +++ b/crates/ui-egui/src/theme/font_setup.rs @@ -0,0 +1,600 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::Blob; +use super::font_fallback::load_fallback; +use super::{Arc, Color32, FontData, FontDefinitions, FontFamily, FontId}; + +/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see +/// and its `docs/integration.md`). The slice is empty +/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. +#[derive(Debug)] +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +mod craft_fonts { + use super::CraftFont; + include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +} + +/// The light or dark value of a colour, by mode. +pub(super) fn pick(dark: bool, light: T, dark_value: T) -> T { + if dark { dark_value } else { light } +} + +pub const UI: &str = "ui"; + +pub const UI_BOLD: &str = "ui-bold"; + +pub const CELL: &str = "cell"; + +pub const CELL_BOLD: &str = "cell-bold"; + +pub const CELL_ITALIC: &str = "cell-italic"; + +pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; + +pub const SERIF: &str = "serif"; + +pub const MONO: &str = "mono"; + +pub fn ui_font(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI.into())) +} + +pub fn ui_bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI_BOLD.into())) +} + +/// The font family to use for a cell font name and weight. +/// +/// When the name is an installed font whose faces were bound by [`font_definitions`], that family +/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui +/// panics on a family that was never bound, so the installed branch is gated on +/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. +pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { + let role = crate::fonts::role_key(name, bold, italic); + if crate::fonts::is_bound(&role) { + return FontFamily::Name(role.into()); + } + let lower = name.to_ascii_lowercase(); + if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { + return FontFamily::Name(MONO.into()); + } + if lower.contains("times") + || lower.contains("georgia") + || lower.contains("cambria") + || lower.contains("serif") && !lower.contains("sans") + || lower.contains("garamond") + { + return FontFamily::Name(SERIF.into()); + } + FontFamily::Name( + match (bold, italic) { + (true, true) => CELL_BOLD_ITALIC, + (true, false) => CELL_BOLD, + (false, true) => CELL_ITALIC, + (false, false) => CELL, + } + .into(), + ) +} + +/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the +/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower +/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the +/// next frame) falls back to the engine's estimate rather than letting egui panic on it. +/// +/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph +/// advances are summed without kerning, which is cheap and never narrower. This takes the +/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. +pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { + let family = cell_family(&font.name, font.bold, font.italic); + let id = FontId::new(font.size * 96.0 / 72.0, family); + ctx.fonts_mut(|f| { + f.families().contains(&id.family).then(|| { + if exact { + f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x + } else { + // Half a point covers the galley's rounding of its width up to the pixel grid. + text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 + } + }) + }) + .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) +} + +/// Whether epaint can open face `index` of this font file and it maps characters to glyphs. +/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font +/// file we did not ship is checked first with the same parser and the same call epaint makes +/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a +/// collection index past the end is rejected instead of crashing the app (#226). A face whose +/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused +/// too and the next candidate gets its turn. +pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { + use skrifa::MetadataProvider; + skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map()) +} + +/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is +/// skipped with a warning and the next candidate is tried. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn try_load(paths: &[(&str, u32)]) -> Option { + for (p, index) in paths { + if let Some(blob) = font_file(p, *index) { + return Some(FontData::from_blob(blob, *index)); + } + } + None +} + +/// The interface and script fallback font files, each read once per run and kept as a shared +/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text +/// engine, because every one of these files is loaded by every font rebuild for as long as the +/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next +/// time. +#[cfg(not(target_arch = "wasm32"))] +fn font_file(path: &str, index: u32) -> Option { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(blob) = files.get(path) { + let bytes: &[u8] = (**blob).as_ref(); + return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); + } + let bytes = std::fs::read(path).ok()?; + if !font_parses(&bytes, index) { + log::warn!("skipping font {path} (face {index}): not a font file egui can read"); + return None; + } + let blob: Blob = std::sync::Arc::new(bytes); + files.insert(path.to_string(), blob.clone()); + Some(blob) +} + +/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not +/// parse (and its key from every family), so a bad font costs a fallback, never a crash. +pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { + let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect(); + for key in &bad { + log::warn!("dropping font {key:?}: egui can't parse it"); + fonts.font_data.remove(key); + } + if !bad.is_empty() { + for chain in fonts.families.values_mut() { + chain.retain(|k| !bad.contains(k)); + } + } +} + +/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` +/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. +const HACK: &str = "Hack"; + +/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty +/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale +/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. +pub(super) fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { + let wasm = cfg!(target_arch = "wasm32"); + craft_fonts::CRAFT_FONTS + .iter() + .enumerate() + .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) + .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) + .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) + .collect() +} + +/// Adds the optional craft-fonts faces to `fonts` and returns their keys. +fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { + let mut keys = Vec::new(); + for (key, font) in craft_font_faces() { + fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); + keys.push(key); + } + keys +} + +/// Whether the optional craft-fonts input was embedded in this build. +pub fn craft_fonts_loaded() -> bool { + !craft_fonts::CRAFT_FONTS.is_empty() +} + +/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web +/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. +pub fn chinese_font_loaded() -> bool { + craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") +} + +/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather +/// than skip when this is true and the faces are absent. +pub fn craft_fonts_required() -> bool { + craft_fonts::CRAFT_FONTS_REQUIRED +} + +/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol +/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a +/// display fallback and never changes the characters a command inserts or stores. +pub fn symbol_fallback(c: char) -> Option { + match c { + '✕' => Some('×'), + '✓' => Some('✔'), + '⌃' => Some('^'), + _ => None, + } +} + +/// `text` with every character the current font set cannot draw replaced by its semantic fallback +/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints +/// a tofu box; it is not applied to workbook text. +pub fn available_text(ui: &egui::Ui, text: &str) -> String { + let font = ui_font(13.0); + text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() +} + +/// The family, style and scripts of every embedded craft-fonts face (empty without the input). +pub fn craft_font_scripts() -> impl Iterator { + craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) +} + +/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use +/// this to tell "the pinned fixture covers this script" from "this host has no face for it", +/// without trusting a manifest script tag alone. +pub fn craft_fonts_cover(text: &str) -> bool { + craft_font_faces().iter().any(|(_, f)| { + use skrifa::MetadataProvider; + let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { + return false; + }; + let map = font.charmap(); + text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn try_load(_paths: &[(&str, u32)]) -> Option { + None +} + +fn home_fonts(file: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{home}/Library/Fonts/{file}") +} + +/// Every file in `files` under every directory in `dirs`, directory first, in the same order a +/// nested loop over the directories would add them. +pub(super) fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { + dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() +} + +/// Candidate font files per role, in preference order. +pub(super) fn candidates(role: &str) -> Vec<(String, u32)> { + let mac_sys = "/System/Library/Fonts"; + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; + let mut v: Vec<(String, u32)> = Vec::new(); + let mut add = |s: String, i: u32| v.push((s, i)); + match role { + "ui" => { + add(format!("{mac_sys}/SFNS.ttf"), 0); + add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); + add(format!("{win}\\segoeui.ttf"), 0); + for p in in_dirs( + &lin, + &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], + ) { + add(p, 0); + } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); + } + "ui-bold" => { + add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); + add(format!("{win}\\segoeuib.ttf"), 0); + for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); + } + "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { + let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { + "cell" => { + ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") + } + "cell-bold" => ( + "Carlito-Bold.ttf", + "calibrib.ttf", + "Calibri Bold.ttf", + "Arial Bold.ttf", + "arialbd.ttf", + "LiberationSans-Bold.ttf", + "DejaVuSans-Bold.ttf", + ), + "cell-italic" => ( + "Carlito-Italic.ttf", + "calibrii.ttf", + "Calibri Italic.ttf", + "Arial Italic.ttf", + "ariali.ttf", + "LiberationSans-Italic.ttf", + "DejaVuSans-Oblique.ttf", + ), + _ => ( + "Carlito-BoldItalic.ttf", + "calibriz.ttf", + "Calibri Bold Italic.ttf", + "Arial Bold Italic.ttf", + "arialbi.ttf", + "LiberationSans-BoldItalic.ttf", + "DejaVuSans-BoldOblique.ttf", + ), + }; + // Calibri if the user has it installed system-wide (never from an Office bundle). + add(format!("/Library/Fonts/{calibri_mac}"), 0); + add(home_fonts(calibri_mac), 0); + add(format!("{win}\\{calibri_win}"), 0); + for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { + add(p, 0); + } + // Fedora keeps each family in its own package folder, as for the UI fonts (#54). + add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); + add(home_fonts(carlito), 0); + add(format!("{mac_sup}/{arial_mac}"), 0); + add(format!("{win}\\{arial_win}"), 0); + for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { + add(p, 0); + } + add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); + add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); + } + "serif" => { + add(format!("{mac_sup}/Times New Roman.ttf"), 0); + add(format!("{win}\\times.ttf"), 0); + for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); + } + "mono" => { + add(format!("{mac_sys}/SFNSMono.ttf"), 0); + add(format!("{mac_sys}/Menlo.ttc"), 0); + add(format!("{win}\\consola.ttf"), 0); + for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); + // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice + // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. + for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); + add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); + } + _ => {} + } + v +} + +/// Builds the font set from system fonts (with egui's defaults as fallback), with the default +/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. +pub fn font_definitions() -> FontDefinitions { + font_definitions_for_language(crate::i18n::Language::En) +} + +/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only +/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two +/// languages with the same order needs no font rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HanOrder { + /// Japanese glyph shapes first (the interface is Japanese). + Japanese, + /// Simplified Chinese first: the widest Han coverage, for every other interface language. + Chinese, +} + +impl HanOrder { + pub fn of(language: crate::i18n::Language) -> Self { + if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } + } +} + +/// Builds the font set with the Han fallback faces ordered for the interface language. +pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { + font_definitions_with(language, &[]) +} + +/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed +/// workbook-font binding. +/// +/// Integration tests use this through their shared harness, so a minimal CI image and a developer +/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository +/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. +pub fn pinned_font_definitions() -> FontDefinitions { + font_definitions_impl(crate::i18n::Language::En, &[], false) +} + +/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are +/// installed, so cells set in them render with them; the rest fall back to the roles. The bound +/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. +pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { + font_definitions_impl(language, used, true) +} + +/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. +fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); + // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, + // and epaint picks the first face that has the glyph, so they only catch their own scripts. + let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; + let hack = fonts.font_data.contains_key(HACK).then_some(HACK); + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); + fonts.families.insert(FontFamily::Name(role.into()), chain); + bound.push(role.to_string()); + } + // Installed workbook fonts, so a cell's chosen font actually renders in it. + if system { + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); + } + } + // Interface text (formula bar, name box, dialogs) falls back to the cell font before the + // script faces: whatever a cell can draw, the formula bar can draw too, even where no script + // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). + bind_cell_fallback(&mut fonts, &fallback); + // Default proportional text in widgets uses the UI font. + if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { + fonts.families.insert(FontFamily::Proportional, ui); + } + // The optional craft-fonts faces go at the very end of every family: a system or bundled face + // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and + // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). + // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still + // leads, and the barred Noto CJK collection never enters. + append_craft_faces(&mut fonts); + drop_unparseable_fonts(&mut fonts); + // Only families egui still has data for count as bound (a dropped face can empty a chain). + bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); + crate::fonts::set_bound(bound); + fonts +} + +/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled +/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data +/// are dropped, as before. +fn build_role_chain( + fonts: &mut FontDefinitions, + role: &str, + base_prop: &[String], + base_mono: &[String], + fallback: &[String], + hack: Option<&str>, + system: bool, +) -> Vec { + let paths = candidates(role); + let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); + let mut chain: Vec = Vec::new(); + let loaded = if system { try_load(&refs) } else { None }; + if let Some(mut fd) = loaded { + // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina + // screen (#1184). Core Text never runs them, so this draws it the way macOS does. + if cfg!(target_os = "macos") && role == UI_BOLD { + fd.tweak.hinting = Some(false); + } + let key = format!("sys-{role}"); + fonts.font_data.insert(key.clone(), Arc::new(fd)); + chain.push(key); + } else if role == UI_BOLD { + chain.push(format!("sys-{UI}")); + } else if role.starts_with("cell-") { + chain.push(format!("sys-{CELL}")); + } + chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); + // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but + // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as + // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it + // only catches characters those do not have. + if let Some(hack) = hack + && !chain.iter().any(|k| k.as_str() == hack) + { + chain.push(hack.to_string()); + } + chain.extend(fallback.iter().cloned()); + chain.retain(|k| fonts.font_data.contains_key(k)); + chain +} + +/// Inserts the loaded cell face before the script fallbacks of the interface families, so the +/// formula bar and dialogs can draw whatever a cell can (#349). +fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { + let cell_key = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell_key) { + return; + } + for role in [UI, UI_BOLD, SERIF, MONO] { + if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) + && !chain.contains(&cell_key) + { + let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); + chain.insert(at, cell_key.clone()); + } + } +} + +/// Appends the permitted craft-fonts faces to every family, preserving the leading system or +/// bundled face's glyph shapes. +fn append_craft_faces(fonts: &mut FontDefinitions) { + let craft = add_craft_fonts(fonts); + if craft.is_empty() { + return; + } + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } +} + +#[cfg(test)] +mod font_binding_tests { + use super::*; + + /// The families [`font_definitions`] always binds, whatever is installed. + fn always_bound() -> Vec { + [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Nothing installed: every name buckets to a role that font_definitions bound. + crate::fonts::set_bound(always_bound()); + for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; + assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); + } + } + } + + #[test] + fn a_bound_installed_font_is_used_by_name() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let role = crate::fonts::role_key("Ubuntu", true, false); + crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); + assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); + // The same family in a style that wasn't bound falls back to a built-in role that is. + let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; + assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); + crate::fonts::set_bound(always_bound()); + } + + #[test] + fn arbitrary_names_never_yield_an_unbound_family() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A workbook can hold any font name; none may name a family egui has no data for. + crate::fonts::set_bound(always_bound()); + for seed in 0..500u32 { + let name: String = + (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); + let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; + assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); + } + } +} diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs new file mode 100644 index 000000000..38cbbd257 --- /dev/null +++ b/crates/ui-egui/src/theme/tests.rs @@ -0,0 +1,356 @@ +use super::*; +use crate::i18n::Language; + +#[test] +/// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. +fn fallback_faces_are_appended_to_every_family() { + // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain + // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. + for han in [HanOrder::Chinese, HanOrder::Japanese] { + let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; + let fonts = font_definitions_for_language(language); + let fb_keys: Vec = fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback face on this machine (minimal Linux) + } + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); + // The optional craft-fonts faces follow the system fallbacks at the very end. + let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); + assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); + assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); + } + } +} + +#[test] +/// Every font family ends with exactly the permitted craft faces in manifest order. +fn craft_fonts_are_the_last_fallback_in_every_family() { + // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. + // A craft face must never displace a system face that already covers a glyph. + let fonts = font_definitions_for_language(Language::En); + let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); + if expected.is_empty() { + return; // optional input absent + } + for (family, chain) in &fonts.families { + let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); + assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); + } +} + +#[test] +/// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. +fn a_required_native_build_embeds_the_chinese_face() { + if cfg!(target_arch = "wasm32") || !craft_fonts_required() { + return; + } + assert!( + chinese_font_loaded(), + "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" + ); + let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); + assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); +} + +#[test] +/// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. +fn barred_faces_never_enter_the_font_set() { + for (family, _, _) in craft_font_scripts() { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } +} + +#[test] +/// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. +fn the_required_craft_faces_keep_their_exact_scripts() { + if !craft_fonts_loaded() { + assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); + return; + } + let faces: Vec<_> = craft_font_scripts().collect(); + for (family, style, scripts) in [ + ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), + ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), + ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), + ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), + ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), + ] { + assert!( + faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), + "{family} {style} with {scripts:?} must be embedded; got {faces:?}" + ); + } +} + +#[test] +/// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. +fn available_text_maps_unbundled_chrome_symbols() { + // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is + // never rewritten: this helper is only used for interface tooltips. + let ctx = egui::Context::default(); + ctx.set_fonts(FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); + }); + out.textures_delta.clear(); +} + +#[test] +fn interface_families_fall_back_to_the_cell_font() { + // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, + // so each interface chain carries the cell face, ahead of the script fallback faces. + let fonts = font_definitions_for_language(Language::En); + let cell = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell) { + return; // no system cell font on this machine + } + let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); + for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { + let chain = fonts.families.get(&family).cloned().unwrap_or_default(); + let at = chain.iter().position(|k| *k == cell); + assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); + let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); + assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); + } +} + +#[test] +fn language_reorders_only_the_han_faces() { + let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { + assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); + } +} + +#[test] +fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { + // Exercise the candidates the fallback loader actually consumes, not just the directory + // constant. This must work on CI without an installed CJK font or a Flatpak runtime. + for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { + for directory in [ + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + ] { + let path = format!("{directory}/NotoSansCJK-Regular.ttc"); + assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); + } + } + let chinese = cjk_han_simplified(); + assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); + assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); +} + +#[test] +/// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. +fn fallback_faces_load_each_file_once() { + // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded + // once, not once per script (tens of MB each). + for language in [Language::En, Language::Ja] { + let fonts = font_definitions_for_language(language); + let present: Vec<&Arc> = fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); + } + } + } +} + +#[test] +/// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. +fn fallback_covers_its_script() { + // Real glyph check, per script: the proportional family (what cells and the UI use) must + // have a glyph for every non-space character in the sample. A script runs when a system + // face or the optional craft-fonts input provides one; the printed skip is a recorded + // gate, and a present face that is not wired in fails. + type Case = (&'static str, Vec<(String, u32)>, &'static str); + let cases: [Case; 10] = [ + ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + // Chinese uses a system Han face or the native-only permitted LXGW craft face; the + // barred Noto CJK collection never supplies it. + ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), + ("sys-cjk-hangul", cjk_hangul(), "한글"), + ("sys-thai", thai(), "ทดสอบ"), + ("sys-lao", lao(), "ພາສາລາວ"), + ("sys-hebrew", hebrew(), "עברית"), + ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), + ("craft-noto-sans-georgian", Vec::new(), "ქართული"), + ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), + ]; + for language in [Language::En, Language::Ja] { + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions_for_language(language)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { + let font = FontId::new(14.0, family.clone()); + for (key, cands, sample) in &cases { + // A script runs when a system face or the optional craft-fonts input provides + // one. The skip is printed: a missing fixture is a recorded gate, not a pass. + let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); + if !system && !craft_fonts_cover(sample) { + eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); + continue; + } + let missing: String = sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); + } + } + } +} + +#[test] +fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); +} + +#[test] +/// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. +fn unparseable_font_files_are_rejected_not_fatal() { + // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of + // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + assert!(font_parses(&real, 0), "egui's own font must parse"); + assert!(!font_parses(b"", 0)); + assert!(!font_parses(&[0xAB; 64], 0)); + assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); + assert!(!font_parses(&real, 3), "a single-face file has no face 3"); + + // A bad face that slipped into the definitions is dropped (key and family entries), and the + // context builds its fonts without panicking. + let mut fonts = font_definitions(); + fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); + for chain in fonts.families.values_mut() { + chain.push("sys-hebrew".into()); + } + drop_unparseable_fonts(&mut fonts); + assert!(!fonts.font_data.contains_key("sys-hebrew")); + assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("Hebrew עברית"); + }); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// A broken existing font candidate is skipped in favor of a later readable candidate. +fn try_load_skips_a_broken_candidate() { + // A candidate that exists but is not a readable font must not win over a good one behind it. + let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let bad = dir.join("broken.ttf"); + let good = dir.join("good.ttf"); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); + let _ = std::fs::write(&good, &real); + let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); + assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); + let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); + assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. +fn interface_fonts_are_read_once_and_shared_with_egui() { + // egui copies owned font bytes into its text engine and keeps the original too; borrowed + // bytes it shares. A rebuild of the font set reuses the file read the first time. + let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let p = path.to_string_lossy().into_owned(); + let first = try_load(&[(p.as_str(), 0)]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); + assert!( + std::sync::Arc::ptr_eq(&first.font, &second.font), + "interface font bytes must be one shared blob, so egui shares them instead of copying" + ); + assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); + assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); +} + +#[test] +fn macos_bold_interface_face_is_drawn_unhinted() { + // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a + // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every + // other app draws it; the regular face and the other platforms keep egui's default. + let fonts = font_definitions_for_language(Language::En); + let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); + if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { + assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); + } + if let Some(regular) = hinting(&format!("sys-{UI}")) { + assert_eq!(regular, None, "only the bold face is affected"); + } +} + +#[test] +fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { + // #1694: Fedora keeps each font family in its own package folder, so without these entries + // no cell face is found there and bold and italic cells draw as regular text. Each comes + // after the existing Linux folders for the same font, so wherever one of those matched, + // nothing changes. + let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); + let cases: [(&str, Vec, &str, &str); 9] = [ + (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), + (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), + (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), + (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), + (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), + ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), + ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), + ]; + for (role, list, fedora_file, earlier) in &cases { + let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); + let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); + assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); + assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); + assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); + } + // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. + let mono = paths(candidates(MONO)); + let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); + let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); + assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); + let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); + assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); +} diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 3de56ed87..2553fbbc7 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,7 +2,7 @@ //! offers the new release: Update and Restart (direct self-update), Download or Later. The flow //! itself is `craft_update::updater::Updater`. -use craft_update::suite::GRIDCRAFT; +use craft_update::CheckResultResponse; use craft_update::updater::{Action, Status, Updater}; use crate::SheetApp; @@ -15,11 +15,13 @@ pub(crate) struct Runtime { startup: bool, /// Help ▸ Check for Updates… was chosen; the next `tick` starts it. manual: bool, + /// The result dialog of a requested check is showing. + result_open: bool, } impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&GRIDCRAFT, VERSION), startup: false, manual: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false, result_open: false } } } @@ -36,19 +38,14 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; if std::mem::take(&mut rt.manual) { rt.updater.check_now(ctx); + rt.result_open = true; } else if rt.startup { rt.updater.check_at_start(ctx); } rt.updater.tick(ctx); - let lang = app.ui.language; - let outcome = match rt.updater.status() { - Status::UpToDate => Some(lang.tr("GridCraft {version} is up to date.").replace("{version}", VERSION)), - Status::Failed(e) => Some(lang.tr("Could not check for updates: {error}").replace("{error}", e)), - Status::Idle | Status::Checking => None, - }; - if let Some(message) = outcome { - rt.updater.dismiss_status(); - app.message = Some(("GridCraft".into(), message)); + // A requested check that found a release leaves the status idle and the prompt opens. + if matches!(rt.updater.status(), Status::Idle) { + rt.result_open = false; } } @@ -66,4 +63,15 @@ pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } + if app.updates_rt.result_open { + let rt = &mut app.updates_rt; + match craft_update::CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { + CheckResultResponse::Close => { + rt.updater.dismiss_status(); + rt.result_open = false; + } + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::None => {} + } + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 63490a36e..1552db2bb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -109,6 +109,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); paint_big_label(ui, rect, &lines, dropdown); + let name = label.replace('\n', " "); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -203,6 +206,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } + // The accessible name is the label, or the tooltip for an icon-only button. + let name = if label.is_empty() { tr(ui, tip) } else { label }; + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); + resp.on_hover_text(tip_text(ui, tip)) +} + +/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its +/// accessible role is a checkbox, selected while `on`. +pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click()); + if on || resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, 4.0, t.pressed); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let color = if ui.is_enabled() { t.text } else { t.text_disabled }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index a2c36e3f3..e2fc537ac 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -152,7 +152,8 @@ 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)); - h.get_by_role_and_label(Role::Button, "Delete").click(); + // 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(); 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/chart_source_layering.rs b/crates/ui-egui/tests/it/chart_source_layering.rs new file mode 100644 index 000000000..5c4b52f75 --- /dev/null +++ b/crates/ui-egui/tests/it/chart_source_layering.rs @@ -0,0 +1,157 @@ +//! #2861: a selected chart's source outline belongs behind floating objects, in cell space. + +use egui::{Color32, Rect, Shape, pos2, vec2}; +use gridcraft_engine::core::RangeRef; +use gridcraft_engine::model::Anchor; +use gridcraft_ui_egui::{SheetApp, theme::Tokens}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const SOURCE: &str = "B2:B12"; +const SHAPE_FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56); +const CELL_FILL: Color32 = Color32::from_rgb(0xEF, 0xDC, 0xBA); + +/// The source range crosses a shape and its selected chart, while its top remains uncovered. +fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) { + let mut session = crate::support::blank_session(); + let values: Vec<_> = (1..=11).map(|value| json!([value])).collect(); + session.execute("range.setValues", json!({"range": "B2", "values": values})).unwrap(); + session.execute("home.fillColor", json!({"range": SOURCE, "color": "#EFDCBA"})).unwrap(); + session.execute("selection.set", json!({"range": SOURCE})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": "column", "at": "B7", "width": 260, "height": 130})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + session.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 160, "height": 35, "fill": "123456"})).unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + session.execute("view.rightToLeft", json!({"on": rtl})).unwrap(); + if frozen { + session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + } + let mut h = crate::support::harness(session, 4); + h.state_mut().selected_chart = Some(id); + if frozen { + h.state_mut().view_mut().unwrap().scroll.y = 20.0; + } + h.run_steps(3); + (h, id) +} + +/// The exact on-screen source rectangle and its pane clips, including RTL and frozen rows. +fn source_geometry(h: &Harness) -> (Rect, Vec) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + let range = RangeRef::parse(SOURCE).unwrap(); + let rect = geo.mirror_rect(geo.range_rect(sheet, range)); + let clips = geo.range_panes(range).into_iter().map(|pane| geo.mirror_rect(pane.intersect(geo.cells))).collect(); + (rect, clips) +} + +/// Allows only floating-point rounding when RTL translation and reflection reach the same bounds. +fn same_rect(a: Rect, b: Rect) -> bool { + a.min.distance(b.min) < 0.01 && a.max.distance(b.max) < 0.01 +} + +/// The last frame's actual source-outline paint commands, with their order and visible clip. +fn source_strokes(h: &Harness) -> Vec<(usize, Rect)> { + let (source, _) = source_geometry(h); + let color = Tokens::get(&h.ctx).ref_color(0, true); + h.output() + .shapes + .iter() + .enumerate() + .filter_map(|(index, shape)| match &shape.shape { + Shape::Rect(rect) if same_rect(rect.rect, source) && rect.stroke.color == color && rect.stroke.width == 1.5 => { + Some((index, shape.clip_rect)) + } + _ => None, + }) + .collect() +} + +/// Floating objects move as a whole on RTL sheets, so their on-screen bounds remain unflipped. +fn object_rect(h: &Harness, anchor: &Anchor) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + geo.mirror_rect(Rect::from_min_size( + pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z), + vec2(anchor.width * geo.z, anchor.height * geo.z), + )) +} + +/// Finds an opaque object fill in final paint order, rather than relying on implementation calls. +fn fill_index(h: &Harness, bounds: Rect, color: Color32) -> usize { + h.output() + .shapes + .iter() + .position(|shape| matches!(&shape.shape, Shape::Rect(rect) if same_rect(rect.rect, bounds) && rect.fill == color)) + .expect("the overlapping floating object's fill was painted") +} + +/// Opaque objects must cover the overlapping source stroke while the uncovered stroke stays visible. +#[test] +fn chart_source_outlines_stay_below_every_overlapping_object() { + for rtl in [false, true] { + for frozen in [false, true] { + let (h, id) = fixture(rtl, frozen); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let chart = sheet.charts.iter().find(|chart| chart.id == id).unwrap(); + let chart_bounds = object_rect(&h, &chart.anchor); + let shape_bounds = object_rect(&h, &sheet.shapes[0].anchor); + let chart_fill = fill_index(&h, chart_bounds, Tokens::get(&h.ctx).grid_bg); + let shape_fill = fill_index(&h, shape_bounds, SHAPE_FILL); + let (source, expected_clips) = source_geometry(&h); + let strokes = source_strokes(&h); + assert_eq!(strokes.len(), expected_clips.len(), "source remains painted: rtl={rtl}, frozen={frozen}"); + for ((_, clip), expected) in strokes.iter().zip(expected_clips) { + assert_eq!(*clip, expected, "source clips stay in cell space"); + } + let visible_top = source.top().max(strokes[0].1.top()); + let x = if rtl { source.left() + 0.75 } else { source.right() - 0.75 }; + let uncovered = pos2(x, visible_top + 4.0); + assert!(source.contains(uncovered) && strokes[0].1.contains(uncovered)); + assert!(!chart_bounds.contains(uncovered) && !shape_bounds.contains(uncovered), "uncovered source outline remains visible"); + let background = h + .output() + .shapes + .iter() + .enumerate() + .find_map(|(index, shape)| match &shape.shape { + Shape::Rect(rect) if rect.fill == CELL_FILL && rect.rect.contains(uncovered) && shape.clip_rect.contains(uncovered) => { + Some(index) + } + _ => None, + }) + .expect("the uncovered source cell has an opaque fill"); + assert!(strokes[0].0 > background, "source outline remains above cell backgrounds"); + for (name, bounds, fill) in [("chart", chart_bounds, chart_fill), ("shape", shape_bounds, shape_fill)] { + let covered = pos2(x, bounds.center().y); + assert!(source.contains(covered) && bounds.contains(covered), "fixture overlaps the {name}'s source stroke"); + for (stroke, _) in &strokes { + assert!(*stroke < fill, "source outline paints over {name}: rtl={rtl}, frozen={frozen}, stroke={stroke}, fill={fill}"); + } + } + } + } +} + +/// Moving the source pass must preserve deselection, hidden-object and offscreen-chart behavior. +#[test] +fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() { + for state in ["unselected", "hidden", "offscreen"] { + let (mut h, id) = fixture(false, false); + assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source"); + match state { + "unselected" => h.state_mut().selected_chart = None, + "hidden" => { + h.state_mut().run("view.showObjects", json!({"on": false})).unwrap(); + } + _ => { + h.state_mut().run("object.move", json!({"kind": "chart", "id": id, "at": "B100"})).unwrap(); + } + } + h.run_steps(3); + assert!(source_strokes(&h).is_empty(), "{state} chart must not highlight its source"); + } +} diff --git a/crates/ui-egui/tests/it/font_box_filter.rs b/crates/ui-egui/tests/it/font_box_filter.rs new file mode 100644 index 000000000..40d270d69 --- /dev/null +++ b/crates/ui-egui/tests/it/font_box_filter.rs @@ -0,0 +1,71 @@ +//! The Home ▸ Font list grows back when a filter that matched nothing is cleared, and its filter +//! field stays where it is (egui#5225). + +use egui::{Event, Key, Modifiers, accesskit::Role}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + crate::common::app(s), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, key: Key) { + h.input_mut().events.extend([ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.to_string())); + h.run_steps(3); +} + +fn text_inputs(h: &Harness) -> Vec { + h.get_all_by_role(Role::TextInput).map(|n| n.rect()).collect() +} + +/// The bottom of the Font list's ninth row, if that row is on screen. +fn ninth_row_bottom(h: &Harness) -> Option { + h.query_by_label("Garamond").map(|n| n.rect().bottom()) +} + +#[test] +fn font_list_grows_back_after_a_filter_is_cleared() { + let mut h = harness(); + let before = text_inputs(&h); + let font = h.state().session.active().unwrap().wb.active().unwrap().style_id(gridcraft_engine::core::CellRef::parse("A1").unwrap()); + let name = h.state().session.active().unwrap().wb.styles.get(font).font.name.clone(); + h.get_by_label(&name).click(); + h.run_steps(4); + let open: Vec<_> = text_inputs(&h).into_iter().filter(|r| !before.contains(r)).collect(); + assert_eq!(open.len(), 1, "the open list has one filter field"); + let field = open[0]; + let full = ninth_row_bottom(&h).expect("the full list shows its ninth row"); + + type_text(&mut h, "zzz"); + assert!(ninth_row_bottom(&h).is_none(), "a filter that matches nothing leaves no rows"); + assert!(text_inputs(&h).contains(&field), "the filter field stays put while the list is empty"); + + for _ in 0..3 { + key(&mut h, Key::Backspace); + } + assert_eq!(ninth_row_bottom(&h), Some(full), "the cleared filter shows the full list again"); + assert!(text_inputs(&h).contains(&field), "the filter field stays put after the list regrows"); +} diff --git a/crates/ui-egui/tests/it/format_chart_type.rs b/crates/ui-egui/tests/it/format_chart_type.rs new file mode 100644 index 000000000..ae19e1091 --- /dev/null +++ b/crates/ui-egui/tests/it/format_chart_type.rs @@ -0,0 +1,91 @@ +//! The Format Chart Area pane's Chart Type section changes the chart's type and marks the current one. + +use egui::accesskit::{Role, Toggled}; +use egui_kittest::kittest::{NodeT, Queryable}; +use gridcraft_engine::Session; +use gridcraft_engine::model::ChartKind; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A two-series chart made with `insert.chart`'s `type` `t` and `subtype` `sub`, with Format Chart Area open on its Chart Type section. +fn open_chart_type(t: &str, sub: &str) -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:C3"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": t, "subtype": sub, "at": "E3"})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = crate::support::harness(session, 4); + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").click(); + h.run_steps(2); + (h, id) +} + +fn kind(h: &Harness, id: u32) -> ChartKind { + h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().kind +} + +/// Whether the pane's `label` button is drawn as the chart's current type. The pane sits on the +/// right, past the Chart Type header's left edge; the ribbon's buttons are elsewhere. +fn marked(h: &Harness, label: &str) -> bool { + let left = h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").rect().left(); + let button = h.get_all_by_role_and_label(Role::Button, label).find(|n| n.rect().left() >= left - 1.0).unwrap(); + button.accesskit_node().toggled() == Some(Toggled::True) +} + +fn click(h: &mut Harness, label: &str) { + let left = h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").rect().left(); + h.get_all_by_role_and_label(Role::Button, label).find(|n| n.rect().left() >= left - 1.0).unwrap().click(); + h.run_steps(2); +} + +#[test] +fn chart_type_offers_the_stacked_and_hundred_percent_stacked_types() { + let (mut h, id) = open_chart_type("column", ""); + for (label, want) in [ + ("Stacked Column", ChartKind::ColumnStacked), + ("100% Stacked Column", ChartKind::ColumnStacked100), + ("Stacked Bar", ChartKind::BarStacked), + ("100% Stacked Bar", ChartKind::BarStacked100), + ("Stacked Line", ChartKind::LineStacked), + ("Stacked Area", ChartKind::AreaStacked), + ("100% Stacked Area", ChartKind::AreaStacked100), + ("Column", ChartKind::ColumnClustered), + ] { + click(&mut h, label); + assert_eq!(kind(&h, id), want, "{label} changes the chart's type"); + } +} + +#[test] +fn chart_type_marks_the_chart_s_current_type() { + for (t, sub, label) in + [("bar", "stacked100", "100% Stacked Bar"), ("area", "stacked", "Stacked Area"), ("column", "", "Column"), ("line", "markers", "Line")] + { + let (h, _) = open_chart_type(t, sub); + for other in [ + "Column", + "Stacked Column", + "100% Stacked Column", + "Bar", + "Stacked Bar", + "100% Stacked Bar", + "Line", + "Stacked Line", + "Area", + "Stacked Area", + "100% Stacked Area", + "Pie", + "Doughnut", + "Scatter", + "Radar", + ] { + assert_eq!(marked(&h, other), other == label, "{other} on a {t} {sub} chart"); + } + } +} diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 78af96a1a..4b8ce0420 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -140,6 +140,15 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() { assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)"); } +#[test] +fn empty_cell_autosum_places_caret_inside_parentheses() { + let mut app = app(); + app.run("formulas.autoSum", json!({"enter": false})).unwrap(); + let ed = app.editor.as_ref().unwrap(); + assert_eq!(ed.text, "=SUM()"); + assert_eq!(ed.caret, ed.text.chars().count() - 1); +} + #[test] /// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice. fn canonical_engine_templates_enter_the_selected_formula_language() { diff --git a/crates/ui-egui/tests/it/hyperlink_dialog.rs b/crates/ui-egui/tests/it/hyperlink_dialog.rs new file mode 100644 index 000000000..63886305c --- /dev/null +++ b/crates/ui-egui/tests/it/hyperlink_dialog.rs @@ -0,0 +1,176 @@ +//! #2876: editing a stored hyperlink starts from its address, display text and ScreenTip. + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::{Cell, Hyperlink}; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::control::{Outcome, handle}; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Runs the same dialog controls used by the public automation interface. +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let Outcome::Done(result) = handle(h.state_mut(), &ctx, method, ¶ms) else { panic!("unexpected screenshot response") }; + assert_eq!(result["ok"], true, "{method}: {result}"); + result +} + +/// A non-default active cell carrying a stored hyperlink and the requested input. +fn linked_harness(input: &str, target: &str) -> Harness { + let mut session = crate::support::blank_session(); + session.execute("cell.set", json!({"cell": "B2", "input": input})).unwrap(); + session.execute("insert.link", json!({"cell": "B2", "target": target, "tooltip": "Original tip"})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + crate::support::harness(session, 4) +} + +/// Captures both content and stored link metadata so cancellation and undo are exact. +fn linked_cell(h: &Harness) -> (Cell, Hyperlink) { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let at = CellRef::parse("B2").unwrap(); + (sheet.cells.get(at).cloned().unwrap(), sheet.hyperlinks.get(&at).cloned().unwrap()) +} + +/// Both relationship URLs and workbook locations prefill the same form. +#[test] +fn existing_external_and_internal_links_prefill_their_fields() { + for target in ["https://example.org/original?q=1", "Sheet1!F9"] { + let mut h = linked_harness("Original label", target); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values["target"], target); + assert_eq!(dialog.values["text"], "Original label"); + assert_eq!(dialog.values["tooltip"], "Original tip"); + assert!(h.query_by_label("Display:").is_some()); + assert!(h.query_by_label("ScreenTip:").is_some()); + } +} + +/// The keyboard entry point shares the prefilled form rather than resetting the address. +#[test] +fn ctrl_k_opens_the_existing_link_for_editing() { + let mut h = linked_harness("Original label", "https://example.org/original"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.name, "insertLink"); + assert_eq!(dialog.values["target"], "https://example.org/original"); +} + +/// Both the OK button and automation confirmation update one link and undo in one step. +#[test] +fn editing_a_link_confirms_and_undo_restores_it() { + for button in [false, true] { + let mut h = linked_harness("Original label", "https://example.org/original"); + let before = linked_cell(&h); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "Sheet1!F9"})); + control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Updated label"})); + control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Updated tip"})); + if button { + h.run_steps(4); + h.get_by_label(" OK ").click(); + h.run_steps(4); + } else { + control(&mut h, "ui.dialog.confirm", json!({})); + } + assert!(h.state().dialog.is_none()); + let (cell, link) = linked_cell(&h); + assert_eq!(cell.value.display(), "Updated label"); + assert_eq!(link.target, "Sheet1!F9"); + assert_eq!(link.tooltip.as_deref(), Some("Updated tip")); + assert!(!link.external); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(linked_cell(&h), before); + } +} + +/// Closing a partially edited form leaves the existing link and cell untouched. +#[test] +fn cancelling_link_edits_keeps_the_original_cell_and_link() { + let mut h = linked_harness("Original label", "Sheet1!F9"); + let before = linked_cell(&h); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/changed"})); + control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Changed label"})); + control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Changed tip"})); + control(&mut h, "ui.dialog.cancel", json!({})); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h), before); +} + +/// Rendered forms keep linked numbers, formulas and number formats when their display text is unchanged. +#[test] +fn unchanged_display_text_preserves_number_and_formula_cells() { + for (input, format, expected) in [("42", "General", "42"), ("=6*7", "General", "42"), ("42", "0.00", "42.00"), ("=6*7", "0.00", "42.00")] { + let mut h = linked_harness(input, "https://example.org/original"); + h.state_mut().run("home.numberFormat", json!({"code": format})).unwrap(); + let (before, _) = linked_cell(&h); + let doc = h.state().session.active().unwrap(); + let display = gridcraft_engine::display::cell_text(&doc.wb, doc.wb.active().unwrap(), CellRef::parse("B2").unwrap()); + assert_eq!(display, expected, "formatted input {input}"); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().unwrap().values["text"], display); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let (after, link) = linked_cell(&h); + assert_eq!(after, before, "input {input}"); + assert_eq!(link.target, "https://example.org/updated"); + assert_eq!(link.tooltip.as_deref(), Some("Original tip")); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h).0, before, "unedited OK for {input}"); + } +} + +/// A missing ScreenTip stays absent when only the address changes or OK is pressed unchanged. +#[test] +fn editing_a_link_without_a_tooltip_keeps_it_absent() { + let mut h = linked_harness("Original label", "https://example.org/original"); + h.state_mut().run("insert.link", json!({"target": "https://example.org/original"})).unwrap(); + assert_eq!(linked_cell(&h).1.tooltip, None); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), ""); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let (cell, link) = linked_cell(&h); + assert_eq!(cell.value.display(), "Original label"); + assert_eq!(link.target, "https://example.org/updated"); + assert_eq!(link.tooltip, None); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(linked_cell(&h).1.tooltip, None); +} + +/// A new link still begins with the web-address default and uses its target as a blank cell's label. +#[test] +fn new_links_keep_the_existing_defaults() { + let mut h = crate::support::blank_harness(4); + control(&mut h, "ui.dialog", json!({"name": "insertLink"})); + h.run_steps(3); + let dialog = h.state().dialog.as_ref().unwrap(); + assert_eq!(dialog.values["target"], "https://"); + assert_eq!(dialog.values.get("text").and_then(Json::as_str).unwrap_or_default(), ""); + assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), ""); + control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/new"})); + h.run_steps(3); + control(&mut h, "ui.dialog.confirm", json!({})); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let at = CellRef::parse("A1").unwrap(); + assert_eq!(sheet.value(at).display(), "https://example.org/new"); + assert_eq!(sheet.hyperlinks[&at].target, "https://example.org/new"); +} diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index a70efa782..e876d736f 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -331,6 +331,43 @@ fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() { assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor"); } +#[test] +fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() { + // Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points + // at the whole column of data, as the same keys select it outside an edit. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap(); + s.execute("selection.set", json!({"cell": "D1"})).unwrap(); + let mut h = harness(s); + text(&mut h, "=COUNTIF("); + click_cell(&mut h, "A1"); + let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone()); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell"); + key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data"); + key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + key(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell"); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell"); + // An automation tool (osascript `key code`) presses and releases within one frame, so by the + // time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on + // the press event. + h.input_mut().events.extend([ + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::ModifiersChanged(Modifiers::NONE), + Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps"); +} + #[test] fn shortcuts_format_and_extend() { let mut s = blank(); @@ -679,6 +716,8 @@ fn dialogs_open_and_close() { "pivotCalculatedField", "about", "macros", + "recordMacro", + "macroEditor", "analysisToolPak", "solver", ] { diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index a22fabc98..810338a48 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2539,14 +2539,14 @@ fn keytip_wm_() { fn keytip_wmr_() { let mut h = harness(blank()); sequence(&mut h, "WMR", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMR must open the recordMacro dialog"); } #[test] fn keytip_wmv_() { let mut h = harness(blank()); sequence(&mut h, "WMV", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMV must open the macros dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs new file mode 100644 index 000000000..f2130fcb6 --- /dev/null +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -0,0 +1,554 @@ +//! The macro step editor: editing, Add Step, breakpoints, dragging steps, and the debugger +//! (step, continue, run to the end as one undo step, stop with keep or undo). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::StepKind; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +/// Three steps that write A1, A2, A3. +fn three(h: &mut Harness) { + let steps: Vec<_> = ["A1", "A2", "A3"].iter().map(|c| json!({"command": "cell.set", "params": {"cell": c, "input": c}})).collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Three", "output": {"onConflict": "overwrite"}, "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"})); + h.run_steps(3); +} + +fn draft_cells(h: &Harness) -> Vec { + let ed = h.state().macros.editor.as_ref().unwrap(); + ed.draft + .steps + .iter() + .map(|s| match &s.kind { + StepKind::Command { params, .. } => params["cell"].as_str().unwrap_or("").to_string(), + _ => String::new(), + }) + .collect() +} + +fn click(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_macro_add_step_through_the_picker_and_save() { + let mut h = harness(); + click(&mut h, "New Macro"); + assert!(h.state().macros.editor.is_some()); + click(&mut h, "Add Step"); + h.input_mut().events.push(Event::Text("bold".into())); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(matches!(&ed.draft.steps[..], [s] if matches!(&s.kind, StepKind::Command { id, .. } if id == "home.bold"))); + assert_eq!(ed.selected.as_deref(), Some("1")); + click(&mut h, "Save"); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!(wb.macros[0].name, ed.draft.name); + assert!(!h.state().macros.editor.as_ref().unwrap().dirty); +} + +#[test] +fn the_gutter_toggles_breakpoints() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 2"); + assert!(h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); + click(&mut h, "Breakpoint 2"); + assert!(!h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); +} + +#[test] +fn dragging_a_step_reorders_the_list() { + let mut h = harness(); + three(&mut h); + let from = h.get_by_label("Step 1: Set A1 to A1").rect().center(); + let to = h.get_by_label("Step 3: Set A3 to A3").rect().center() + egui::vec2(0.0, 8.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for k in 1..=6 { + let p = from + (to - from) * (k as f32 / 6.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(draft_cells(&h), ["A2", "A3", "A1"]); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.dirty); + assert_eq!(ed.selected.as_deref(), Some("3"), "the moved step stays selected"); +} + +#[test] +fn step_continue_and_finish_as_one_undo_step() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 3"); + let undo_before = h.state().session.active().unwrap().undo.len(); + // Continue starts the run (saving the breakpoint first) and stops before step 3. + click(&mut h, "Continue"); + let ed = h.state().macros.editor.clone().unwrap(); + assert_eq!(ed.debug.as_ref().and_then(|d| d["current"]["step"].as_str()), Some("3")); + assert_eq!(ed.selected.as_deref(), Some("3"), "the current step is selected"); + assert!(h.query_by_label_contains("Paused before step 3").is_some()); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Text("A1".into()), Value::Empty)); + assert!(h.state().grid.macro_overlay.as_ref().is_some_and(|o| !o.target.is_empty()), "the target is outlined"); + // Clearing the breakpoint while paused, then Step runs step 3 and the run finishes. + click(&mut h, "Breakpoint 3"); + click(&mut h, "Step"); + assert!(h.state().session.macro_debug.is_none()); + assert_eq!(value(&h, "A3"), Value::Text("A3".into())); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(ed.outcome.as_deref().is_some_and(|o| o.starts_with("Finished")), "{:?}", ed.outcome); + let doc = h.state().session.active().unwrap(); + // The breakpoint save is one entry, the run another. + assert_eq!(doc.undo.last().map(|e| e.label.as_str()), Some("Run Macro: Three")); + assert!(doc.undo.len() <= undo_before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Empty, Value::Empty)); +} + +#[test] +fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { + for keep in [false, true] { + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + click(&mut h, "Run to Here"); + assert_eq!( + h.state().macros.editor.as_ref().unwrap().debug.as_ref().and_then(|d| d["current"]["step"].as_str()), + Some("3"), + "{:?} {:?}", + h.state().message, + h.state().macros.editor.as_ref().unwrap().outcome + ); + click(&mut h, "Stop"); + assert!(h.state().macros.editor.as_ref().unwrap().ask_stop); + click(&mut h, if keep { "Keep Changes" } else { "Undo Changes" }); + assert!(h.state().session.macro_debug.is_none()); + let want = if keep { Value::Text("A2".into()) } else { Value::Empty }; + assert_eq!(value(&h, "A2"), want, "keep = {keep}"); + assert_eq!(value(&h, "A3"), Value::Empty); + } +} + +/// Step 3's Cell box is picking from the grid, with the selection it last saw (A1). +fn pick_step_3_cell(h: &mut Harness) { + let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs; + refs.pick = Some("ref:3:cell".into()); + refs.seen = "$A$1".into(); +} + +#[test] +fn a_pick_ends_when_its_box_leaves_the_screen() { + // Choosing another step hides the picking box; a selection made meanwhile must not land in + // it when the step is shown again. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + pick_step_3_cell(&mut h); + h.run_steps(2); + click(&mut h, "Step 1: Set A1 to A1"); + assert!(h.state().macros.editor.as_ref().unwrap().refs.pick.is_none(), "the pick ends with its box"); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(2); + click(&mut h, "Step 3: Set A3 to A3"); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); +} + +#[test] +fn debugging_never_writes_the_selection_into_a_reference_box() { + // A reference box that was picking from the grid stays out of it once an action starts, and + // the selections the debugger makes while it runs never land in a box or dirty the draft. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + pick_step_3_cell(&mut h); + h.run_steps(2); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + click(&mut h, "Run to Here"); + assert!(h.state().session.macro_debug.is_some()); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.refs.pick.is_none(), "the action ends the pick"); + assert!(!ed.dirty); + // A box focused while debugging does not take the selection either. + pick_step_3_cell(&mut h); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + h.state_mut().macros.editor.as_mut().unwrap().refs.pick = None; + click(&mut h, "Stop"); + click(&mut h, "Undo Changes"); + assert!(h.state().session.macro_debug.is_none()); +} + +#[test] +fn step_into_from_the_macros_dialog_pauses_before_the_first_step() { + let mut h = harness(); + three(&mut h); + h.state_mut().dialog = None; + h.state_mut().macros.editor = None; + h.state_mut().open_dialog("macros", json!({"name": "Three"})); + h.run_steps(2); + click(&mut h, "Step Into"); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().session.macro_debug.is_some()); + assert!(h.query_by_label_contains("Debugging “Three”").is_some(), "the status bar says so"); +} + +#[test] +fn a_recorded_macro_opens_in_the_editor() { + let mut h = harness(); + click(&mut h, "Record Macro"); + // The Record Macro dialog comes first. + h.run_steps(2); + click(&mut h, "OK"); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "5"})).unwrap(); + h.state_mut().run("home.bold", json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Stop"); + let name = h.state().session.active().unwrap().wb.macros[0].name.clone(); + h.state_mut().open_dialog("macroEditor", json!({"macro": name})); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(!ed.draft.steps.is_empty()); + assert!(!ed.dirty); +} + +#[test] +fn run_from_the_editor_asks_before_writing_over_data() { + let mut h = harness(); + h.state_mut().run("cell.set", json!({"cell": "A2", "input": "keep me"})).unwrap(); + let steps = vec![json!({"command": "cell.set", "params": {"cell": "A2", "input": "new"}})]; + h.state_mut().run("macro.save", json!({"macro": {"name": "Over", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Over", "check": true})); + h.run_steps(3); + // Check marks the cell it would write over. + let o = h.state().grid.macro_overlay.clone().unwrap(); + assert_eq!(o.conflicts.iter().map(|(_, r)| r.a1()).collect::>(), ["A2"]); + click(&mut h, "Run"); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.choices.len() == 3)); + click(&mut h, "Overwrite"); + assert_eq!(value(&h, "A2"), Value::Text("new".into())); +} + +#[test] +fn a_new_macro_never_replaces_one_with_its_name() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor = None; + h.state_mut().dialog = None; + h.run_steps(2); + click(&mut h, "New Macro"); + for button in ["Save", "Check", "Run"] { + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "three".into(); + ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, button); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1, "{button}"); + assert_eq!(wb.macros[0].name, "Three", "{button}"); + assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact"); + assert!(value(&h, "B1").is_empty(), "{button}: nothing ran"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("A macro called “three” already exists. Choose another name."), "{button}: {msg}"); + assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none()); + h.run_steps(2); + } +} + +#[test] +fn a_rename_that_lands_before_a_failed_save_is_remembered() { + let mut h = harness(); + three(&mut h); + let big = "x".repeat(5 * 1024 * 1024); + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "Renamed".into(); + // Too large to keep in the workbook: the rename lands, the save after it fails. + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big}))); + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, "Save"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("too large"), "{msg}"); + assert_eq!(h.state().macros.editor.as_ref().unwrap().saved_as.as_deref(), Some("Renamed")); + // Trimmed back, the next Save works. + h.state_mut().macros.editor.as_mut().unwrap().draft.steps.pop(); + h.run_steps(2); + click(&mut h, "Save"); + assert!(h.state_mut().message.take().is_none()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3)); +} + +#[test] +fn another_dialog_never_replaces_the_open_editor() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.run_steps(2); + for name in ["formatCells", "macros", "macroEditor"] { + h.state_mut().open_dialog(name, json!({})); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("macroEditor"), "{name}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"), "{name}"); + } + click(&mut h, "New Macro"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); +} + +#[test] +fn enter_in_change_never_picks_a_control_step() { + let mut h = harness(); + three(&mut h); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for search in ["", "stop"] { + h.state_mut().macros.editor.as_mut().unwrap().selected = Some("1".into()); + h.run_steps(2); + click(&mut h, "Change…"); + if !search.is_empty() { + h.input_mut().events.push(Event::Text(search.into())); + h.run_steps(2); + } + h.input_mut().events.push(enter.clone()); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind); + } +} + +#[test] +fn the_editor_works_only_on_the_workbook_it_opened_in() { + let mut h = harness(); + // A new macro whose one step writes A1, not saved yet. + h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"})); + h.run_steps(3); + let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); + h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step]; + let book1 = h.state().session.active().unwrap().uid; + // Another workbook becomes active while the editor is open (File › New, Switch Window, an agent). + h.state_mut().session.new_workbook(); + h.run_steps(3); + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + // Save, Check, Run and Debug never act on the other workbook. + assert!(!gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + gridcraft_ui_egui::macro_editor::check(app, &mut ed); + gridcraft_ui_egui::macro_editor::run(app, &mut ed); + gridcraft_ui_egui::macro_editor::debug_action(app, &mut ed, "step"); + assert!(app.session.macro_debug.is_none()); + assert!(app.session.active().unwrap().wb.macros.is_empty(), "nothing was saved into the other workbook"); + assert_eq!(value(&h, "A1"), Value::Empty, "nothing ran there"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + h.state_mut().message = None; + h.state_mut().macros.editor = Some(ed); + h.run_steps(3); + // The editor stays with its own workbook's window: it is not drawn over the other one. + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"), "the editor is not shown here"); + // Back in its own workbook, the editor shows again and saves there. + let i = h.state().session.documents().iter().position(|d| d.uid == book1).unwrap(); + h.state_mut().session.set_active(i); + h.run_steps(3); + assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "macroEditor"), "the editor is back with its workbook"); + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + assert!(gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + assert_eq!(app.session.active().unwrap().wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fresh"]); + app.macros.editor = Some(ed); + h.run_steps(3); + // Closing the workbook (without saving it) closes its editor. + h.state_mut().run("file.close", json!({"force": true})).unwrap(); + h.run_steps(3); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); +} + +/// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the +/// app answered with over the next frames. +fn request_window_close(h: &mut Harness) -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +fn step_commands(h: &mut Harness) -> Vec { + let mut out = Vec::new(); + for _ in 0..4 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +#[test] +fn quitting_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + // As if the workbook had just been saved. + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + h.run_steps(2); + // Every workbook is saved, but the draft is not: the window stays open and the editor asks. + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some()); + // Cancel keeps the app and the draft. + // The formula bar's Cancel button has the same name; the question's is the button drawn last. + h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); + // Asked again, Don't Save closes the editor and then the window. + request_window_close(&mut h); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() { + let mut h = harness(); + three(&mut h); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + // The saved, unchanged editor closes and the window close goes on (the window then asks to + // close again, which a test harness has to do by hand); the workbook is asked about as + // without the editor. + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); +} + +#[test] +fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() { + let mut h = harness(); + three(&mut h); + // File › Close on a changed workbook asks about saving it, which can't replace the editor. + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Close the macro step editor")), "{:?}", h.state().message); +} + +#[test] +fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { + let mut h = harness(); + three(&mut h); + let ctx = h.ctx.clone(); + let reply = |o: gridcraft_ui_egui::control::Outcome| match o { + gridcraft_ui_egui::control::Outcome::Done(v) => v, + _ => json!(null), + }; + // Unsaved steps are only the editor's to ask about: cancel is refused and keeps them. + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_some(), "{r}"); + assert!(h.state().macros.editor.is_some()); + // A saved editor closes, and takes its overlays with it. + h.state_mut().macros.editor.as_mut().unwrap().dirty = false; + h.get_by_label("Check").click(); + h.run_steps(3); + assert!(h.state().grid.macro_overlay.is_some()); + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_none(), "{r}"); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().grid.macro_overlay.is_none()); +} + +#[test] +fn the_editor_fits_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut session = Session::new(); + session.new_workbook(); + let app = SheetApp::new(session, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + three(&mut h); + h.run_steps(4); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let window = h.get_by_label_contains("Macro: Three").rect(); + assert!(screen.contains_rect(window), "the editor at {window:?} is outside the {size:?} window"); + // The editor's own controls (Delete is left out: the ribbon has one too). + for label in ["Add Step", "Duplicate", "Check", "Run", "Step", "Continue", "Run to Here", "Stop", "Details", "Inputs", "Output", "Save"] { + let r = h.get_by_label(label).rect(); + assert!(screen.contains_rect(r), "{label} at {r:?} is outside the {size:?} window"); + } + } +} + +#[test] +fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { + let mut h = harness(); + let steps: Vec<_> = (0..gridcraft_engine::model::MAX_MACRO_STEPS) + .map(|i| json!({"command": "cell.set", "params": {"cell": format!("A{}", i + 1), "input": "x"}})) + .collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Big", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Big"})); + h.run_steps(2); + // Each row used to copy the whole list: five frames took about a minute. Now well under a + // second; the bound leaves room for a slow machine. + let t = gridcraft_ui_egui::now_ms(); + h.run_steps(5); + let ms = gridcraft_ui_egui::now_ms() - t; + assert!(ms < 10_000.0, "five frames took {ms} ms"); + assert!(h.query_by_label_contains("Step 1: ").is_some()); +} diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs new file mode 100644 index 000000000..171821cb7 --- /dev/null +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -0,0 +1,362 @@ +//! Automate › Macros in the window: recording with the status bar's Stop, the Macros dialog +//! (macros, Automate scripts and a VBA project), macro shortcuts and the question asked when a +//! macro's output would land on data. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(tab: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +fn save(h: &mut Harness, m: serde_json::Value) { + h.state_mut().run("macro.save", json!({"macro": m})).unwrap(); + h.run_steps(2); +} + +fn dialog_name(h: &Harness) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +#[test] +fn record_from_the_ribbon_and_stop_from_the_status_bar() { + let mut h = harness("Automate"); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("recordMacro"), "Record Macro asks for a name first"); + assert!(!h.state().session.is_recording()); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + assert!(h.query_by_label("Stop Recording").is_some(), "the button turns into Stop Recording"); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label_contains("Recording · 1 step").is_some(), "the status bar counts the steps"); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + let name = wb.macros[0].name.clone(); + assert_eq!(name, "Macro1"); + assert_eq!(h.state().macros.recent, [name]); + assert!(h.query_by_label_contains("Recording ·").is_none()); +} + +#[test] +fn use_relative_references_sets_how_the_next_recording_starts() { + let mut h = harness("Automate"); + assert!(h.state().macros.relative, "relative is the default"); + h.get_by_label("Use Relative References").click(); + h.run_steps(2); + assert!(!h.state().macros.relative); + h.get_by_label("Record Macro").click(); + h.run_steps(5); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.macro_recording.as_ref().is_some_and(|r| !r.relative)); +} + +#[test] +fn the_macros_dialog_runs_the_selected_macro() { + let mut h = harness("Automate"); + save( + &mut h, + json!({"name": "Fill", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "C3", "input": "done"}}]}), + ); + save(&mut h, json!({"name": "Other", "steps": []})); + h.get_by_label("Macros").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("macros")); + h.get_by_label("Fill").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "C3"), Value::Text("done".into())); + // One undo step undoes the whole run. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "C3"), Value::Empty); +} + +#[test] +fn the_macros_dialog_lists_scripts_and_the_vba_project_beside_macros() { + let mut s = Session::new(); + s.new_workbook(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(std::sync::Arc::new(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": []})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + // The dialog keeps view.macros (scripts and VBA) as its result, as before. + let items = h.state().dialog.as_ref().and_then(|d| d.result.clone()).and_then(|r| r.as_array().cloned()).unwrap_or_default(); + assert!(items.iter().any(|i| i["kind"] == "vba"), "{items:?}"); + assert!(h.query_by_label_contains("also has a VBA project").is_some()); + // A script and a macro may share a name; each has its own row. + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["kind"], json!("script")); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); + // Deleting asks first, then removes the script and leaves the macro. + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.text.contains("script “Mark”"))); + // The question's Delete, in front of the dialog's. + h.get_all_by_label("Delete").last().expect("the question's Delete").click(); + h.run_steps(3); + assert!(h.query_by_label("Mark (script, 1 step)").is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); +} + +#[test] +fn options_set_a_shortcut_that_runs_the_macro() { + let mut h = harness("Home"); + save( + &mut h, + json!({"name": "Stamp", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "B2", "input": "stamped"}}]}), + ); + assert!(gridcraft_ui_egui::macro_ui::save_options(h.state_mut(), "Stamp", Some('m'), "Stamps B2")); + let m = h.state().session.active().unwrap().wb.macros[0].clone(); + assert_eq!((m.shortcut.as_deref(), m.description.as_str()), (Some("Ctrl+Shift+M"), "Stamps B2")); + let mods = Modifiers::COMMAND | Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: false, repeat: false, modifiers: mods }); + h.run_steps(3); + assert_eq!(value(&h, "B2"), Value::Text("stamped".into())); +} + +#[test] +fn a_shortcut_gridcraft_uses_does_not_run_a_macro() { + let mut h = harness("Home"); + // A file can bring any letter; Ctrl+Shift+Z stays Redo. + let m = gridcraft_engine::model::Macro { name: "Z".into(), shortcut: Some("Ctrl+Shift+Z".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().wb).macros.push(m); + assert_eq!(gridcraft_ui_egui::macro_ui::shortcut_macro(h.state(), Key::Z), None); +} + +#[test] +fn a_macro_whose_output_lands_on_data_asks_and_can_shift_it_down() { + let mut h = harness("Automate"); + h.state_mut().run("cell.set", json!({"cell": "B1", "input": "old"})).unwrap(); + save(&mut h, json!({"name": "Put", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "new"}}]})); + gridcraft_ui_egui::macro_ui::run_macro(h.state_mut(), "Put"); + h.run_steps(2); + let q = h.state().macros.question.clone().expect("a question"); + assert_eq!(q.choices.iter().map(|c| c.0.as_str()).collect::>(), ["Overwrite", "Shift Down", "Shift Right"]); + assert!(q.text.contains("B1"), "{}", q.text); + assert!(!q.text.contains("onConflict"), "the hint for programs is not shown: {}", q.text); + h.get_by_label("Shift Down").click(); + h.run_steps(3); + assert!(h.state().macros.question.is_none()); + assert_eq!(value(&h, "B1"), Value::Text("new".into())); + assert_eq!(value(&h, "B2"), Value::Text("old".into())); + assert_eq!(h.state().macros.recent, ["Put"]); +} + +#[test] +fn the_record_macro_dialog_names_the_macro_and_checks_name_and_shortcut() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + let set = |h: &mut Harness, k: &str, v: &str| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), json!(v)); + h.run_steps(2); + }; + assert_eq!(h.state().dialog.as_ref().unwrap().values["name"], json!("Macro1"), "the next free name is suggested"); + set(&mut h, "name", "taken"); + assert!(h.query_by_label_contains("already exists").is_some()); + set(&mut h, "name", "Two\nlines"); + assert!(h.query_by_label_contains("line breaks").is_some()); + set(&mut h, "name", "Tidy Up"); + set(&mut h, "shortcut", "P"); + assert!(h.query_by_label_contains("already runs macro “Taken”").is_some()); + set(&mut h, "shortcut", "S"); + assert!(h.query_by_label_contains("Save As").is_some()); + // OK stays off while something is wrong. + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + set(&mut h, "shortcut", "Q"); + set(&mut h, "description", "Tidies the block"); + assert!(h.query_by_label_contains("Choose another").is_none()); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + let m = h.state().session.active().unwrap().wb.macros.iter().find(|m| m.name == "Tidy Up").cloned().expect("saved under its name"); + assert_eq!(m.shortcut.as_deref(), Some("Ctrl+Shift+Q")); + assert_eq!(m.description, "Tidies the block"); +} + +#[test] +fn view_macros_record_macro_toggles_recording() { + let mut h = harness("Home"); + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(4); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + // Opening it again while recording stops and saves, as Excel's Stop Recording does. + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(!h.state().session.is_recording()); +} + +#[test] +fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + // As when the workbook was opened with its scripts (#2178). + s.active_mut().unwrap().scripts_trusted = false; + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + assert!(h.query_by_label_contains("came from a file and are disabled").is_some()); + h.get_by_label("Trust Scripts").click(); + // The dialog shrinks without the row: let it settle before the next click. + h.run_steps(4); + assert!(h.state().session.doc().unwrap().scripts_trusted); + assert!(h.query_by_label("Trust Scripts").is_none(), "the row goes once the scripts are trusted"); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); +} + +#[test] +fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("macro.save", json!({"name": "Tidy", "steps": []})).unwrap(); + let app = SheetApp::new(s, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for (dialog, labels) in [("macros", &["Run", "Delete", "Options…", "Cancel"][..]), ("recordMacro", &["OK", "Cancel"][..])] { + h.state_mut().open_dialog(dialog, json!({})); + h.run_steps(4); + for label in labels { + // The dialog's own button: the last one drawn (the ribbon has a Delete too). + let r = h.get_all_by_label(label).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING); + assert!(screen.contains_rect(r), "{dialog}: {label} at {r:?} is outside the {size:?} window"); + } + h.state_mut().dialog = None; + h.run_steps(2); + } + } +} + +#[test] +fn a_question_is_answered_in_the_workbook_that_asked_it() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.is_some()); + // Another workbook with a macro of that name becomes active before the answer (an agent). + h.state_mut().dialog = None; + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1, "the other workbook's macro is kept"); + assert!(h.state().macros.question.is_none()); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + // Asked and answered in the same workbook, it deletes there. + h.state_mut().message = None; + h.run_steps(2); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + h.get_all_by_label("Delete").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.macros.is_empty()); +} + +#[test] +fn the_macros_dialog_closes_when_another_workbook_becomes_active() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Options…").click(); + h.run_steps(2); + // Its rows and the Options pane hold the first workbook's macros; an agent switches workbooks. + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "the dialog closed instead of acting on the other workbook"); + assert!(h.state().session.active().unwrap().wb.macros[0].shortcut.is_none()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 09abd1097..a75e651e6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -15,6 +15,7 @@ mod cell_editor_caret; mod cell_pictures; mod cf_rule_pick; mod chart_resize; +mod chart_source_layering; mod clipboard; mod comments_pane; mod commit_after_pointing; @@ -36,7 +37,9 @@ mod filter_list_scroll; mod filter_menu_enter; mod find_dialog_focus; mod floating_pictures; +mod font_box_filter; mod font_size_box; +mod format_chart_type; mod format_painter; mod formula_bar; mod formula_bar_expand; @@ -49,6 +52,7 @@ mod header_autofit; mod header_column_drag; mod host_chrome; mod hyperlink_ctrl_click; +mod hyperlink_dialog; mod in_cell_editor_size; mod in_cell_editor_wrap; mod insert_copied_cells; @@ -56,7 +60,10 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macro_editor; +mod macros_ui; mod menu_hover; +mod merge_center_label; mod merged_cells; mod more_colors; mod native_print; diff --git a/crates/ui-egui/tests/it/merge_center_label.rs b/crates/ui-egui/tests/it/merge_center_label.rs new file mode 100644 index 000000000..16cb0e798 --- /dev/null +++ b/crates/ui-egui/tests/it/merge_center_label.rs @@ -0,0 +1,51 @@ +//! Home › Merge & Center is a split button: clicking its text label runs `home.mergeCenter`, like +//! its icon does. + +use egui::{Event, Shape, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; +use serde_json::json; + +#[test] +fn clicking_the_label_merges_and_centers() { + for language in [Language::En, Language::De] { + click_label(language); + } +} + +fn click_label(language: Language) { + let label = language.tr("Merge & Center"); + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "A1:B1"})).unwrap(); + let mut h = crate::common::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + { + let mut app = crate::common::app(s); + app.ui.language = language; + app + }, + ); + h.run_steps(4); + let at = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == label => Some(t.pos + t.galley.size() / 2.0), + _ => None, + }) + .unwrap_or_else(|| panic!("the Home tab shows {label}")); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "{label}"); +} diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index 10d5e1848..8756666db 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -12,11 +12,15 @@ const LEFT: &str = "This is a very long left-aligned text that overflows into ma const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells"; fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + harness_with_size(egui::vec2(1200.0, 800.0), setup) +} + +fn harness_with_size(size: egui::Vec2, setup: impl FnOnce(&mut Session)) -> Harness { let mut s = Session::new(); s.new_workbook(); setup(&mut s); s.execute("selection.set", json!({"range": "A20"})).unwrap(); - let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(size).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); @@ -108,3 +112,281 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() { assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}"); assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}"); } + +/// Fully fitting wrapped lines retain the whole cell clip, including +/// room for glyphs extending below their logical line boxes. +#[test] +fn wrapped_text_fitting_lines_keep_full_cell_clip() { + const TEXT: &str = "Short wrapped text with two or three lines."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 15})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 120})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected one wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() >= 2, "text must wrap"); + assert!(text.galley.size().y < cell.height(), "all lines must fit"); + + let last_bottom = text.pos.y + text.galley.rows.last().unwrap().rect().bottom(); + + assert!(last_bottom < cell.bottom() - 1.0, "room must remain below the final line"); + + assert!((clip.bottom() - cell.bottom()).abs() <= 0.5, "full cell clip must be retained: clip={clip:?}, cell={cell:?}"); +} +/// Issue #2752: fixed-height wrapped cells must not paint +/// a fraction of a text line against the next row's border. +#[test] +fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() { + const TEXT: &str = "Row 3 target. This is intentionally long text that must wrap \ + across many lines within a narrow column. It must never \ + paint over the next row."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + + // Apply the custom height after Wrap Text so AutoFit + // cannot expand the row for this scenario. + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 62.67})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected one painted wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() > 4, "test must produce multiple wrapped lines"); + + assert!(text.galley.size().y > cell.height(), "wrapped text must exceed the fixed row height"); + + assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must stay within B3: {clip:?} vs {cell:?}"); + + let mut full_rows_visible = 0; + + for (index, row) in text.galley.rows.iter().enumerate() { + let top = text.pos.y + row.rect().top(); + let bottom = text.pos.y + row.rect().bottom(); + let full_height = bottom - top; + + let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0); + + let partly_visible = visible_height > 0.5 && visible_height < full_height - 0.5; + + assert!( + !partly_visible, + "wrapped line {} is partially visible: \ + line=[{top:.2}, {bottom:.2}], clip={clip:?}", + index + 1 + ); + + if visible_height >= full_height - 0.5 { + full_rows_visible += 1; + } + } + + assert!(full_rows_visible >= 2, "the clipping fix must still show complete text lines"); +} + +/// Issue #2752: a row shorter than a text line must not +/// render a fragment of wrapped text. +#[test] +fn wrapped_text_in_tiny_row_has_no_partial_lines() { + const TEXT: &str = "This wrapped text should not be partly painted."; + + let h = harness(|s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + + s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap(); + + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 5})).unwrap(); + }); + + let cell = cell_rect(&h, "B3"); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected the wrapped text shape"); + + let (text, clip) = shapes[0]; + + assert!(text.galley.rows.len() > 1, "text must wrap into multiple lines"); + + assert!(cell.height() < text.galley.rows[0].rect().height(), "test setup must produce a row shorter than one line"); + + assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must remain inside its cell"); + + assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}"); +} + +/// Issue #2752: the viewport bottom can cut through a line +/// that otherwise fits fully inside a wrapped cell. +#[test] +fn wrapped_text_clipping_survives_partial_vertical_scroll() { + let text = "Wrapped text must stay within both the worksheet cell \ + and the viewport during vertical scrolling. " + .repeat(40); + + let mut h = harness_with_size(egui::vec2(1200.0, 520.0), |s| { + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap(); + s.execute("cell.set", json!({"cell": "B3", "input": text})).unwrap(); + s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "3:3", "height": 350})).unwrap(); + }); + + let initial_cell = cell_rect(&h, "B3"); + + let (target_center, zoom) = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == text => Some(t), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "expected wrapped B3 text"); + + let painted = shapes[0]; + + let center = painted + .galley + .rows + .iter() + .map(|row| { + let top = painted.pos.y + row.rect().top(); + let bottom = painted.pos.y + row.rect().bottom(); + (top, bottom) + }) + .find(|(top, bottom)| { + let center = (top + bottom) / 2.0; + center > geo.cells.bottom() + 32.0 && *bottom < initial_cell.bottom() - 1.0 + }) + .map(|(top, bottom)| (top + bottom) / 2.0) + .expect("fixture must provide a cell-fitting line below the viewport"); + + (center, geo.z) + }; + + let viewport_bottom_before_scroll = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.bottom() + }; + + { + let app = h.state_mut(); + app.view_mut().unwrap().scroll.y = (target_center - viewport_bottom_before_scroll) / zoom; + } + + h.run_steps(4); + + let cell = cell_rect(&h, "B3"); + + let (viewport, scroll_zoom) = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + (geo.cells, geo.z) + }; + + assert!(scroll_zoom > 0.0); + + let shapes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(t) if t.galley.job.text == text => Some((t, shape.clip_rect)), + _ => None, + }) + .collect(); + + assert_eq!(shapes.len(), 1, "B3 must remain painted"); + + let (painted, clip) = shapes[0]; + + assert!(clip.top() >= viewport.top() - 0.5, "clip exceeds viewport top: {clip:?} vs {viewport:?}"); + + assert!(clip.bottom() <= viewport.bottom() + 0.5, "clip exceeds viewport bottom: {clip:?} vs {viewport:?}"); + + assert!(clip.bottom() <= cell.bottom() + 0.5, "clip exceeds cell bottom: {clip:?} vs {cell:?}"); + + let full_line_bottoms: Vec = + painted.galley.rows.iter().map(|row| painted.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect(); + + let cell_line_bottom = full_line_bottoms.last().copied().expect("at least one line must fit within B3"); + + assert!( + painted.galley.rows.iter().any(|row| { + let top = painted.pos.y + row.rect().top(); + let bottom = painted.pos.y + row.rect().bottom(); + + top + 0.5 < viewport.bottom() && viewport.bottom() < bottom - 0.5 && bottom <= cell.bottom() + 0.01 + }), + "viewport bottom must cross a line that fits B3: \ + viewport={viewport:?}, cell={cell:?}" + ); + + let expected_bottom = viewport.bottom().min(cell_line_bottom); + + assert!( + (clip.bottom() - expected_bottom).abs() <= 0.5, + "clip must respect viewport and complete-line limits: \ + clip={clip:?}, expected={expected_bottom:.2}" + ); +} diff --git a/crates/ui-egui/tests/it/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs index 58e6d3f6f..0055bfbfb 100644 --- a/crates/ui-egui/tests/it/whole_line_shortcuts.rs +++ b/crates/ui-egui/tests/it/whole_line_shortcuts.rs @@ -1,4 +1,4 @@ -//! Ctrl+Shift+= and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel; +//! Ctrl+Shift+= (or Ctrl with the numpad's +) and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel; //! any other selection opens the Insert or Delete dialog. use egui::{Key, Modifiers}; @@ -57,6 +57,22 @@ fn whole_columns_are_inserted_and_deleted_at_once() { assert_eq!(value(&h, "A1"), Value::from("a1")); } +#[test] +fn numpad_plus_inserts_without_shift() { + let mut h = harness("2:2"); + press(&mut h, Key::Plus, false); + assert!(h.state().dialog.is_none(), "no dialog for whole rows"); + assert!(value(&h, "A2").is_empty(), "a row was inserted above row 2"); + assert_eq!(value(&h, "A3"), Value::from("a2")); +} + +#[test] +fn unshifted_equals_inserts_nothing() { + let mut h = harness("2:2"); + press(&mut h, Key::Equals, false); + assert_eq!(value(&h, "A2"), Value::from("a2")); +} + #[test] fn other_selections_still_ask_which_way_to_shift() { let mut h = harness("A2:B2"); diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index b53a44828..562afff11 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -23,3 +23,7 @@ zip = { workspace = true } aes = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } + +[dev-dependencies] +# Every registered function name, for the formula file-spelling test. +gridcraft-functions = { workspace = true } diff --git a/crates/xlsx/examples/write_perf.rs b/crates/xlsx/examples/write_perf.rs new file mode 100644 index 000000000..c45f2bdb9 --- /dev/null +++ b/crates/xlsx/examples/write_perf.rs @@ -0,0 +1,89 @@ +//! XLSX save timing on a generated workbook of realistic shape (release build): +//! `cargo run --release -p gridcraft-xlsx --example write_perf -- [rows] [runs] [out.xlsx]` +//! +//! The workbook mixes what a large business sheet holds: repeated and unique text, integers, +//! decimals, dates, booleans, formulas with cached values, a few styles, a header row and a +//! second summary sheet. Prints each run and the median / minimum of `write_xlsx`. +#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it + +use std::time::Instant; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Cell, Formula, NumFmt, Sheet, StyleId, Workbook}; + +fn build(rows: u32) -> Workbook { + let mut wb = Workbook::new(); + let bold = wb.styles.derive(StyleId::DEFAULT, |s| s.font.bold = true); + let money = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("#,##0.00")); + let date = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("yyyy-mm-dd")); + let pct = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("0.0%")); + let regions = ["North", "South", "East", "West", "Central"]; + let products = ["Widget", "Gadget", "Gizmo", "Doohickey", "Sprocket", "Thingamajig", "Whatsit"]; + let mut s = Sheet::new("Sales"); + let set = |s: &mut Sheet, row: u32, col: u32, value: Value, style: StyleId| { + s.cells.set(CellRef::new(row, col), Cell { value, formula: None, style, py: None }); + }; + for (col, title) in ["Order", "Date", "Region", "Product", "Units", "Price", "Revenue", "Share", "Paid", "Note"].iter().enumerate() { + set(&mut s, 0, col as u32, Value::text(*title), bold); + } + for r in 1..=rows { + let i = r as usize; + set(&mut s, r, 0, Value::text(format!("ORD-{:07}", r)), StyleId::DEFAULT); + set(&mut s, r, 1, Value::Number(45000.0 + (i % 730) as f64), date); + set(&mut s, r, 2, Value::text(regions[i % regions.len()]), StyleId::DEFAULT); + set(&mut s, r, 3, Value::text(products[i % products.len()]), StyleId::DEFAULT); + let units = (i * 7 % 250 + 1) as f64; + let price = ((i * 37 % 10_000) as f64) / 100.0 + 0.99; + set(&mut s, r, 4, Value::Number(units), StyleId::DEFAULT); + set(&mut s, r, 5, Value::Number(price), money); + let mut rev = Cell::formula(Formula::new(&format!("=E{0}*F{0}", r + 1))); + rev.value = Value::Number(units * price); + rev.style = money; + s.cells.set(CellRef::new(r, 6), rev); + let mut share = Cell::formula(Formula::new(&format!("=G{}/Summary!$B$1", r + 1))); + share.value = Value::Number(units * price / 1.0e7); + share.style = pct; + s.cells.set(CellRef::new(r, 7), share); + set(&mut s, r, 8, Value::Bool(!i.is_multiple_of(3)), StyleId::DEFAULT); + if i.is_multiple_of(10) { + set(&mut s, r, 9, Value::text(format!("Follow up with customer #{} & check ", i % 977)), StyleId::DEFAULT); + } + } + let mut summary = Sheet::new("Summary"); + let mut total = Cell::formula(Formula::new(&format!("=SUM(Sales!G2:G{})", rows + 1))); + total.value = Value::Number(1.0e7); + summary.cells.set(CellRef::new(0, 1), total); + summary.cells.set(CellRef::new(0, 0), Cell { value: Value::text("Total"), formula: None, style: bold, py: None }); + for (k, region) in regions.iter().enumerate() { + let row = k as u32 + 2; + summary.cells.set(CellRef::new(row, 0), Cell { value: Value::text(*region), formula: None, style: StyleId::DEFAULT, py: None }); + let mut f = Cell::formula(Formula::new(&format!("=SUMIFS(Sales!G:G,Sales!C:C,A{})", row + 1))); + f.value = Value::Number(2.0e6); + f.style = money; + summary.cells.set(CellRef::new(row, 1), f); + } + wb.sheets = vec![std::sync::Arc::new(s), std::sync::Arc::new(summary)]; + wb +} + +fn main() { + let mut args = std::env::args().skip(1); + let rows: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(200_000); + let runs: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(7).max(1); + let out = args.next(); + let wb = build(rows); + let mut times = vec![]; + let mut bytes = vec![]; + for i in 0..runs { + let t = Instant::now(); + bytes = gridcraft_xlsx::write_xlsx(&wb).expect("write"); + let dt = t.elapsed().as_secs_f64() * 1000.0; + println!("run {i}: {dt:.1} ms ({} bytes)", bytes.len()); + times.push(dt); + } + times.sort_by(f64::total_cmp); + println!("write_xlsx {rows} rows: median {:.1} ms, min {:.1} ms", times[times.len() / 2], times[0]); + if let Some(path) = out { + std::fs::write(&path, &bytes).expect("write output"); + } +} diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index 0a63044a0..bf495d747 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -169,6 +169,16 @@ impl Hash { } } + /// The digest's length in bytes. + fn digest_len(self) -> usize { + match self { + Hash::Sha1 => 20, + Hash::Sha256 => 32, + Hash::Sha384 => 48, + Hash::Sha512 => 64, + } + } + /// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64. fn block_size(self) -> usize { match self { @@ -444,6 +454,8 @@ fn agile_descriptor(xml: &[u8]) -> Result { }) } +/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the +/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments. fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { let descriptor = agile_descriptor(xml)?; let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor; @@ -464,15 +476,20 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptE let secret = field(&key_value, &BLOCK_KEY)?; let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; // Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the - // package key (the verifier above only authenticates the password), keyed by a random - // salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included. + // package key (the verifier above only authenticates the password), computed over the whole + // EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a + // random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so + // both lengths are tried. let mut mac_field = hmac_key; aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?; - mac_field.truncate(key_data.salt.len()); let mut declared = hmac_value; aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?; - let mac = hmac(key_data.hash, &mac_field, package); - if declared.get(..mac.len()) != Some(mac.as_slice()) { + let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| { + let Some(key) = mac_field.get(..len) else { return false }; + let mac = hmac(key_data.hash, key, package); + declared.get(..mac.len()) == Some(mac.as_slice()) + }); + if !verified { return Err(malformed("the decrypted data failed its integrity check")); } let (size, data) = package_parts(package)?; diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs index 5f14686e9..56ac7d8a2 100644 --- a/crates/xlsx/src/crypto/tests.rs +++ b/crates/xlsx/src/crypto/tests.rs @@ -3,7 +3,7 @@ //! files are built in code by `crate::testing`. No Excel-produced file is used. use super::*; -use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; +use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, encrypt_standard, with_agile_header}; const PASSWORD: &str = "Pass1234"; /// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values @@ -231,3 +231,20 @@ fn integrity_matches_the_stream_not_the_plaintext() { assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); } + +#[test] +fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() { + // Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size + // key. Both verify, and neither lets a modified package through. + let plain = payload(5000); + for key in [vec![7u8; 64], vec![7u8; 16]] { + let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len()); + let mut tampered = package.clone(); + tampered[100] ^= 1; + assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); + } + // Another length (a key that matches neither) fails. + let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]); + assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index c5e1f686c..966088d5b 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -6,12 +6,29 @@ use gridcraft_model::Formula; /// Formula text for a file. pub fn to_file(f: &Formula) -> String { - match f.expr() { - Some(e) => expr_to_file(e), + match f.expr_arc() { + // Most formulas have nothing the file spells differently: print them without copying. + Some(e) if !needs_file_spelling(&e) => gridcraft_formula::print(&e), + Some(e) => expr_to_file(Expr::clone(&e)), None => f.text.clone(), } } +/// Whether [`expr_to_file`] would change anything in `e`: a LET or LAMBDA (parameter prefixes), +/// a function with a file prefix, a spill, `@` or trim operator, or a structured reference. +fn needs_file_spelling(e: &Expr) -> bool { + let mut found = false; + e.walk(&mut |x| { + found |= match x { + Expr::Call(name, _) => name == "LET" || name == "LAMBDA" || crate::tables::file_function_name(name).is_some(), + Expr::Unary(gridcraft_formula::UnOp::Spill | gridcraft_formula::UnOp::At | gridcraft_formula::UnOp::Trim(_), _) => true, + Expr::Struct(_) => true, + _ => false, + } + }); + found +} + /// Arbitrary formula text (names, CF, validation) for a file. Unparseable text is kept. pub fn text_to_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); @@ -136,6 +153,68 @@ pub fn from_file(text: &str) -> String { mod tests { use super::*; + /// Formulas without a file spelling skip the rewrite; the text must be what it would give. + #[test] + fn plain_formulas_print_as_the_rewrite_would() { + for text in [ + "SUM(A1:B2)*2", + "A1&\"x\"\"y\"", + "Sheet2!$A$1+'My Sheet'!C:C-$3:4", + "IF(A1>0,{1,2;3,4},-B$2%)", + "Sheet1:Sheet3!A1", + "INDEX(A:A,1):B5", + "Total*2", + "XLOOKUP(1,A:A,B:B)", + "LET(x,1,x)", + "LAMBDA(a,a)(1)", + "A1#", + "@A1:A2", + "SUM(A1:.B3)", + "T[@C]", + ] { + let f = Formula::new(text); + assert_eq!(to_file(&f), expr_to_file(f.expr().unwrap()), "{text}"); + } + } + + /// The fast path in [`to_file`] skips the rewrite on a list of what triggers it. Run both paths + /// over every registered function, alone, nested and next to every operator, so a function or + /// rewrite added later without updating [`needs_file_spelling`] fails here. + #[test] + fn every_registered_function_prints_as_the_rewrite_would() { + let shapes = [ + "{}", + "1+{}", + "-{}%", + "SUM({},A1:B2)", + "IF(A1,{},Sheet2!$C$3)", + "LET(x,{},x)", + "LAMBDA(a,{})(1)", + "@{}", + "{}&A1#", + "{}+SUM(A1:.B3)", + "{}+T[@C]", + "{}+T[[#All],[C]]", + "({})*{{1,2;3,4}}", + "{}:B5 C1:D2", + "{}+My Sheet!A:A+Total", + ]; + let mut checked = 0; + for spec in gridcraft_functions::all() { + let n = spec.min_args.max(1).min(spec.max_args).min(4); + let args = ["A1", "B2:C3", "1", "\"x\""].get(..n).unwrap_or_default().join(","); + let call = format!("{}({args})", spec.name); + for shape in shapes { + let text = shape.replace("{}", &call).replace("{{", "{").replace("}}", "}"); + let f = Formula::new(&text); + let Some(e) = f.expr() else { continue }; + assert_eq!(to_file(&f), expr_to_file(e), "{text}"); + checked += 1; + } + } + assert!(checked > gridcraft_functions::all().len() * 10, "only {checked} formulas parsed"); + } + #[test] fn spill_references() { assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"); diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 056fc6114..3e2fea105 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -42,6 +42,16 @@ use crate::read::Ctx; use crate::write::{NS_REL, Out, Rels, XML_DECL}; use crate::xml::{self, esc_attr}; +mod read; +mod rebase; +mod write; + +pub use read::KeptReader; +pub use rebase::{SheetKept, rebase_links}; +#[cfg(test)] +pub(crate) use write::sheet_names_in; +pub use write::{KeptOut, SheetIds, WorkbookKept, workbook_elements, write_package_rels, write_workbook}; + /// Most bytes kept for one workbook. pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; /// Largest single kept part. @@ -178,1209 +188,3 @@ fn rels_part_name(part: &str) -> String { let (dir, file) = package::split_dir(part); if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") } } - -/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the -/// rels part is damaged or too large (its part then can't be kept faithfully). -fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { - let name = rels_part_name(part); - let bytes = match cx.pkg.read_limited(&name, MAX_META) { - Ok(Some(b)) => b, - Ok(None) => return Ok(vec![]), - Err(e) => return Err(e.to_string()), - }; - let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; - let (dir, _) = package::split_dir(part); - let mut out = vec![]; - for r in root.kids("Relationship") { - if out.len() >= MAX_RELS { - return Err(format!("{name}: too many relationships")); - } - let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; - let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); - out.push(PreservedRel { - id: id.to_string(), - rel_type: r.attr("Type").unwrap_or("").to_string(), - target: if external { target.to_string() } else { package::resolve(dir, target) }, - external, - }); - } - Ok(out) -} - -// ---------------------------------------------------------------- reading - -/// The `` entries of the workbook that are chart sheets, each with the number of worksheets -/// (model sheets) before it. -fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { - let mut out = vec![]; - let mut worksheets = 0usize; - for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { - if file_to_model.get(i).copied().flatten().is_some() { - worksheets += 1; - } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { - out.push((worksheets, se)); - } - } - out -} - -/// Collects kept content while a package is read. -pub struct KeptReader { - on: bool, - strict: bool, - defaults: HashMap, - overrides: HashMap, - budget: usize, - parts_left: usize, -} - -impl KeptReader { - pub fn new(cx: &mut Ctx<'_>) -> KeptReader { - let mut k = KeptReader { - on: true, - strict: false, - defaults: HashMap::new(), - overrides: HashMap::new(), - budget: kept_total(), - parts_left: MAX_KEPT_PARTS, - }; - if let Ok(root) = raw_rels(cx, "") - && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) - { - k.on = false; - k.strict = true; - } - match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { - Some(types) => { - for d in types.kids("Default") { - if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { - k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); - } - } - for o in types.kids("Override") { - if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { - k.overrides.insert(normalize(p), t.to_string()); - } - } - } - None => k.on = false, - } - k - } - - fn content_type(&self, part: &str) -> Option { - let key = normalize(part); - if let Some(t) = self.overrides.get(&key) { - return Some(t.clone()); - } - let (_, file) = package::split_dir(&key); - let ext = file.rsplit_once('.').map(|(_, e)| e)?; - self.defaults.get(ext).cloned() - } - - /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the - /// graph reaches rewritten content, a missing or damaged part, or a size limit. - fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { - if root.external { - return Ok(vec![]); - } - let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); - if blocked(root) { - return Err(format!("it refers to {}", kind(&root.rel_type))); - } - let mut parts = vec![]; - let mut bytes = 0usize; - let mut local: HashSet = HashSet::new(); - let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); - while let Some((name, depth)) = queue.pop_front() { - let key = normalize(&name); - if seen.contains(&key) || !local.insert(key) { - continue; - } - if depth > MAX_CHAIN { - return Err("its relationships are nested too deeply".into()); - } - if parts.len() >= self.parts_left { - return Err("it has too many parts".into()); - } - let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; - let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{name} is missing")), - Err(e) => return Err(e.to_string()), - }; - bytes = bytes.saturating_add(data.len()); - if bytes > self.budget { - return Err("it is too large".into()); - } - let rels = raw_rels(cx, &name)?; - for r in rels.iter().filter(|r| !r.external) { - if blocked(r) { - return Err(format!("it refers to {}", kind(&r.rel_type))); - } - queue.push_back((r.target.clone(), depth + 1)); - } - parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); - } - self.budget = self.budget.saturating_sub(bytes); - self.parts_left = self.parts_left.saturating_sub(parts.len()); - Ok(parts) - } - - /// Keeps a set of relationships (all or nothing) and the parts behind them. - fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { - self.keep_through(cx, p, roots, seen, &[]) - } - - /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. - fn keep_through( - &mut self, - cx: &mut Ctx<'_>, - p: &mut Preserved, - roots: &[PreservedRel], - seen: &mut HashSet, - through: &[&str], - ) -> Result<(), String> { - let mut new_parts: Vec = vec![]; - let mut scratch = seen.clone(); - for r in roots { - let parts = self.closure(cx, r, &scratch, through)?; - scratch.extend(parts.iter().map(|q| normalize(&q.name))); - new_parts.extend(parts); - } - *seen = scratch; - p.parts.extend(new_parts); - for r in roots { - if !p.rels.iter().any(|x| x.id == r.id) { - p.rels.push(r.clone()); - } - } - Ok(()) - } - - /// Package-root and workbook-level content. - pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { - if !self.on { - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); - } - if self.strict { - cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); - } - return None; - } - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - // Package root: custom document properties, ribbon customisation, task panes. - if let Ok(root) = raw_rels(cx, "") { - for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { - let mut q = Preserved::default(); - match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { - Ok(()) => { - p.parts.extend(q.parts); - p.package_rels.extend(q.rels); - } - Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), - } - } - } - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); - } - } - // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the - // drawing, chart, style and colour parts behind it. - for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - let name = se.attr("name").unwrap_or("").to_string(); - let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; - match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { - Ok(()) => p.sheets.push(PreservedSheet { - name, - visibility: match se.attr("state") { - Some("hidden") => Visibility::Hidden, - Some("veryHidden") => Visibility::VeryHidden, - _ => Visibility::Visible, - }, - rel_id: r.id.clone(), - before: worksheets, - sheet_id: se.attr_u32("sheetId").unwrap_or(0), - }), - Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), - } - } - // Links to external workbooks: `[1]` in formulas is the position in , - // so they are kept all together or not at all. - let ids: Vec<&str> = - wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); - if !ids.is_empty() { - let roots: Option> = - ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); - let kept = match roots { - Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), - None => Err("a link has no part".into()), - }; - match kept { - Ok(()) => { - let mut x = format!(""); - for id in &ids { - let _ = write!(x, "", esc_attr(id)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); - cx.warn("links to external workbooks were kept; they are not followed or refreshed"); - } - Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), - } - } - // Excel trusts the saved cell values only when both are present; without them it - // recalculates everything on open, and links into closed workbooks come back as errors. - if let Some(v) = wb_xml.child("fileVersion") { - let mut x = String::from(""); - p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); - } - let calc_features = wb_xml - .child("extLst") - .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) - .and_then(|e| e.child("calcFeatures")); - if let Some(c) = calc_features { - let mut x = format!(""); - for name in c.kids("feature").filter_map(|f| f.attr("name")) { - let _ = write!(x, "", esc_attr(name)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - /// Sheet-level content of the sheet part `part`. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { - if !self.on { - return None; - } - let rels = match raw_rels(cx, part) { - Ok(r) => r, - Err(e) => { - cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); - return None; - } - }; - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); - } - } - // Notes have one VML drawing per sheet; another one holds the header and footer pictures. - let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); - let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); - if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { - let scan = match cx.pkg.read(part) { - Ok(Some(bytes)) => scan_sheet(&bytes), - _ => None, - }; - let Some(scan) = scan else { - cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); - return; - }; - // Printer settings (a binary part per sheet) hang off . - if let Some(id) = scan.page_setup.as_deref() - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) - { - match self.keep(cx, p, std::slice::from_ref(r), seen) { - Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), - Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), - } - } - // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, - // kept byte for byte (GridCraft writes the header text, not the pictures). - if let Some(id) = scan.legacy_drawing_hf.as_deref() - && scan.legacy_drawing.as_deref() != Some(id) - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) - { - match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { - Ok(()) => { - let xml = format!("", esc_attr(&r.id)); - p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); - cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); - } - Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), - } - } - let mut frags = scan.fragments; - // Controls and OLE objects are drawn from shapes in the legacy VML drawing. - if frags.iter().any(|(slot, _)| slot != "picture") { - match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { - Ok((vml, parts)) => { - p.parts.extend(parts); - p.vml = Some(vml); - } - Err(why) => { - cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); - frags.retain(|(slot, _)| slot == "picture"); - } - } - } - for (slot, x) in frags { - let mut ids = vec![]; - let ok = map_rel_ids(&x, &mut |id| { - ids.push(id.to_string()); - Some(id.to_string()) - }) - .is_some(); - let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); - let kept = match (ok, roots) { - (true, Some(roots)) => self.keep(cx, p, &roots, seen), - (false, _) => Err("its XML is damaged".into()), - (_, None) => Err("it refers to a missing relationship".into()), - }; - match kept { - Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), - Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), - } - } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { - cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); - } else { - p.vml = None; - } - } - - /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its - /// shapes refer to (preview images). - fn vml( - &mut self, - cx: &mut Ctx<'_>, - rels: &[PreservedRel], - rid: Option<&str>, - seen: &mut HashSet, - ) -> Result<(PreservedPart, Vec), String> { - let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; - // Charged to the kept total like any other part: many sheets can point at one highly - // compressed VML drawing, and each keeps its own copy. - if self.budget == 0 { - return Err("it is too large".into()); - } - let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); - let data = match cx.pkg.read_limited(&rel.target, limit) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{} is missing", rel.target)), - Err(IoError::TooLarge(_)) => return Err("it is too large".into()), - Err(e) => return Err(e.to_string()), - }; - self.budget = self.budget.saturating_sub(data.len()); - let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; - let text = strip_notes(&text); - let vrels = raw_rels(cx, &rel.target)?; - let mut q = Preserved::default(); - let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); - self.keep(cx, &mut q, &roots, seen)?; - let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; - Ok((vml, q.parts)) - } -} - -/// What a sheet part holds for pass-through. -struct SheetScan { - /// (slot, XML with the namespace declarations it needs). - fragments: Vec<(String, String)>, - legacy_drawing: Option, - /// The relationship id on `` (header and footer pictures). - legacy_drawing_hf: Option, - /// The relationship id on `` (printer settings). - page_setup: Option, -} - -/// Finds the sheet's ``, ``, `` (also inside -/// ``) and the `` and `` ids. `None` when the XML is damaged. -fn scan_sheet(bytes: &[u8]) -> Option { - let data = xml::to_utf8(bytes); - let text = std::str::from_utf8(&data).ok()?; - let mut reader = Reader::from_str(text); - let mut decls: Vec<(String, String)> = vec![]; - let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; - let mut in_root = false; - loop { - let start = usize::try_from(reader.buffer_position()).ok()?; - match reader.read_event().ok()? { - Event::Eof => break, - Event::Start(e) if !in_root => { - in_root = true; - for a in e.attributes().with_checks(false).flatten() { - if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") - && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) - && !v.contains('"') - { - decls.push((p.to_string(), v.to_string())); - } - } - } - Event::Start(e) => { - let local = e.local_name().as_ref().to_vec(); - match local.as_slice() { - b"pageSetup" => out.page_setup = rel_id_attr(&e), - b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), - b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e), - _ => {} - } - reader.read_to_end(e.name()).ok()?; - let end = usize::try_from(reader.buffer_position()).ok()?; - let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); - if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { - let frag = text.get(start..end)?; - push_fragment(&mut out, frag, &local, &decls); - } - } - Event::Empty(e) if in_root => { - let local = e.local_name().as_ref().to_vec(); - if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" { - let id = rel_id_attr(&e); - match local.as_slice() { - b"pageSetup" => out.page_setup = id, - b"legacyDrawing" => out.legacy_drawing = id, - _ => out.legacy_drawing_hf = id, - } - } else if local == b"picture" - && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) - { - push_fragment(&mut out, frag, &local, &decls); - } - } - Event::End(_) => break, - _ => {} - } - } - Some(out) -} - -/// The `r:id` of an element (an `id` attribute with a namespace prefix). -fn rel_id_attr(e: &BytesStart<'_>) -> Option { - e.attributes() - .with_checks(false) - .flatten() - .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) - .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) -} - -fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { - let frag = with_decls(frag, decls); - let slot = if local == b"AlternateContent" { - // The kept element is the one the first choice holds. - let Ok(el) = xml::parse(frag.as_bytes()) else { return }; - match el.child("Choice").and_then(|c| c.children.first()) { - Some(c) => c.name.clone(), - None => return, - } - } else { - String::from_utf8_lossy(local).into_owned() - }; - if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { - out.fragments.push((slot, frag)); - } -} - -/// Adds the root's namespace declarations a fragment doesn't declare itself. -fn with_decls(frag: &str, decls: &[(String, String)]) -> String { - let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { - return frag.to_string(); - }; - let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); - let mut add = String::new(); - for (p, v) in decls { - if !head.contains(&format!("xmlns:{p}=")) { - let _ = write!(add, " xmlns:{p}=\"{v}\""); - } - } - format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) -} - -/// Byte ranges of the `…` (or self-closed) elements in VML text. -fn shape_spans(vml: &str) -> Vec<(usize, usize)> { - let mut spans = vec![]; - let mut from = 0; - while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); - if !is_shape { - from = after; - continue; - } - let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; - let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { - tag_end - } else { - match vml.get(tag_end..).and_then(|s| s.find("")) { - Some(j) => tag_end + j + "".len(), - None => break, - } - }; - spans.push((i, end)); - from = end; - } - spans -} - -/// VML without its note shapes (`ObjectType="Note"`). -fn strip_notes(vml: &str) -> String { - let mut out = String::with_capacity(vml.len()); - let mut last = 0; - for (a, b) in shape_spans(vml) { - let shape = vml.get(a..b).unwrap_or(""); - if shape.contains("ObjectType=\"Note\"") { - out.push_str(vml.get(last..a).unwrap_or("")); - last = b; - } - } - out.push_str(vml.get(last..).unwrap_or("")); - out -} - -/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. -fn shape_numbers(vml: &str) -> Vec { - vml.match_indices("_x0000_s") - .filter_map(|(i, m)| { - let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); - digits.parse().ok() - }) - .collect() -} - -/// The highest shape block VML text claims in `` or uses in a shape id. -fn vml_block_max(vml: &str) -> u64 { - let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; - let claimed = vml - .find("().ok()).max().unwrap_or(0)) - .unwrap_or(0); - used.max(claimed) -} - -/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh -/// shape block so they can't collide with the kept shapes the sheet XML refers to. -fn merge_vml(kept: &str, ours: &str) -> String { - let Some(close) = kept.rfind("") else { return ours.to_string() }; - let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; - let mut add = String::new(); - if !kept.contains("id=\"_x0000_t202\"") - && let Some(i) = ours.find("")) - { - add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); - } - for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { - let shape = ours.get(a..b).unwrap_or(""); - let id = block.saturating_mul(1024).saturating_add(1 + k as u64); - match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { - (Some(i), Some(j)) => { - let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); - } - _ => add.push_str(shape), - } - } - let mut head = kept.get(..close).unwrap_or("").to_string(); - // Claim the block in the drawing's id map. - if let Some(i) = head.find(" Option) -> Option { - let mut reader = Reader::from_str(x); - let mut writer = Writer::new(Vec::with_capacity(x.len())); - let mut prefixes: HashSet> = HashSet::new(); - let mut depth = 0usize; - loop { - match reader.read_event().ok()? { - Event::Eof => break, - Event::Start(e) => { - depth += 1; - if depth > MAX_FRAGMENT_DEPTH { - return None; - } - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Start(e)).ok()?; - } - Event::Empty(e) => { - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Empty(e)).ok()?; - } - Event::End(e) => { - depth = depth.saturating_sub(1); - writer.write_event(Event::End(e)).ok()?; - } - ev => writer.write_event(ev).ok()?, - } - } - String::from_utf8(writer.into_inner()).ok() -} - -fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { - for a in e.attributes().with_checks(false) { - let a = a.ok()?; - if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") - && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) - { - prefixes.insert(p.to_vec()); - } - } - let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; - let mut out = BytesStart::new(name); - for a in e.attributes().with_checks(false) { - let a = a.ok()?; - let key = a.key.as_ref(); - let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); - if is_rel { - let old = std::str::from_utf8(&a.value).ok()?; - let new = map(old)?; - out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); - } else { - out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); - } - } - Some(out) -} - -// ---------------------------------------------------------------- writing - -/// Names given to kept parts while a package is written. -#[derive(Default)] -pub struct KeptOut { - used: HashSet, - n: u32, - /// The highest VML shape block a kept header and footer drawing uses: notes go after it. - vml_blocks: u32, - /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. - root: Vec<(String, String, bool)>, -} - -impl KeptOut { - /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the - /// notes drawings GridCraft writes don't reuse their shape ids. - pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { - let vmls = - wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); - let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); - self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); - } - - /// The shape block of the `n`th notes drawing. - pub fn notes_block(&self, n: u32) -> u32 { - n.saturating_add(self.vml_blocks) - } - - /// A part name for a kept part: its original name when that can't collide with anything - /// written, otherwise a fresh one in the same folder. - fn claim(&mut self, original: &str) -> String { - let lower = original.to_ascii_lowercase(); - let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); - if own && self.used.insert(lower) { - return original.to_string(); - } - let (dir, file) = package::split_dir(original); - let (stem, ext) = match file.rsplit_once('.') { - Some((s, e)) => (s, format!(".{e}")), - None => (file, String::new()), - }; - let stem = if own { format!("{stem}_") } else { "kept".to_string() }; - loop { - self.n = self.n.saturating_add(1); - let file = format!("{stem}{}{ext}", self.n); - let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; - if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { - return cand; - } - } - } -} - -/// Original part name (normalized) → written part name. -type NameMap = HashMap; - -fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { - if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } -} - -/// A kept part's relationships, ids unchanged, targets renamed. -fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { - let mut s = format!("{XML_DECL}"); - for r in rels { - let Some(target) = rel_target(r, names) else { continue }; - let _ = write!( - s, - "", - esc_attr(&r.id), - esc_attr(&r.rel_type), - esc_attr(&target), - if r.external { " TargetMode=\"External\"" } else { "" } - ); - } - s.push_str(""); - s -} - -fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { - let mut names = NameMap::new(); - for p in parts { - names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); - } - let mut written = HashSet::new(); - for p in parts { - let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; - if !written.insert(new.clone()) { - continue; - } - out.part(&new, Some(&p.content_type), p.data.to_vec()); - if !p.rels.is_empty() { - out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); - } - } - names -} - -/// Adds the owner's relationships; returns old id → new id. -fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { - let mut ids = HashMap::new(); - for r in list { - if let Some(target) = rel_target(r, names) { - let id = rels.add_raw(&r.rel_type, &target, r.external); - ids.insert(r.id.clone(), id); - } - } - ids -} - -fn remap(x: &str, ids: &HashMap) -> Option { - map_rel_ids(x, &mut |id| ids.get(id).cloned()) -} - -/// A kept chart sheet being written: its `` entry's fields and its new relationship id. -pub struct KeptSheet { - before: usize, - name: String, - visibility: Visibility, - rid: String, - sheet_id: u32, -} - -/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another -/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id -/// that no sheet asks for. -pub struct SheetIds { - wanted: HashSet, - given: HashSet, - next: u32, -} - -impl SheetIds { - pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { - let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); - SheetIds { wanted, given: HashSet::new(), next: 1 } - } - - pub fn take(&mut self, wanted: u32) -> u32 { - if wanted != 0 && self.given.insert(wanted) { - return wanted; - } - while self.wanted.contains(&self.next) || self.given.contains(&self.next) { - self.next += 1; - } - self.given.insert(self.next); - self.next - } -} - -/// The workbook's kept content as written. -#[derive(Default)] -pub struct WorkbookKept { - /// Workbook XML elements that go right after `` (``). - pub elements: String, - sheets: Vec, -} - -impl WorkbookKept { - /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. - pub fn file_index(&self, i: usize) -> usize { - i + self.sheets.iter().filter(|s| s.before <= i).count() - } - - /// The `` entries of the chart sheets that go right before the model's sheet `i` - /// (`i` = the number of sheets for those that come last), with their ids from `ids`. - pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { - let mut x = String::new(); - for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { - let state = match s.visibility { - Visibility::Visible => "", - Visibility::Hidden => " state=\"hidden\"", - Visibility::VeryHidden => " state=\"veryHidden\"", - }; - let name: String = s.name.chars().take(31).collect(); - let id = ids.take(s.sheet_id); - let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); - } - x - } -} - -/// The part names reachable from `root` through kept parts' relationships (normalized). -fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { - let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); - let mut seen = HashSet::new(); - let mut queue = VecDeque::from([normalize(&root.target)]); - while let Some(n) = queue.pop_front() { - if !seen.insert(n.clone()) { - continue; - } - if let Some(q) = by_name.get(&n) { - queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); - } - } - seen -} - -/// Every formula (``) of a chart part, depth first. -fn chart_formulas(el: &xml::El, out: &mut Vec) { - if el.name == "f" && !el.text.trim().is_empty() { - out.push(el.text.trim().to_string()); - } - for c in &el.children { - chart_formulas(c, out); - } -} - -/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to -/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. -pub(crate) fn sheet_names_in(formula: &str) -> Vec { - let mut names = vec![]; - let chars: Vec = formula.chars().collect(); - let at = |i: usize| chars.get(i).copied(); - let mut i = 0; - while let Some(c) = at(i) { - let mut name = String::new(); - let start = i; - if c == '\'' { - i += 1; - while let Some(c) = at(i) { - if c == '\'' { - if at(i + 1) == Some('\'') { - name.push('\''); - i += 2; - continue; - } - break; - } - name.push(c); - i += 1; - } - i += 1; - } else { - while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { - name.push(c); - i += 1; - } - } - if at(i) == Some('!') { - if !name.is_empty() && !name.starts_with('[') { - names.push(name); - } - i += 1; - } else if i == start { - i += 1; - } - } - names -} - -/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets -/// that exist under the same names (the chart is kept as it was, so a series on a deleted or -/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. -fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { - let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); - if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { - return false; - } - let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; - let parts = reachable(p, root); - let mut formulas = vec![]; - for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { - if let Ok(x) = xml::parse(&q.data) { - chart_formulas(&x, &mut formulas); - } - } - formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) -} - -/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back -/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. -pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { - let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; - let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); - let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); - let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); - // Parts reached only by dropped chart sheets go with them. - let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); - if !gone.is_empty() { - let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); - for n in others { - gone.remove(&n); - } - } - let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); - let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); - let names = write_parts(out, &parts); - let ids = attach(wb_rels, &rels, &names); - for r in &p.package_rels { - if let Some(target) = rel_target(r, &names) { - out.kept.root.push((r.rel_type.clone(), target, r.external)); - } - } - WorkbookKept { - elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), - sheets: live - .iter() - .filter_map(|s| { - Some(KeptSheet { - before: s.before, - name: s.name.clone(), - visibility: s.visibility, - rid: ids.get(&s.rel_id)?.clone(), - sheet_id: s.sheet_id, - }) - }) - .collect(), - } -} - -/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). -pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { - let Some(p) = wb.preserved.as_deref() else { return String::new() }; - p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() -} - -/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. -pub fn write_package_rels(root: &mut Rels, out: &mut Out) { - for (ty, target, external) in std::mem::take(&mut out.kept.root) { - root.add_raw(&ty, &target, external); - } -} - -/// A sheet's kept content being written. -pub struct SheetKept<'a> { - p: Option<&'a Preserved>, - names: NameMap, - ids: HashMap, -} - -impl<'a> SheetKept<'a> { - /// Writes the sheet's kept parts and adds their relationships to the sheet's. - pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { - let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; - let names = write_parts(out, &p.parts); - let ids = attach(rels, &p.rels, &names); - SheetKept { p: Some(p), names, ids } - } - - fn vml(&self) -> Option<&'a PreservedPart> { - self.p?.vml.as_ref() - } - - fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { - if !vml.rels.is_empty() { - out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); - } - } - - /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. - pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { - let Some(vml) = self.vml() else { return ours }; - self.write_vml_rels(vml, name, out); - merge_vml(&String::from_utf8_lossy(&vml.data), &ours) - } - - /// The kept VML drawing of a sheet without notes; its new relationship id. - pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { - let vml = self.vml()?; - let name = out.kept.claim(&vml.name); - out.part(&name, Some(CT_VML), vml.data.to_vec()); - self.write_vml_rels(vml, &name, out); - Some(rels.add("vmlDrawing", &format!("/{name}"))) - } - - /// The new relationship id for `` (printer settings). - pub fn page_setup_id(&self) -> Option<&str> { - let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; - self.ids.get(old).map(String::as_str) - } - - /// The kept sheet elements, in schema order. - pub fn elements(&self) -> String { - let Some(p) = self.p else { return String::new() }; - let mut s = String::new(); - for slot in SHEET_SLOTS { - for e in p.elements.iter().filter(|e| e.slot == *slot) { - if let Some(x) = remap(&e.xml, &self.ids) { - s.push_str(&x); - } - } - } - s - } -} - -// ---------------------------------------------------------------- linked workbooks - -/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked -/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel -/// does on Save As, or made absolute when no relative path exists (another drive). `None` when -/// nothing changes. -pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { - let p = wb.preserved.as_deref()?; - let from = Folder::of(wb.source_dir.as_deref()?)?; - let to = Folder::of(to)?; - if from == to { - return None; - } - let mut parts = p.parts.clone(); - let mut changed = false; - for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { - if r.external - && is_kind(r, &["externalLinkPath"]) - && let Some(target) = rebase(&r.target, &from, &to) - { - r.target = target; - changed = true; - } - } - if !changed { - return None; - } - let mut out = wb.clone(); - out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); - Some(out) -} - -/// An absolute folder, lexically normalized. -#[derive(PartialEq)] -struct Folder { - /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). - root: String, - names: Vec, -} - -impl Folder { - fn of(dir: &Path) -> Option { - if !dir.is_absolute() { - return None; - } - let mut f = Folder { root: String::new(), names: vec![] }; - for c in dir.components() { - match c { - Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), - Component::CurDir => {} - Component::ParentDir => { - f.names.pop(); - } - Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), - } - } - Some(f) - } -} - -/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an -/// absolute path or URL, or a path that climbs above the root. -fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { - if !is_relative_path(target) { - return None; - } - // Each name as the file system knows it, and as it is written in a target. Names taken from - // the original target keep their spelling. - let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); - for seg in target.split(['/', '\\']) { - match seg { - "" | "." => {} - ".." => { - path.pop()?; - } - _ => path.push((decode_name(seg), seg.to_string())), - } - } - let folders = path.len().checked_sub(1)?; - if !from.root.eq_ignore_ascii_case(&to.root) { - let sep = if from.root.contains('\\') { "\\" } else { "/" }; - let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); - return Some(format!("file:///{}{}", from.root, names.join(sep))); - } - let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); - let up = "../".repeat(to.names.len().saturating_sub(common)); - let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); - Some(format!("{up}{}", down.join("/"))) -} - -/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). -fn is_relative_path(target: &str) -> bool { - let scheme = target.split_once(':').is_some_and(|(s, _)| { - s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) - }); - !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme -} - -/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path -/// can't hold are percent-encoded, other characters are written as they are. -fn encode_name(name: &str) -> String { - let mut s = String::with_capacity(name.len()); - for c in name.chars() { - if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { - s.push(c); - } else { - let _ = write!(s, "%{:02X}", c as u32); - } - } - s -} - -/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. -fn decode_name(seg: &str) -> String { - let b = seg.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while let Some(&c) = b.get(i) { - let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); - match hex { - Some(byte) if c == b'%' => { - out.push(byte); - i += 3; - } - _ => { - out.push(c); - i += 1; - } - } - } - String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) -} diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs new file mode 100644 index 000000000..6be20d76d --- /dev/null +++ b/crates/xlsx/src/passthrough/read.rs @@ -0,0 +1,688 @@ +use super::*; + +/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the +/// rels part is damaged or too large (its part then can't be kept faithfully). +fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { + let name = rels_part_name(part); + let bytes = match cx.pkg.read_limited(&name, MAX_META) { + Ok(Some(b)) => b, + Ok(None) => return Ok(vec![]), + Err(e) => return Err(e.to_string()), + }; + let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; + let (dir, _) = package::split_dir(part); + let mut out = vec![]; + for r in root.kids("Relationship") { + if out.len() >= MAX_RELS { + return Err(format!("{name}: too many relationships")); + } + let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + out.push(PreservedRel { + id: id.to_string(), + rel_type: r.attr("Type").unwrap_or("").to_string(), + target: if external { target.to_string() } else { package::resolve(dir, target) }, + external, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading + +/// The `` entries of the workbook that are chart sheets, each with the number of worksheets +/// (model sheets) before it. +fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { + let mut out = vec![]; + let mut worksheets = 0usize; + for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { + if file_to_model.get(i).copied().flatten().is_some() { + worksheets += 1; + } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { + out.push((worksheets, se)); + } + } + out +} + +/// Collects kept content while a package is read. +pub struct KeptReader { + on: bool, + strict: bool, + defaults: HashMap, + overrides: HashMap, + budget: usize, + parts_left: usize, +} + +impl KeptReader { + pub fn new(cx: &mut Ctx<'_>) -> KeptReader { + let mut k = KeptReader { + on: true, + strict: false, + defaults: HashMap::new(), + overrides: HashMap::new(), + budget: kept_total(), + parts_left: MAX_KEPT_PARTS, + }; + if let Ok(root) = raw_rels(cx, "") + && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) + { + k.on = false; + k.strict = true; + } + match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { + Some(types) => { + for d in types.kids("Default") { + if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { + k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); + } + } + for o in types.kids("Override") { + if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { + k.overrides.insert(normalize(p), t.to_string()); + } + } + } + None => k.on = false, + } + k + } + + fn content_type(&self, part: &str) -> Option { + let key = normalize(part); + if let Some(t) = self.overrides.get(&key) { + return Some(t.clone()); + } + let (_, file) = package::split_dir(&key); + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + self.defaults.get(ext).cloned() + } + + /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the + /// graph reaches rewritten content, a missing or damaged part, or a size limit. + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { + if root.external { + return Ok(vec![]); + } + let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); + if blocked(root) { + return Err(format!("it refers to {}", kind(&root.rel_type))); + } + let mut parts = vec![]; + let mut bytes = 0usize; + let mut local: HashSet = HashSet::new(); + let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); + while let Some((name, depth)) = queue.pop_front() { + let key = normalize(&name); + if seen.contains(&key) || !local.insert(key) { + continue; + } + if depth > MAX_CHAIN { + return Err("its relationships are nested too deeply".into()); + } + if parts.len() >= self.parts_left { + return Err("it has too many parts".into()); + } + let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; + let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{name} is missing")), + Err(e) => return Err(e.to_string()), + }; + bytes = bytes.saturating_add(data.len()); + if bytes > self.budget { + return Err("it is too large".into()); + } + let rels = raw_rels(cx, &name)?; + for r in rels.iter().filter(|r| !r.external) { + if blocked(r) { + return Err(format!("it refers to {}", kind(&r.rel_type))); + } + queue.push_back((r.target.clone(), depth + 1)); + } + parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); + } + self.budget = self.budget.saturating_sub(bytes); + self.parts_left = self.parts_left.saturating_sub(parts.len()); + Ok(parts) + } + + /// Keeps a set of relationships (all or nothing) and the parts behind them. + fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { + self.keep_through(cx, p, roots, seen, &[]) + } + + /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. + fn keep_through( + &mut self, + cx: &mut Ctx<'_>, + p: &mut Preserved, + roots: &[PreservedRel], + seen: &mut HashSet, + through: &[&str], + ) -> Result<(), String> { + let mut new_parts: Vec = vec![]; + let mut scratch = seen.clone(); + for r in roots { + let parts = self.closure(cx, r, &scratch, through)?; + scratch.extend(parts.iter().map(|q| normalize(&q.name))); + new_parts.extend(parts); + } + *seen = scratch; + p.parts.extend(new_parts); + for r in roots { + if !p.rels.iter().any(|x| x.id == r.id) { + p.rels.push(r.clone()); + } + } + Ok(()) + } + + /// Package-root and workbook-level content. + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { + if !self.on { + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); + } + if self.strict { + cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); + } + return None; + } + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + // Package root: custom document properties, ribbon customisation, task panes. + if let Ok(root) = raw_rels(cx, "") { + for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { + let mut q = Preserved::default(); + match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { + Ok(()) => { + p.parts.extend(q.parts); + p.package_rels.extend(q.rels); + } + Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), + } + } + } + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); + } + } + // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the + // drawing, chart, style and colour parts behind it. + for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + let name = se.attr("name").unwrap_or("").to_string(); + let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; + match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { + Ok(()) => p.sheets.push(PreservedSheet { + name, + visibility: match se.attr("state") { + Some("hidden") => Visibility::Hidden, + Some("veryHidden") => Visibility::VeryHidden, + _ => Visibility::Visible, + }, + rel_id: r.id.clone(), + before: worksheets, + sheet_id: se.attr_u32("sheetId").unwrap_or(0), + }), + Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), + } + } + // Links to external workbooks: `[1]` in formulas is the position in , + // so they are kept all together or not at all. + let ids: Vec<&str> = + wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); + if !ids.is_empty() { + let roots: Option> = + ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); + let kept = match roots { + Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), + None => Err("a link has no part".into()), + }; + match kept { + Ok(()) => { + let mut x = format!(""); + for id in &ids { + let _ = write!(x, "", esc_attr(id)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); + cx.warn("links to external workbooks were kept; they are not followed or refreshed"); + } + Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), + } + } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + /// Sheet-level content of the sheet part `part`. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + if !self.on { + return None; + } + let rels = match raw_rels(cx, part) { + Ok(r) => r, + Err(e) => { + cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); + return None; + } + }; + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); + } + } + // Notes have one VML drawing per sheet; another one holds the header and footer pictures. + let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); + let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + let scan = match cx.pkg.read(part) { + Ok(Some(bytes)) => scan_sheet(&bytes), + _ => None, + }; + let Some(scan) = scan else { + cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); + return; + }; + // Printer settings (a binary part per sheet) hang off . + if let Some(id) = scan.page_setup.as_deref() + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) + { + match self.keep(cx, p, std::slice::from_ref(r), seen) { + Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), + Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), + } + } + // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, + // kept byte for byte (GridCraft writes the header text, not the pictures). + if let Some(id) = scan.legacy_drawing_hf.as_deref() + && scan.legacy_drawing.as_deref() != Some(id) + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) + { + match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { + Ok(()) => { + let xml = format!("", esc_attr(&r.id)); + p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); + cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); + } + Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), + } + } + let mut frags = scan.fragments; + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. + if frags.iter().any(|(slot, _)| slot != "picture") { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + Ok((vml, parts)) => { + p.parts.extend(parts); + p.vml = Some(vml); + } + Err(why) => { + cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); + frags.retain(|(slot, _)| slot == "picture"); + } + } + } + for (slot, x) in frags { + let mut ids = vec![]; + let ok = map_rel_ids(&x, &mut |id| { + ids.push(id.to_string()); + Some(id.to_string()) + }) + .is_some(); + let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); + let kept = match (ok, roots) { + (true, Some(roots)) => self.keep(cx, p, &roots, seen), + (false, _) => Err("its XML is damaged".into()), + (_, None) => Err("it refers to a missing relationship".into()), + }; + match kept { + Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), + Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), + } + } + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); + } else { + p.vml = None; + } + } + + /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its + /// shapes refer to (preview images). + fn vml( + &mut self, + cx: &mut Ctx<'_>, + rels: &[PreservedRel], + rid: Option<&str>, + seen: &mut HashSet, + ) -> Result<(PreservedPart, Vec), String> { + let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; + // Charged to the kept total like any other part: many sheets can point at one highly + // compressed VML drawing, and each keeps its own copy. + if self.budget == 0 { + return Err("it is too large".into()); + } + let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); + let data = match cx.pkg.read_limited(&rel.target, limit) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(IoError::TooLarge(_)) => return Err("it is too large".into()), + Err(e) => return Err(e.to_string()), + }; + self.budget = self.budget.saturating_sub(data.len()); + let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; + let text = strip_notes(&text); + let vrels = raw_rels(cx, &rel.target)?; + let mut q = Preserved::default(); + let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); + self.keep(cx, &mut q, &roots, seen)?; + let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; + Ok((vml, q.parts)) + } +} + +/// What a sheet part holds for pass-through. +struct SheetScan { + /// (slot, XML with the namespace declarations it needs). + fragments: Vec<(String, String)>, + legacy_drawing: Option, + /// The relationship id on `` (header and footer pictures). + legacy_drawing_hf: Option, + /// The relationship id on `` (printer settings). + page_setup: Option, +} + +/// Finds the sheet's ``, ``, `` (also inside +/// ``) and the `` and `` ids. `None` when the XML is damaged. +fn scan_sheet(bytes: &[u8]) -> Option { + let data = xml::to_utf8(bytes); + let text = std::str::from_utf8(&data).ok()?; + let mut reader = Reader::from_str(text); + let mut decls: Vec<(String, String)> = vec![]; + let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; + let mut in_root = false; + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) if !in_root => { + in_root = true; + for a in e.attributes().with_checks(false).flatten() { + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) + && !v.contains('"') + { + decls.push((p.to_string(), v.to_string())); + } + } + } + Event::Start(e) => { + let local = e.local_name().as_ref().to_vec(); + match local.as_slice() { + b"pageSetup" => out.page_setup = rel_id_attr(&e), + b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), + b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e), + _ => {} + } + reader.read_to_end(e.name()).ok()?; + let end = usize::try_from(reader.buffer_position()).ok()?; + let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); + if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { + let frag = text.get(start..end)?; + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::Empty(e) if in_root => { + let local = e.local_name().as_ref().to_vec(); + if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" { + let id = rel_id_attr(&e); + match local.as_slice() { + b"pageSetup" => out.page_setup = id, + b"legacyDrawing" => out.legacy_drawing = id, + _ => out.legacy_drawing_hf = id, + } + } else if local == b"picture" + && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) + { + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::End(_) => break, + _ => {} + } + } + Some(out) +} + +/// The `r:id` of an element (an `id` attribute with a namespace prefix). +fn rel_id_attr(e: &BytesStart<'_>) -> Option { + e.attributes() + .with_checks(false) + .flatten() + .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) + .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) +} + +fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { + let frag = with_decls(frag, decls); + let slot = if local == b"AlternateContent" { + // The kept element is the one the first choice holds. + let Ok(el) = xml::parse(frag.as_bytes()) else { return }; + match el.child("Choice").and_then(|c| c.children.first()) { + Some(c) => c.name.clone(), + None => return, + } + } else { + String::from_utf8_lossy(local).into_owned() + }; + if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { + out.fragments.push((slot, frag)); + } +} + +/// Adds the root's namespace declarations a fragment doesn't declare itself. +fn with_decls(frag: &str, decls: &[(String, String)]) -> String { + let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { + return frag.to_string(); + }; + let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); + let mut add = String::new(); + for (p, v) in decls { + if !head.contains(&format!("xmlns:{p}=")) { + let _ = write!(add, " xmlns:{p}=\"{v}\""); + } + } + format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) +} + +/// Byte ranges of the `…` (or self-closed) elements in VML text. +fn shape_spans(vml: &str) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut from = 0; + while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); + if !is_shape { + from = after; + continue; + } + let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; + let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { + tag_end + } else { + match vml.get(tag_end..).and_then(|s| s.find("")) { + Some(j) => tag_end + j + "".len(), + None => break, + } + }; + spans.push((i, end)); + from = end; + } + spans +} + +/// VML without its note shapes (`ObjectType="Note"`). +fn strip_notes(vml: &str) -> String { + let mut out = String::with_capacity(vml.len()); + let mut last = 0; + for (a, b) in shape_spans(vml) { + let shape = vml.get(a..b).unwrap_or(""); + if shape.contains("ObjectType=\"Note\"") { + out.push_str(vml.get(last..a).unwrap_or("")); + last = b; + } + } + out.push_str(vml.get(last..).unwrap_or("")); + out +} + +/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. +fn shape_numbers(vml: &str) -> Vec { + vml.match_indices("_x0000_s") + .filter_map(|(i, m)| { + let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() + }) + .collect() +} + +/// The highest shape block VML text claims in `` or uses in a shape id. +pub(super) fn vml_block_max(vml: &str) -> u64 { + let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; + let claimed = vml + .find("().ok()).max().unwrap_or(0)) + .unwrap_or(0); + used.max(claimed) +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh +/// shape block so they can't collide with the kept shapes the sheet XML refers to. +pub(super) fn merge_vml(kept: &str, ours: &str) -> String { + let Some(close) = kept.rfind("") else { return ours.to_string() }; + let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let mut add = String::new(); + if !kept.contains("id=\"_x0000_t202\"") + && let Some(i) = ours.find("")) + { + add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + } + for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let shape = ours.get(a..b).unwrap_or(""); + let id = block.saturating_mul(1024).saturating_add(1 + k as u64); + match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { + (Some(i), Some(j)) => { + let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); + } + _ => add.push_str(shape), + } + } + let mut head = kept.get(..close).unwrap_or("").to_string(); + // Claim the block in the drawing's id map. + if let Some(i) = head.find(" Option) -> Option { + let mut reader = Reader::from_str(x); + let mut writer = Writer::new(Vec::with_capacity(x.len())); + let mut prefixes: HashSet> = HashSet::new(); + let mut depth = 0usize; + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => { + depth += 1; + if depth > MAX_FRAGMENT_DEPTH { + return None; + } + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Start(e)).ok()?; + } + Event::Empty(e) => { + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Empty(e)).ok()?; + } + Event::End(e) => { + depth = depth.saturating_sub(1); + writer.write_event(Event::End(e)).ok()?; + } + ev => writer.write_event(ev).ok()?, + } + } + String::from_utf8(writer.into_inner()).ok() +} + +fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) + { + prefixes.insert(p.to_vec()); + } + } + let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; + let mut out = BytesStart::new(name); + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + let key = a.key.as_ref(); + let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); + if is_rel { + let old = std::str::from_utf8(&a.value).ok()?; + let new = map(old)?; + out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); + } else { + out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); + } + } + Some(out) +} diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs new file mode 100644 index 000000000..d758f86d6 --- /dev/null +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -0,0 +1,199 @@ +use super::read::merge_vml; +use super::write::{NameMap, attach, part_rels_xml, remap, write_parts}; +use super::*; + +/// A sheet's kept content being written. +pub struct SheetKept<'a> { + p: Option<&'a Preserved>, + names: NameMap, + ids: HashMap, +} + +impl<'a> SheetKept<'a> { + /// Writes the sheet's kept parts and adds their relationships to the sheet's. + pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { + let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; + let names = write_parts(out, &p.parts); + let ids = attach(rels, &p.rels, &names); + SheetKept { p: Some(p), names, ids } + } + + fn vml(&self) -> Option<&'a PreservedPart> { + self.p?.vml.as_ref() + } + + fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { + if !vml.rels.is_empty() { + out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); + } + } + + /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { + let Some(vml) = self.vml() else { return ours }; + self.write_vml_rels(vml, name, out); + merge_vml(&String::from_utf8_lossy(&vml.data), &ours) + } + + /// The kept VML drawing of a sheet without notes; its new relationship id. + pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { + let vml = self.vml()?; + let name = out.kept.claim(&vml.name); + out.part(&name, Some(CT_VML), vml.data.to_vec()); + self.write_vml_rels(vml, &name, out); + Some(rels.add("vmlDrawing", &format!("/{name}"))) + } + + /// The new relationship id for `` (printer settings). + pub fn page_setup_id(&self) -> Option<&str> { + let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; + self.ids.get(old).map(String::as_str) + } + + /// The kept sheet elements, in schema order. + pub fn elements(&self) -> String { + let Some(p) = self.p else { return String::new() }; + let mut s = String::new(); + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(x) = remap(&e.xml, &self.ids) { + s.push_str(&x); + } + } + } + s + } +} + +// ---------------------------------------------------------------- linked workbooks + +/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked +/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel +/// does on Save As, or made absolute when no relative path exists (another drive). `None` when +/// nothing changes. +pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { + let p = wb.preserved.as_deref()?; + let from = Folder::of(wb.source_dir.as_deref()?)?; + let to = Folder::of(to)?; + if from == to { + return None; + } + let mut parts = p.parts.clone(); + let mut changed = false; + for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { + if r.external + && is_kind(r, &["externalLinkPath"]) + && let Some(target) = rebase(&r.target, &from, &to) + { + r.target = target; + changed = true; + } + } + if !changed { + return None; + } + let mut out = wb.clone(); + out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); + Some(out) +} + +/// An absolute folder, lexically normalized. +#[derive(PartialEq)] +struct Folder { + /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). + root: String, + names: Vec, +} + +impl Folder { + fn of(dir: &Path) -> Option { + if !dir.is_absolute() { + return None; + } + let mut f = Folder { root: String::new(), names: vec![] }; + for c in dir.components() { + match c { + Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), + Component::CurDir => {} + Component::ParentDir => { + f.names.pop(); + } + Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), + } + } + Some(f) + } +} + +/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an +/// absolute path or URL, or a path that climbs above the root. +fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { + if !is_relative_path(target) { + return None; + } + // Each name as the file system knows it, and as it is written in a target. Names taken from + // the original target keep their spelling. + let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); + for seg in target.split(['/', '\\']) { + match seg { + "" | "." => {} + ".." => { + path.pop()?; + } + _ => path.push((decode_name(seg), seg.to_string())), + } + } + let folders = path.len().checked_sub(1)?; + if !from.root.eq_ignore_ascii_case(&to.root) { + let sep = if from.root.contains('\\') { "\\" } else { "/" }; + let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); + return Some(format!("file:///{}{}", from.root, names.join(sep))); + } + let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); + let up = "../".repeat(to.names.len().saturating_sub(common)); + let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); + Some(format!("{up}{}", down.join("/"))) +} + +/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). +fn is_relative_path(target: &str) -> bool { + let scheme = target.split_once(':').is_some_and(|(s, _)| { + s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme +} + +/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path +/// can't hold are percent-encoded, other characters are written as they are. +fn encode_name(name: &str) -> String { + let mut s = String::with_capacity(name.len()); + for c in name.chars() { + if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { + s.push(c); + } else { + let _ = write!(s, "%{:02X}", c as u32); + } + } + s +} + +/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. +fn decode_name(seg: &str) -> String { + let b = seg.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(byte) if c == b'%' => { + out.push(byte); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) +} diff --git a/crates/xlsx/src/passthrough/write.rs b/crates/xlsx/src/passthrough/write.rs new file mode 100644 index 000000000..8b8d24591 --- /dev/null +++ b/crates/xlsx/src/passthrough/write.rs @@ -0,0 +1,325 @@ +use super::read::{map_rel_ids, vml_block_max}; +use super::*; + +// ---------------------------------------------------------------- writing + +/// Names given to kept parts while a package is written. +#[derive(Default)] +pub struct KeptOut { + used: HashSet, + n: u32, + /// The highest VML shape block a kept header and footer drawing uses: notes go after it. + vml_blocks: u32, + /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. + root: Vec<(String, String, bool)>, +} + +impl KeptOut { + /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the + /// notes drawings GridCraft writes don't reuse their shape ids. + pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { + let vmls = + wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); + let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); + self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); + } + + /// The shape block of the `n`th notes drawing. + pub fn notes_block(&self, n: u32) -> u32 { + n.saturating_add(self.vml_blocks) + } + + /// A part name for a kept part: its original name when that can't collide with anything + /// written, otherwise a fresh one in the same folder. + pub(super) fn claim(&mut self, original: &str) -> String { + let lower = original.to_ascii_lowercase(); + let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); + if own && self.used.insert(lower) { + return original.to_string(); + } + let (dir, file) = package::split_dir(original); + let (stem, ext) = match file.rsplit_once('.') { + Some((s, e)) => (s, format!(".{e}")), + None => (file, String::new()), + }; + let stem = if own { format!("{stem}_") } else { "kept".to_string() }; + loop { + self.n = self.n.saturating_add(1); + let file = format!("{stem}{}{ext}", self.n); + let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; + if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { + return cand; + } + } + } +} + +/// Original part name (normalized) → written part name. +pub(super) type NameMap = HashMap; + +fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { + if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } +} + +/// A kept part's relationships, ids unchanged, targets renamed. +pub(super) fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { + let mut s = format!("{XML_DECL}"); + for r in rels { + let Some(target) = rel_target(r, names) else { continue }; + let _ = write!( + s, + "", + esc_attr(&r.id), + esc_attr(&r.rel_type), + esc_attr(&target), + if r.external { " TargetMode=\"External\"" } else { "" } + ); + } + s.push_str(""); + s +} + +pub(super) fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { + let mut names = NameMap::new(); + for p in parts { + names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); + } + let mut written = HashSet::new(); + for p in parts { + let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; + if !written.insert(new.clone()) { + continue; + } + out.part(&new, Some(&p.content_type), p.data.to_vec()); + if !p.rels.is_empty() { + out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); + } + } + names +} + +/// Adds the owner's relationships; returns old id → new id. +pub(super) fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { + let mut ids = HashMap::new(); + for r in list { + if let Some(target) = rel_target(r, names) { + let id = rels.add_raw(&r.rel_type, &target, r.external); + ids.insert(r.id.clone(), id); + } + } + ids +} + +pub(super) fn remap(x: &str, ids: &HashMap) -> Option { + map_rel_ids(x, &mut |id| ids.get(id).cloned()) +} + +/// A kept chart sheet being written: its `` entry's fields and its new relationship id. +pub struct KeptSheet { + before: usize, + name: String, + visibility: Visibility, + rid: String, + sheet_id: u32, +} + +/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another +/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id +/// that no sheet asks for. +pub struct SheetIds { + wanted: HashSet, + given: HashSet, + next: u32, +} + +impl SheetIds { + pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { + let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); + SheetIds { wanted, given: HashSet::new(), next: 1 } + } + + pub fn take(&mut self, wanted: u32) -> u32 { + if wanted != 0 && self.given.insert(wanted) { + return wanted; + } + while self.wanted.contains(&self.next) || self.given.contains(&self.next) { + self.next += 1; + } + self.given.insert(self.next); + self.next + } +} + +/// The workbook's kept content as written. +#[derive(Default)] +pub struct WorkbookKept { + /// Workbook XML elements that go right after `` (``). + pub elements: String, + sheets: Vec, +} + +impl WorkbookKept { + /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. + pub fn file_index(&self, i: usize) -> usize { + i + self.sheets.iter().filter(|s| s.before <= i).count() + } + + /// The `` entries of the chart sheets that go right before the model's sheet `i` + /// (`i` = the number of sheets for those that come last), with their ids from `ids`. + pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { + let mut x = String::new(); + for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { + let state = match s.visibility { + Visibility::Visible => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = s.name.chars().take(31).collect(); + let id = ids.take(s.sheet_id); + let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); + } + x + } +} + +/// The part names reachable from `root` through kept parts' relationships (normalized). +fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { + let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([normalize(&root.target)]); + while let Some(n) = queue.pop_front() { + if !seen.insert(n.clone()) { + continue; + } + if let Some(q) = by_name.get(&n) { + queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); + } + } + seen +} + +/// Every formula (``) of a chart part, depth first. +fn chart_formulas(el: &xml::El, out: &mut Vec) { + if el.name == "f" && !el.text.trim().is_empty() { + out.push(el.text.trim().to_string()); + } + for c in &el.children { + chart_formulas(c, out); + } +} + +/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to +/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. +pub(crate) fn sheet_names_in(formula: &str) -> Vec { + let mut names = vec![]; + let chars: Vec = formula.chars().collect(); + let at = |i: usize| chars.get(i).copied(); + let mut i = 0; + while let Some(c) = at(i) { + let mut name = String::new(); + let start = i; + if c == '\'' { + i += 1; + while let Some(c) = at(i) { + if c == '\'' { + if at(i + 1) == Some('\'') { + name.push('\''); + i += 2; + continue; + } + break; + } + name.push(c); + i += 1; + } + i += 1; + } else { + while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { + name.push(c); + i += 1; + } + } + if at(i) == Some('!') { + if !name.is_empty() && !name.starts_with('[') { + names.push(name); + } + i += 1; + } else if i == start { + i += 1; + } + } + names +} + +/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets +/// that exist under the same names (the chart is kept as it was, so a series on a deleted or +/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. +fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { + let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); + if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { + return false; + } + let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; + let parts = reachable(p, root); + let mut formulas = vec![]; + for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { + if let Ok(x) = xml::parse(&q.data) { + chart_formulas(&x, &mut formulas); + } + } + formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) +} + +/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back +/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { + let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; + let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); + let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); + let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); + // Parts reached only by dropped chart sheets go with them. + let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); + if !gone.is_empty() { + let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); + for n in others { + gone.remove(&n); + } + } + let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); + let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); + let names = write_parts(out, &parts); + let ids = attach(wb_rels, &rels, &names); + for r in &p.package_rels { + if let Some(target) = rel_target(r, &names) { + out.kept.root.push((r.rel_type.clone(), target, r.external)); + } + } + WorkbookKept { + elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), + sheets: live + .iter() + .filter_map(|s| { + Some(KeptSheet { + before: s.before, + name: s.name.clone(), + visibility: s.visibility, + rid: ids.get(&s.rel_id)?.clone(), + sheet_id: s.sheet_id, + }) + }) + .collect(), + } +} + +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + +/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. +pub fn write_package_rels(root: &mut Rels, out: &mut Out) { + for (ty, target, external) in std::mem::take(&mut out.kept.root) { + root.add_raw(&ty, &target, external); + } +} diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index b3837e2f0..45a618893 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -26,6 +26,12 @@ use crate::read::Ctx; use crate::write::{NS_MAIN, NS_REL, Out, Rels, XML_DECL, abs_range}; use crate::xml::{El, esc_attr}; +mod read; +mod write; + +pub use read::{CachesIn, read_pivots}; +pub use write::PivotWriter; + pub const CT_PIVOT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"; pub const CT_PIVOT_CACHE_DEF: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"; pub const CT_PIVOT_CACHE_REC: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"; @@ -168,1259 +174,3 @@ fn key(v: &Value) -> Key { Value::Array(_) => key(&v.scalar()), } } - -#[derive(Default)] -struct Stats { - blank: bool, - text: bool, - number: bool, - non_int: bool, - boolean: bool, - error: bool, - long: bool, - min: f64, - max: f64, -} - -impl Stats { - fn add(&mut self, v: &Value) { - match v { - Value::Empty => self.blank = true, - Value::Number(n) => { - if !self.number { - self.min = *n; - self.max = *n; - } - self.number = true; - self.min = self.min.min(*n); - self.max = self.max.max(*n); - if n.fract() != 0.0 { - self.non_int = true; - } - } - Value::Text(t) => { - self.text = true; - if t.chars().count() > 255 { - self.long = true; - } - } - Value::Bool(_) => self.boolean = true, - Value::Error(_) => self.error = true, - Value::Array(_) => self.add(&v.scalar()), - } - } - /// Excel counts booleans and errors as strings in the cache field flags. - fn strings(&self) -> bool { - self.text || self.boolean || self.error - } - /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers. - fn kinds(&self) -> usize { - usize::from(self.strings()) + usize::from(self.number) - } -} - -/// Date grouping of a cache field. -struct Group { - by: PivotDateGroup, - /// The database field the grouping applies to. - base: usize, - /// The next (coarser) grouping field of the same base. - par: Option, - start: f64, - end: f64, - /// Group item captions, including the `end` items. - items: Vec, -} - -struct CField { - name: String, - /// Column offset in the source (database fields only). - col: Option, - stats: Stats, - /// Shared items (written when `itemized`). - items: Vec, - index: HashMap, - itemized: bool, - group: Option, - /// A calculated field's formula (without `=`); such fields aren't database fields. - formula: Option, -} - -impl CField { - /// Item captions the PivotTable's `` indices refer to. - fn item_labels(&self) -> Vec { - match &self.group { - Some(g) => g.items.clone(), - None => self.items.iter().map(label).collect(), - } - } -} - -/// A cache built from a PivotTable's source. -struct Cache { - fields: Vec, - /// Pivot field → cache field, by the pivot field's position in rows, then columns. - axis_map: Vec>, - source_xml: String, - records: Option<(u64, String)>, -} - -/// PivotTables being written; the tables themselves are finished after the styles part (their -/// value number formats need style number-format ids). -#[derive(Default)] -pub struct PivotWriter { - /// (cache id, workbook-relative part name). - caches: Vec<(u32, String)>, - pending: Vec, - tables: u32, -} - -struct Pending { - part: String, - sheet: usize, - idx: usize, - cache_id: u32, - cache: Cache, -} - -impl PivotWriter { - /// Writes the caches of a sheet's PivotTables and adds the sheet's relationships to its - /// PivotTable parts. Pivots whose source can't be resolved are not written. - pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { - let Some(sheet) = wb.sheet(si) else { return }; - for (idx, pt) in sheet.pivots.iter().enumerate() { - let Some(cache) = build_cache(wb, pt, si) else { continue }; - let n = self.caches.len() as u32 + 1; - let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); - let rec_rid = if let Some((count, xml)) = &cache.records { - let mut r = Rels::default(); - let rid = r.add("pivotCacheRecords", &format!("pivotCacheRecords{n}.xml")); - out.part(&format!("xl/pivotCache/_rels/pivotCacheDefinition{n}.xml.rels"), None, r.xml().into_bytes()); - let s = format!("{XML_DECL}{xml}"); - out.part(&format!("xl/pivotCache/pivotCacheRecords{n}.xml"), Some(CT_PIVOT_CACHE_REC), s.into_bytes()); - Some(rid) - } else { - None - }; - out.part(&cache_part, Some(CT_PIVOT_CACHE_DEF), cache_xml(&cache, rec_rid.as_deref(), wb.date_system).into_bytes()); - self.caches.push((n, format!("pivotCache/pivotCacheDefinition{n}.xml"))); - self.tables += 1; - let t = self.tables; - rels.add("pivotTable", &format!("../pivotTables/pivotTable{t}.xml")); - let mut tr = Rels::default(); - tr.add("pivotCacheDefinition", &format!("../pivotCache/pivotCacheDefinition{n}.xml")); - out.part(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels"), None, tr.xml().into_bytes()); - self.pending.push(Pending { part: format!("xl/pivotTables/pivotTable{t}.xml"), sheet: si, idx, cache_id: n, cache }); - } - } - - /// Adds the workbook relationships to the caches; returns the `` element (empty - /// when there are none). - pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { - if self.caches.is_empty() { - return String::new(); - } - let mut s = String::from(""); - for (id, target) in &self.caches { - let rid = wb_rels.add("pivotCacheDefinition", target); - let _ = write!(s, ""); - } - s.push_str(""); - s - } - - /// Number formats used by value fields (to be registered in the styles part). - pub fn number_formats(&self, wb: &Workbook) -> Vec { - let mut v = Vec::new(); - for p in &self.pending { - if let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) { - for val in &pt.values { - if let Some(f) = &val.number_format - && !v.contains(f) - { - v.push(f.clone()); - } - } - } - } - v - } - - /// Writes the PivotTable parts. `fmt_id` maps a number format code to its styles id. - pub fn finish(self, wb: &Workbook, out: &mut Out, fmt_id: &dyn Fn(&str) -> Option) { - for p in self.pending { - let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) else { continue }; - let xml = table_xml(pt, p.idx, p.cache_id, &p.cache, fmt_id); - out.part(&p.part, Some(CT_PIVOT_TABLE), xml.into_bytes()); - } - } -} - -/// Resolves a pivot source to (sheet, range including the header row, worksheetSource XML). -fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Option<(usize, RangeRef, String)> { - let t = text.trim().trim_start_matches('=').trim(); - if t.is_empty() || depth > 4 { - return None; - } - if let Some((si, ti)) = wb.table(t) - && let Some(sh) = wb.sheet(si) - && let Some(tb) = sh.tables.get(ti) - { - if !tb.header_row { - return None; - } - let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); - let r = RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)); - // Table names are sanitized when written; refer by range when this one would change. - let valid = !tb.name.is_empty() - && tb.name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') - && tb.name.starts_with(|c: char| c.is_alphabetic() || c == '_') - && CellRef::parse(&tb.name).is_none(); - let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; - return Some((si, r, xml)); - } - let (sheet, body) = gridcraft_core::split_sheet(t); - if let Some(r) = RangeRef::parse(body) { - let si = match &sheet { - Some(n) => wb.sheet_index(n)?, - None => default_sheet, - }; - let sh = wb.sheet(si)?; - let r = if r.is_full_cols() || r.is_full_rows() { r.intersection(&sh.used_range()?)? } else { r }; - return Some((si, r, range_source_xml(&sh.name, r))); - } - if sheet.is_none() - && let Some(n) = wb.name(t, default_sheet) - { - let (si, r, _) = resolve_source(wb, &n.formula.clone(), default_sheet, depth + 1)?; - let xml = if n.scope.is_none() { - format!("", esc_attr(&n.name)) - } else { - wb.sheet(si).map(|s| range_source_xml(&s.name, r))? - }; - return Some((si, r, xml)); - } - None -} - -fn range_source_xml(sheet: &str, r: RangeRef) -> String { - let name: String = sheet.chars().take(31).collect(); - format!("", r.a1(), esc_attr(&name)) -} - -fn header_text(v: &Value) -> String { - match v { - Value::Empty => String::new(), - v => label(v).trim().to_string(), - } -} - -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { - let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; - if r.count() > MAX_SOURCE_CELLS { - return None; - } - let sh = wb.sheet(si)?; - let mut fields: Vec = Vec::new(); - // Lower-case names taken, and the next suffix for each header (linear for repeated headers). - let mut taken = std::collections::HashSet::new(); - let mut next_suffix: HashMap = HashMap::new(); - for (k, c) in (r.start.col..=r.end.col).enumerate() { - let mut h = header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()); - if h.is_empty() { - h = format!("Column{}", k + 1); - } - let mut n = h.clone(); - let mut i = next_suffix.get(&h.to_lowercase()).copied().unwrap_or(2); - while taken.contains(&n.to_lowercase()) { - n = format!("{h}{i}"); - i += 1; - } - next_suffix.insert(h.to_lowercase(), i); - taken.insert(n.to_lowercase()); - fields.push(CField { - name: n, - col: Some(k as u32), - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: None, - formula: None, - }); - } - let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - - // Records (rows that aren't entirely blank), read once. - let mut rows: Vec = Vec::new(); - if r.end.row > r.start.row { - for row in r.start.row + 1..=r.end.row { - let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); - if !blank { - rows.push(row); - } - } - } - for &row in &rows { - for f in fields.iter_mut() { - let c = r.start.col + f.col.unwrap_or(0); - f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); - } - } - - // Which fields are on an axis, and how dates are grouped: per base column, the base field - // takes the finest grouping and coarser ones become extra (non-database) fields chained - // through `par`, as Excel lays them out. - let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); - let groupable = |f: &CField| { - let s = &f.stats; - s.number - && !s.text - && !s.boolean - && !s.error - && datetime_from_serial(wb.date_system, s.min).is_some() - && datetime_from_serial(wb.date_system, s.max).is_some() - }; - // (base column field, groupings fine → coarse) - let mut groupings: Vec<(usize, Vec)> = Vec::new(); - for pf in &axis { - let Some(b) = find(&fields, &pf.source_col) else { continue }; - if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { - continue; - } - match groupings.iter_mut().find(|(x, _)| *x == b) { - Some((_, v)) => { - if !v.contains(&pf.date_group) { - v.push(pf.date_group); - } - } - None => groupings.push((b, vec![pf.date_group])), - } - } - let fineness = |g: &PivotDateGroup| match g { - PivotDateGroup::Days => 0, - PivotDateGroup::Months => 1, - PivotDateGroup::Quarters => 2, - PivotDateGroup::Years => 3, - PivotDateGroup::None => 4, - }; - for (b, list) in groupings.iter_mut() { - list.sort_by_key(fineness); - let (min, max) = fields.get(*b).map(|f| (f.stats.min, f.stats.max)).unwrap_or((0.0, 0.0)); - let (start, end) = (min.floor(), max.floor() + 1.0); - let make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(wb.date_system, by, start, end) }; - let mut prev = *b; - for (k, by) in list.iter().enumerate() { - if k == 0 { - if let Some(f) = fields.get_mut(*b) { - f.group = Some(make(*by)); - } - continue; - } - let new = fields.len(); - let caption = group_caption(*by); - let mut n = caption.to_string(); - let mut i = 2; - while fields.iter().any(|f| same(&f.name, &n)) { - n = format!("{caption}{i}"); - i += 1; - } - if let Some(g) = fields.get_mut(prev).and_then(|f| f.group.as_mut()) { - g.par = Some(new); - } - fields.push(CField { - name: n, - col: None, - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: Some(make(*by)), - formula: None, - }); - prev = new; - } - } - let mut axis_map: Vec> = Vec::with_capacity(axis.len()); - for pf in &axis { - let Some(b) = find(&fields, &pf.source_col) else { - axis_map.push(None); - continue; - }; - let grouped = fields.iter().position(|f| f.group.as_ref().is_some_and(|g| g.base == b && g.by == pf.date_group)); - match grouped { - Some(i) if pf.date_group != PivotDateGroup::None => axis_map.push(Some(i)), - _ => { - if let Some(f) = fields.get_mut(b) - && f.group.is_none() - { - f.itemized = true; - } - axis_map.push(Some(b)); - } - } - } - // Page fields (filters not on an axis) need items too. - for f in &pt.filters { - let on_axis = axis.iter().any(|a| same(&a.source_col, &f.source_col)); - if !on_axis - && let Some(i) = find(&fields, &f.source_col) - && let Some(cf) = fields.get_mut(i) - { - cf.itemized = true; - } - } - for f in fields.iter_mut() { - if f.group.is_some() { - f.itemized = false; - } else if f.col.is_some() && (f.stats.text || f.stats.boolean || f.stats.error) { - f.itemized = true; - } - } - // Shared items, in order of first appearance. - for &row in &rows { - for f in fields.iter_mut().filter(|f| f.itemized) { - let c = r.start.col + f.col.unwrap_or(0); - let v = sh.value(CellRef::new(row, c)).scalar(); - let k = key(&v); - if !f.index.contains_key(&k) && f.items.len() < MAX_ITEMS { - f.index.insert(k, f.items.len() as u32); - f.items.push(v); - } - } - } - // Records snapshot. - let db_fields = fields.iter().filter(|f| f.col.is_some()).count() as u64; - let records = if (rows.len() as u64).saturating_mul(db_fields) <= MAX_RECORD_CELLS { - let mut s = String::new(); - for &row in &rows { - s.push_str(""); - for f in fields.iter().filter(|f| f.col.is_some()) { - let c = r.start.col + f.col.unwrap_or(0); - let v = sh.value(CellRef::new(row, c)).scalar(); - if f.itemized { - match f.index.get(&key(&v)) { - Some(i) => { - let _ = write!(s, ""); - } - None => s.push_str(&item_xml(&v, wb.date_system, false)), - } - } else { - s.push_str(&item_xml(&v, wb.date_system, f.group.is_some())); - } - } - s.push_str(""); - } - Some((rows.len() as u64, s)) - } else { - None - }; - // Calculated fields come after the database and grouping fields. - for cf in &pt.calculated_fields { - let f = cf.formula.trim(); - let f = f.strip_prefix('=').unwrap_or(f).trim(); - if cf.name.trim().is_empty() || f.is_empty() || fields.iter().any(|x| same(&x.name, &cf.name)) { - continue; - } - fields.push(CField { - name: cf.name.clone(), - col: None, - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: None, - formula: Some(f.to_string()), - }); - } - Some(Cache { fields, axis_map, source_xml, records }) -} - -fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec { - let mut v = vec![format!("<{}", mdy(sys, start))]; - match by { - PivotDateGroup::Years => { - let y0 = datetime_from_serial(sys, start).map(|d| d.year).unwrap_or(1900); - let y1 = datetime_from_serial(sys, end - 1.0).map(|d| d.year).unwrap_or(y0).max(y0); - for y in y0..=y1.min(y0 + 10_000) { - v.push(y.to_string()); - } - } - PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), - PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::to_string)), - PivotDateGroup::Days | PivotDateGroup::None => { - const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - for (m, n) in MONTHS.iter().zip(DAYS) { - for d in 1..=n { - v.push(format!("{d}-{m}")); - } - } - } - } - v.push(format!(">{}", mdy(sys, end))); - v -} - -/// A cache value element (``, ``, ``…); `date` writes numbers as ``. -fn item_xml(v: &Value, sys: DateSystem, date: bool) -> String { - match v { - Value::Empty => "".into(), - Value::Number(n) => { - if date && let Some(d) = iso(sys, *n) { - return format!(""); - } - format!("", crate::xml::num(*n)) - } - Value::Text(t) => format!("", esc_attr(t)), - Value::Bool(b) => format!("", u8::from(*b)), - Value::Error(e) => format!("", esc_attr(e.as_str())), - Value::Array(_) => item_xml(&v.scalar(), sys, date), - } -} - -fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { - let mut s = format!("{XML_DECL} { - let _ = write!(s, " r:id=\"{rid}\" recordCount=\"{count}\""); - } - _ => s.push_str(" saveData=\"0\""), - } - s.push_str(" refreshOnLoad=\"1\" refreshedBy=\"GridCraft\" createdVersion=\"6\" refreshedVersion=\"6\" minRefreshableVersion=\"3\">"); - let _ = write!(s, "{}", c.source_xml); - let _ = write!(s, "", c.fields.len()); - for f in &c.fields { - let fmt = if f.group.is_some() { 14 } else { 0 }; - let _ = write!(s, "'); - let st = &f.stats; - if f.col.is_some() { - s.push_str(" 1 { - s.push_str(" containsMixedTypes=\"1\""); - } - if st.number { - s.push_str(" containsNumber=\"1\""); - if !st.non_int { - s.push_str(" containsInteger=\"1\""); - } - let _ = write!(s, " minValue=\"{}\" maxValue=\"{}\"", crate::xml::num(st.min), crate::xml::num(st.max)); - } - if st.long { - s.push_str(" longText=\"1\""); - } - } - if f.itemized { - let _ = write!(s, " count=\"{}\">", f.items.len()); - for v in &f.items { - s.push_str(&item_xml(v, sys, false)); - } - s.push_str(""); - } else { - s.push_str("/>"); - } - } - if let Some(g) = &f.group { - s.push_str("", g.base); - let _ = write!(s, "", g.items.len()); - for it in &g.items { - let _ = write!(s, "", esc_attr(it)); - } - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { - let nf = c.fields.len(); - let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); - let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); - // Filters: on an axis field → hidden items there; otherwise a page field. - let mut hidden: HashMap = HashMap::new(); - let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); - for f in &pt.filters { - let axis_pos = pt.rows.iter().chain(pt.columns.iter()).position(|a| same(&a.source_col, &f.source_col)); - match axis_pos { - Some(p) => { - if let Some(Some(i)) = c.axis_map.get(p) { - hidden.insert(*i, f); - } - } - None => { - if let Some(i) = find_db(&f.source_col) - && !pages.iter().any(|(j, _)| *j == i) - { - pages.push((i, f)); - hidden.insert(i, f); - } - } - } - } - let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); - let vals: Vec<(usize, &PivotValue)> = - pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); - let axis_field = |i: usize| -> Option<&PivotField> { - let p = c.axis_map.iter().position(|x| *x == Some(i))?; - pt.rows.iter().chain(pt.columns.iter()).nth(p) - }; - - let compact = pt.layout == PivotLayout::Compact; - let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; - let mut s = format!( - "{XML_DECL} s.push_str(" outline=\"1\" outlineData=\"1\""), - PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), - PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), - } - if pt.values_on_rows { - s.push_str(" dataOnRows=\"1\""); - } - if !pt.show_headers { - s.push_str(" showHeaders=\"0\""); - } - if !pt.enable_show_details { - s.push_str(" enableDrill=\"0\""); - } - // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"), - // `colGrandTotals` the grand total column at its right ("On for Rows"). - if !pt.grand_totals_cols { - s.push_str(" rowGrandTotals=\"0\""); - } - if !pt.grand_totals_rows { - s.push_str(" colGrandTotals=\"0\""); - } - s.push_str(" multipleFieldFilters=\"0\">"); - - // Location: the body (report filters sit above it). - let a = pt.anchor; - let end = pt.last_range.map(|r| r.end).unwrap_or(a); - let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); - let k = vals.len(); - // Σ Values (two or more value fields): its index among the written fields of its axis. - let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); - let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); - let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); - // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. - let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); - let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; - let row_levels = row_idx.len() + usize::from(row_values.is_some()); - let first_data_col = if row_levels == 0 && col_idx.is_empty() { - 0 - } else if compact { - 1 - } else { - row_levels.max(1) - }; - // Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row. - let hide_values_row = col_values.is_some() && col_idx.is_empty(); - let first_header_row = usize::from(!hide_values_row); - let _ = write!( - s, - ""); - - // Fields. - let _ = write!(s, ""); - for (i, f) in c.fields.iter().enumerate() { - s.push_str(" {} - PivotLayout::Outline => s.push_str(" compact=\"0\""), - PivotLayout::Tabular => s.push_str(" compact=\"0\" outline=\"0\""), - } - if f.group.is_some() { - s.push_str(" numFmtId=\"14\""); - } - let filter = hidden.get(&i).copied(); - let labels = f.item_labels(); - let is_page = axis == Some("axisPage"); - // Hidden items; never hide every item (a field needs one visible item). - let mut hide: Vec = match filter.and_then(|f| f.selected.as_ref()) { - Some(sel) if !(is_page && sel.len() == 1) => labels.iter().map(|l| !sel.iter().any(|x| same(x, l))).collect(), - _ => vec![false; labels.len()], - }; - if !hide.is_empty() && hide.iter().all(|h| *h) { - hide.iter_mut().for_each(|h| *h = false); - } - if is_page && hide.iter().any(|h| *h) { - s.push_str(" multipleItemSelectionAllowed=\"1\""); - } - s.push_str(" showAll=\"0\""); - if let Some(af) = axis_field(i) { - match af.sort { - PivotSort::Asc => s.push_str(" sortType=\"ascending\""), - PivotSort::Desc => s.push_str(" sortType=\"descending\""), - PivotSort::None => {} - } - } - if !pt.subtotals { - s.push_str(" defaultSubtotal=\"0\""); - } - if !pt.subtotals_top { - s.push_str(" subtotalTop=\"0\""); - } - if axis.is_some() { - let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); - let count = labels.len() + usize::from(pt.subtotals); - let _ = write!(s, ">"); - for (x, l) in labels.iter().enumerate() { - let _ = write!(s, ""); - } - if pt.subtotals { - s.push_str(""); - } - s.push_str(""); - } else { - s.push_str("/>"); - } - } - s.push_str(""); - - axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); - axis_fields_xml(&mut s, "colFields", &col_idx, col_values); - if !pages.is_empty() { - let _ = write!(s, "", pages.len()); - for (i, f) in &pages { - let _ = write!(s, ""); - } - s.push_str(""); - } - if !vals.is_empty() { - let _ = write!(s, ""); - let mut used: Vec = Vec::new(); - for (i, v) in &vals { - let base = if v.name.trim().is_empty() { - format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) - } else { - v.name.clone() - }; - let mut n = base.clone(); - let mut j = 2; - while used.iter().any(|u| same(u, &n)) { - n = format!("{base}{j}"); - j += 1; - } - used.push(n.clone()); - let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::RunningTotal => { - let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); - } - PivotShowAs::Rank => { - let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); - } - } - if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { - let _ = write!(s, " numFmtId=\"{id}\""); - } - if v.show_as == PivotShowAs::Rank { - let _ = write!( - s, - ">" - ); - } else { - s.push_str("/>"); - } - } - s.push_str(""); - } - s.push_str(""); - if hide_values_row { - let _ = write!( - s, - "" - ); - } - s.push_str(""); - s -} - -/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. -fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { - let n = idx.len() + usize::from(values_at.is_some()); - if n == 0 { - return; - } - let _ = write!(s, "<{tag} count=\"{n}\">"); - for (j, i) in idx.iter().enumerate() { - if values_at == Some(j) { - s.push_str(""); - } - let _ = write!(s, ""); - } - if values_at.is_some_and(|p| p >= idx.len()) { - s.push_str(""); - } - let _ = write!(s, ""); -} - -// ================================================================ reading - -/// A cache field as read. -#[derive(Clone, Debug, Default)] -struct FieldIn { - name: String, - database: bool, - /// Shared item captions. - items: Vec, - /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. - group: Option<(Option, Option, Vec)>, - /// A calculated field's formula. - formula: Option, -} - -impl FieldIn { - fn labels(&self) -> &[String] { - match &self.group { - Some((_, _, items)) if !items.is_empty() => items, - _ => &self.items, - } - } -} - -#[derive(Clone, Debug, Default)] -struct CacheIn { - source: String, - fields: Vec, -} - -/// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. -#[derive(Default)] -pub struct CachesIn { - by_id: HashMap, - parsed: HashMap>, - next_id: u32, -} - -impl CachesIn { - pub fn new(wb_xml: &El, wb_rels: &[crate::package::Rel]) -> CachesIn { - let mut by_id = HashMap::new(); - if let Some(pc) = wb_xml.child("pivotCaches") { - for c in pc.kids("pivotCache") { - if let (Some(id), Some(rid)) = (c.attr_u32("cacheId"), c.attr("id")) - && let Some(r) = wb_rels.iter().find(|r| r.id == rid) - { - by_id.insert(id, r.target.clone()); - } - } - } - CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } - } -} - -fn item_label_el(e: &El, sys: DateSystem) -> String { - let v = e.attr("v").unwrap_or(""); - match e.name.as_str() { - "m" => "(blank)".into(), - "n" => v.trim().parse::().ok().filter(|n| n.is_finite()).map(number_to_text).unwrap_or_else(|| v.to_string()), - "b" => if matches!(v.trim(), "1" | "true") { "TRUE" } else { "FALSE" }.into(), - "d" => crate::sheet_read::parse_iso_datetime(v, sys).map(number_to_text).unwrap_or_else(|| v.to_string()), - _ => v.to_string(), - } -} - -fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option { - let x = cx.optional_xml(part).ok().flatten()?; - if x.name != "pivotCacheDefinition" { - cx.warn(format!("{part} is not a pivot cache definition; its PivotTable was kept as plain cells")); - return None; - } - let src = x.child("cacheSource"); - let ty = src.and_then(|s| s.attr("type")).unwrap_or("worksheet"); - if ty != "worksheet" { - cx.warn(format!("PivotTables with a {ty} data source are not supported; kept as plain cells")); - return None; - } - let ws = src.and_then(|s| s.child("worksheetSource")); - let source = match ws { - Some(w) if w.attr("name").is_some_and(|n| !n.trim().is_empty()) => w.attr("name").unwrap_or("").trim().to_string(), - Some(w) => { - if w.attr("id").is_some() { - cx.warn("a PivotTable's source is in another workbook; its source reference may not resolve"); - } - let r = w.attr("ref").map(|r| r.replace('$', "")).and_then(|r| RangeRef::parse(&r)); - let Some(r) = r else { - cx.warn("a PivotTable's source reference is missing or invalid; kept as plain cells"); - return None; - }; - let sheet = w.attr("sheet").filter(|s| !s.is_empty()).unwrap_or(sheet_name); - format!("{}!{}", quote_sheet(sheet), abs_range(&r)) - } - None => { - cx.warn("a PivotTable's cache has no worksheet source; kept as plain cells"); - return None; - } - }; - let sys = cx.date_system; - let mut fields = Vec::new(); - if let Some(cf) = x.child("cacheFields") { - for f in cf.kids("cacheField").take(MAX_FIELDS) { - let items = f.child("sharedItems").map(|si| si.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); - let group = f.child("fieldGroup").map(|g| { - let base = g.attr_u32("base").map(|b| b as usize); - let by = match g.child("rangePr").map(|r| r.attr("groupBy").unwrap_or("range")) { - Some("years") => Some(PivotDateGroup::Years), - Some("quarters") => Some(PivotDateGroup::Quarters), - Some("months") => Some(PivotDateGroup::Months), - Some("days") => Some(PivotDateGroup::Days), - _ => None, - }; - let gi = g.child("groupItems").map(|gi| gi.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); - (base, by, gi) - }); - fields.push(FieldIn { - name: f.attr("name").unwrap_or("").to_string(), - database: f.flag("databaseField", true), - items, - group, - formula: f.attr("formula").map(str::to_string), - }); - } - } - if fields.is_empty() { - cx.warn("a PivotTable's cache has no fields; kept as plain cells"); - return None; - } - Some(CacheIn { source, fields }) -} - -/// Reads the PivotTables related to a worksheet part. -pub fn read_pivots(cx: &mut Ctx<'_>, caches: &mut CachesIn, sheet_part: &str, sheet_name: &str) -> Result, IoError> { - let rels = cx.pkg.rels(sheet_part)?; - let mut out = Vec::new(); - for rel in rels.iter().filter(|r| r.kind == "pivotTable" && !r.external) { - let Some(x) = cx.optional_xml(&rel.target)? else { continue }; - if x.name != "pivotTableDefinition" { - cx.warn(format!("{} is not a PivotTable definition; skipped", rel.target)); - continue; - } - // The cache: the table's own relationship, else the workbook's cache id. - let trels = cx.pkg.rels(&rel.target)?; - let cache_part = trels - .iter() - .find(|r| r.kind == "pivotCacheDefinition" && !r.external) - .map(|r| r.target.clone()) - .or_else(|| x.attr_u32("cacheId").and_then(|id| caches.by_id.get(&id).cloned())); - let Some(cache_part) = cache_part else { - cx.warn(format!("PivotTable in {} has no cache; kept as plain cells", rel.target)); - continue; - }; - let cache = match caches.parsed.get(&cache_part) { - Some(c) => c.clone(), - None => { - let c = read_cache(cx, &cache_part, sheet_name); - caches.parsed.insert(cache_part.clone(), c.clone()); - c - } - }; - let Some(cache) = cache else { continue }; - if let Some(pt) = read_table(cx, &x, &cache, caches.next_id) { - caches.next_id = caches.next_id.saturating_add(1); - out.push(pt); - } - } - Ok(out) -} - -/// A pivot field's data items (not subtotal or grand-total items). -fn items_of(e: &El) -> Vec<&El> { - e.child("items").map(|it| it.kids("item").filter(|i| i.attr("t").is_none_or(|t| t == "data")).collect()).unwrap_or_default() -} - -/// (source column name, date grouping) of a cache field used on an axis. -fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { - let f = cache.fields.get(i)?; - if f.formula.is_some() { - cx.warn("calculated PivotTable fields are not supported"); - return None; - } - match &f.group { - Some((base, by, _)) => { - let by = match by { - Some(b) => *b, - None => { - cx.warn(format!("PivotTable grouping of \"{}\" is not supported; the field is ungrouped", f.name)); - PivotDateGroup::None - } - }; - let base_name = if f.database { f.name.clone() } else { base.and_then(|b| cache.fields.get(b))?.name.clone() }; - Some((base_name, by)) - } - None if !f.database => { - cx.warn(format!("PivotTable field \"{}\" (grouped items) is not supported", f.name)); - None - } - None => Some((f.name.clone(), PivotDateGroup::None)), - } -} - -fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option { - let mut pt = PivotTable { id, ..PivotTable::default() }; - if let Some(n) = x.attr("name").filter(|n| !n.trim().is_empty()) { - pt.name = n.to_string(); - } else { - pt.name = format!("PivotTable{id}"); - } - pt.source = cache.source.clone(); - let Some(loc) = x.child("location").and_then(|l| l.attr("ref")).and_then(|r| RangeRef::parse(&r.replace('$', ""))) else { - cx.warn(format!("PivotTable \"{}\" has no valid location; kept as plain cells", pt.name)); - return None; - }; - let pfs: Vec<&El> = x.child("pivotFields").map(|p| p.kids("pivotField").collect()).unwrap_or_default(); - let pf = |i: usize| pfs.get(i).copied(); - let idx_list = |name: &str, attr: &str| -> Vec { - x.child(name).map(|e| e.children.iter().filter_map(|f| f.attr_i64(attr)).collect()).unwrap_or_default() - }; - let row_x = idx_list("rowFields", "x"); - let col_x = idx_list("colFields", "x"); - let pages: Vec<&El> = x.child("pageFields").map(|p| p.kids("pageField").collect()).unwrap_or_default(); - - // Layout from the first axis field (pivotField defaults: compact and outline on). - let first_axis = row_x.iter().chain(col_x.iter()).find(|i| **i >= 0).and_then(|i| pf(*i as usize)); - let (compact, outline) = match first_axis { - Some(f) => (f.flag("compact", true), f.flag("outline", true)), - None => (x.flag("compact", true), x.flag("outline", false)), - }; - pt.layout = if compact { - PivotLayout::Compact - } else if outline { - PivotLayout::Outline - } else { - PivotLayout::Tabular - }; - if let Some(f) = first_axis { - pt.subtotals = f.flag("defaultSubtotal", true); - pt.subtotals_top = f.flag("subtotalTop", true); - } - pt.grand_totals_rows = x.flag("colGrandTotals", true); - pt.grand_totals_cols = x.flag("rowGrandTotals", true); - pt.show_headers = x.flag("showHeaders", true); - pt.enable_show_details = x.flag("enableDrill", true); - pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string(); - if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() { - cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name)); - } - - let mut filters: Vec = Vec::new(); - - // (fields, position of Σ Values (`-2`) among them) - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { - let mut v = Vec::new(); - let mut values_at = None; - for &i in list { - if i == -2 { - values_at = Some(v.len()); - continue; - } - if i < 0 { - continue; - } - let i = i as usize; - let Some((name, group)) = field_ref(cx, cache, i) else { continue }; - let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); - let mut f = PivotField { source_col: name.clone(), date_group: group, sort: PivotSort::None, collapsed_items: vec![] }; - if let Some(e) = pf(i) { - f.sort = match e.attr("sortType") { - Some("ascending") => PivotSort::Asc, - Some("descending") => PivotSort::Desc, - _ => PivotSort::None, - }; - let items = items_of(e); - let lab = |it: &El| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned(); - f.collapsed_items = items.iter().filter(|it| !it.flag("sd", true)).filter_map(|it| lab(it)).collect(); - if items.iter().any(|it| it.flag("h", false)) { - let shown: Vec = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); - if !filters.iter().any(|ff| same(&ff.source_col, &name)) { - filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); - } - } - } - v.push(f); - } - (v, values_at) - }; - let mut page_filters = Vec::new(); - for p in &pages { - let Some(i) = p.attr_i64("fld").filter(|i| *i >= 0).map(|i| i as usize) else { continue }; - let Some((name, _)) = field_ref(cx, cache, i) else { continue }; - let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); - let selected = if let Some(k) = p.attr_u32("item") { - labels.get(k as usize).map(|l| vec![l.clone()]) - } else { - pf(i).and_then(|e| { - let items = items_of(e); - items.iter().any(|it| it.flag("h", false)).then(|| { - items - .iter() - .filter(|it| !it.flag("h", false)) - .filter_map(|it| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned()) - .collect() - }) - }) - }; - page_filters.push(PivotFilter { source_col: name, selected }); - } - let mut axis_filters = Vec::new(); - let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters); - let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters); - pt.rows = rows; - pt.columns = columns; - // Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last - // field by default. In Columns it always comes last, as the column layout puts it there. - pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false)); - pt.values_position = - if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None }; - filters.extend(page_filters); - filters.extend(axis_filters); - pt.filters = filters; - - // Values. - if let Some(df) = x.child("dataFields") { - for d in df.kids("dataField") { - let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue }; - let Some(f) = cache.fields.get(i) else { continue }; - let func = match d.attr("subtotal") { - None => PivotFunc::Sum, - Some(s) => func_from(s).unwrap_or_else(|| { - cx.warn(format!("PivotTable function {s} is not supported; using Sum")); - PivotFunc::Sum - }), - }; - let ext = d - .child("extLst") - .into_iter() - .flat_map(|l| l.kids("ext")) - .filter_map(|e| e.child("dataField")) - .find_map(|e| e.attr("pivotShowAs").map(str::to_string)); - let show_as = match (ext.as_deref(), d.attr("showDataAs")) { - (Some("rankDescending"), _) => PivotShowAs::Rank, - (Some(other), _) => { - cx.warn(format!("PivotTable show-values-as {other} is not supported")); - PivotShowAs::Normal - } - (None, None | Some("normal")) => PivotShowAs::Normal, - (None, Some("percentOfTotal")) => PivotShowAs::PercentOfGrandTotal, - (None, Some("percentOfCol")) => PivotShowAs::PercentOfColumnTotal, - (None, Some("percentOfRow")) => PivotShowAs::PercentOfRowTotal, - (None, Some("runTotal")) => PivotShowAs::RunningTotal, - (None, Some(other)) => { - cx.warn(format!("PivotTable show-values-as {other} is not supported")); - PivotShowAs::Normal - } - }; - let number_format = d - .attr_u32("numFmtId") - .filter(|id| *id != 0) - .and_then(|id| crate::tables::builtin_format(id).map(str::to_string).or_else(|| cx.num_fmts.get(&id).cloned())); - let name = d.attr("name").map(str::to_string).unwrap_or_else(|| format!("{} of {}", func_caption(func), f.name)); - pt.values.push(PivotValue { source_col: f.name.clone(), func, name, show_as, number_format }); - } - } - - // Calculated fields of the cache (each PivotTable on it can use them). - for f in &cache.fields { - if let Some(formula) = &f.formula - && !f.name.trim().is_empty() - && !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name)) - { - let formula = formula.trim(); - let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; - pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula }); - } - } - - pt.anchor = loc.start; - let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 }; - pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end)); - Some(pt) -} diff --git a/crates/xlsx/src/pivot/read.rs b/crates/xlsx/src/pivot/read.rs new file mode 100644 index 000000000..134a8ab22 --- /dev/null +++ b/crates/xlsx/src/pivot/read.rs @@ -0,0 +1,385 @@ +use super::{ + CellRef, Ctx, DateSystem, El, HashMap, IoError, MAX_FIELDS, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, + PivotShowAs, PivotSort, PivotTable, PivotValue, RangeRef, abs_range, func_caption, func_from, number_to_text, quote_sheet, same, +}; + +// ================================================================ reading + +/// A cache field as read. +#[derive(Clone, Debug, Default)] +struct FieldIn { + name: String, + database: bool, + /// Shared item captions. + items: Vec, + /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. + group: Option<(Option, Option, Vec)>, + /// A calculated field's formula. + formula: Option, +} + +impl FieldIn { + fn labels(&self) -> &[String] { + match &self.group { + Some((_, _, items)) if !items.is_empty() => items, + _ => &self.items, + } + } +} + +#[derive(Clone, Debug, Default)] +struct CacheIn { + source: String, + fields: Vec, +} + +/// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. +#[derive(Default)] +pub struct CachesIn { + by_id: HashMap, + parsed: HashMap>, + next_id: u32, +} + +impl CachesIn { + pub fn new(wb_xml: &El, wb_rels: &[crate::package::Rel]) -> CachesIn { + let mut by_id = HashMap::new(); + if let Some(pc) = wb_xml.child("pivotCaches") { + for c in pc.kids("pivotCache") { + if let (Some(id), Some(rid)) = (c.attr_u32("cacheId"), c.attr("id")) + && let Some(r) = wb_rels.iter().find(|r| r.id == rid) + { + by_id.insert(id, r.target.clone()); + } + } + } + CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } + } +} + +fn item_label_el(e: &El, sys: DateSystem) -> String { + let v = e.attr("v").unwrap_or(""); + match e.name.as_str() { + "m" => "(blank)".into(), + "n" => v.trim().parse::().ok().filter(|n| n.is_finite()).map(number_to_text).unwrap_or_else(|| v.to_string()), + "b" => if matches!(v.trim(), "1" | "true") { "TRUE" } else { "FALSE" }.into(), + "d" => crate::sheet_read::parse_iso_datetime(v, sys).map(number_to_text).unwrap_or_else(|| v.to_string()), + _ => v.to_string(), + } +} + +fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option { + let x = cx.optional_xml(part).ok().flatten()?; + if x.name != "pivotCacheDefinition" { + cx.warn(format!("{part} is not a pivot cache definition; its PivotTable was kept as plain cells")); + return None; + } + let src = x.child("cacheSource"); + let ty = src.and_then(|s| s.attr("type")).unwrap_or("worksheet"); + if ty != "worksheet" { + cx.warn(format!("PivotTables with a {ty} data source are not supported; kept as plain cells")); + return None; + } + let ws = src.and_then(|s| s.child("worksheetSource")); + let source = match ws { + Some(w) if w.attr("name").is_some_and(|n| !n.trim().is_empty()) => w.attr("name").unwrap_or("").trim().to_string(), + Some(w) => { + if w.attr("id").is_some() { + cx.warn("a PivotTable's source is in another workbook; its source reference may not resolve"); + } + let r = w.attr("ref").map(|r| r.replace('$', "")).and_then(|r| RangeRef::parse(&r)); + let Some(r) = r else { + cx.warn("a PivotTable's source reference is missing or invalid; kept as plain cells"); + return None; + }; + let sheet = w.attr("sheet").filter(|s| !s.is_empty()).unwrap_or(sheet_name); + format!("{}!{}", quote_sheet(sheet), abs_range(&r)) + } + None => { + cx.warn("a PivotTable's cache has no worksheet source; kept as plain cells"); + return None; + } + }; + let sys = cx.date_system; + let mut fields = Vec::new(); + if let Some(cf) = x.child("cacheFields") { + for f in cf.kids("cacheField").take(MAX_FIELDS) { + let items = f.child("sharedItems").map(|si| si.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + let group = f.child("fieldGroup").map(|g| { + let base = g.attr_u32("base").map(|b| b as usize); + let by = match g.child("rangePr").map(|r| r.attr("groupBy").unwrap_or("range")) { + Some("years") => Some(PivotDateGroup::Years), + Some("quarters") => Some(PivotDateGroup::Quarters), + Some("months") => Some(PivotDateGroup::Months), + Some("days") => Some(PivotDateGroup::Days), + _ => None, + }; + let gi = g.child("groupItems").map(|gi| gi.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + (base, by, gi) + }); + fields.push(FieldIn { + name: f.attr("name").unwrap_or("").to_string(), + database: f.flag("databaseField", true), + items, + group, + formula: f.attr("formula").map(str::to_string), + }); + } + } + if fields.is_empty() { + cx.warn("a PivotTable's cache has no fields; kept as plain cells"); + return None; + } + Some(CacheIn { source, fields }) +} + +/// Reads the PivotTables related to a worksheet part. +pub fn read_pivots(cx: &mut Ctx<'_>, caches: &mut CachesIn, sheet_part: &str, sheet_name: &str) -> Result, IoError> { + let rels = cx.pkg.rels(sheet_part)?; + let mut out = Vec::new(); + for rel in rels.iter().filter(|r| r.kind == "pivotTable" && !r.external) { + let Some(x) = cx.optional_xml(&rel.target)? else { continue }; + if x.name != "pivotTableDefinition" { + cx.warn(format!("{} is not a PivotTable definition; skipped", rel.target)); + continue; + } + // The cache: the table's own relationship, else the workbook's cache id. + let trels = cx.pkg.rels(&rel.target)?; + let cache_part = trels + .iter() + .find(|r| r.kind == "pivotCacheDefinition" && !r.external) + .map(|r| r.target.clone()) + .or_else(|| x.attr_u32("cacheId").and_then(|id| caches.by_id.get(&id).cloned())); + let Some(cache_part) = cache_part else { + cx.warn(format!("PivotTable in {} has no cache; kept as plain cells", rel.target)); + continue; + }; + let cache = match caches.parsed.get(&cache_part) { + Some(c) => c.clone(), + None => { + let c = read_cache(cx, &cache_part, sheet_name); + caches.parsed.insert(cache_part.clone(), c.clone()); + c + } + }; + let Some(cache) = cache else { continue }; + if let Some(pt) = read_table(cx, &x, &cache, caches.next_id) { + caches.next_id = caches.next_id.saturating_add(1); + out.push(pt); + } + } + Ok(out) +} + +/// A pivot field's data items (not subtotal or grand-total items). +fn items_of(e: &El) -> Vec<&El> { + e.child("items").map(|it| it.kids("item").filter(|i| i.attr("t").is_none_or(|t| t == "data")).collect()).unwrap_or_default() +} + +/// (source column name, date grouping) of a cache field used on an axis. +fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { + let f = cache.fields.get(i)?; + if f.formula.is_some() { + cx.warn("calculated PivotTable fields are not supported"); + return None; + } + match &f.group { + Some((base, by, _)) => { + let by = match by { + Some(b) => *b, + None => { + cx.warn(format!("PivotTable grouping of \"{}\" is not supported; the field is ungrouped", f.name)); + PivotDateGroup::None + } + }; + let base_name = if f.database { f.name.clone() } else { base.and_then(|b| cache.fields.get(b))?.name.clone() }; + Some((base_name, by)) + } + None if !f.database => { + cx.warn(format!("PivotTable field \"{}\" (grouped items) is not supported", f.name)); + None + } + None => Some((f.name.clone(), PivotDateGroup::None)), + } +} + +fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option { + let mut pt = PivotTable { id, ..PivotTable::default() }; + if let Some(n) = x.attr("name").filter(|n| !n.trim().is_empty()) { + pt.name = n.to_string(); + } else { + pt.name = format!("PivotTable{id}"); + } + pt.source = cache.source.clone(); + let Some(loc) = x.child("location").and_then(|l| l.attr("ref")).and_then(|r| RangeRef::parse(&r.replace('$', ""))) else { + cx.warn(format!("PivotTable \"{}\" has no valid location; kept as plain cells", pt.name)); + return None; + }; + let pfs: Vec<&El> = x.child("pivotFields").map(|p| p.kids("pivotField").collect()).unwrap_or_default(); + let pf = |i: usize| pfs.get(i).copied(); + let idx_list = |name: &str, attr: &str| -> Vec { + x.child(name).map(|e| e.children.iter().filter_map(|f| f.attr_i64(attr)).collect()).unwrap_or_default() + }; + let row_x = idx_list("rowFields", "x"); + let col_x = idx_list("colFields", "x"); + let pages: Vec<&El> = x.child("pageFields").map(|p| p.kids("pageField").collect()).unwrap_or_default(); + + // Layout from the first axis field (pivotField defaults: compact and outline on). + let first_axis = row_x.iter().chain(col_x.iter()).find(|i| **i >= 0).and_then(|i| pf(*i as usize)); + let (compact, outline) = match first_axis { + Some(f) => (f.flag("compact", true), f.flag("outline", true)), + None => (x.flag("compact", true), x.flag("outline", false)), + }; + pt.layout = if compact { + PivotLayout::Compact + } else if outline { + PivotLayout::Outline + } else { + PivotLayout::Tabular + }; + if let Some(f) = first_axis { + pt.subtotals = f.flag("defaultSubtotal", true); + pt.subtotals_top = f.flag("subtotalTop", true); + } + pt.grand_totals_rows = x.flag("colGrandTotals", true); + pt.grand_totals_cols = x.flag("rowGrandTotals", true); + pt.show_headers = x.flag("showHeaders", true); + pt.enable_show_details = x.flag("enableDrill", true); + pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string(); + if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() { + cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name)); + } + + let mut filters: Vec = Vec::new(); + + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { + let mut v = Vec::new(); + let mut values_at = None; + for &i in list { + if i == -2 { + values_at = Some(v.len()); + continue; + } + if i < 0 { + continue; + } + let i = i as usize; + let Some((name, group)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let mut f = PivotField { source_col: name.clone(), date_group: group, sort: PivotSort::None, collapsed_items: vec![] }; + if let Some(e) = pf(i) { + f.sort = match e.attr("sortType") { + Some("ascending") => PivotSort::Asc, + Some("descending") => PivotSort::Desc, + _ => PivotSort::None, + }; + let items = items_of(e); + let lab = |it: &El| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned(); + f.collapsed_items = items.iter().filter(|it| !it.flag("sd", true)).filter_map(|it| lab(it)).collect(); + if items.iter().any(|it| it.flag("h", false)) { + let shown: Vec = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); + if !filters.iter().any(|ff| same(&ff.source_col, &name)) { + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + } + } + } + v.push(f); + } + (v, values_at) + }; + let mut page_filters = Vec::new(); + for p in &pages { + let Some(i) = p.attr_i64("fld").filter(|i| *i >= 0).map(|i| i as usize) else { continue }; + let Some((name, _)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let selected = if let Some(k) = p.attr_u32("item") { + labels.get(k as usize).map(|l| vec![l.clone()]) + } else { + pf(i).and_then(|e| { + let items = items_of(e); + items.iter().any(|it| it.flag("h", false)).then(|| { + items + .iter() + .filter(|it| !it.flag("h", false)) + .filter_map(|it| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned()) + .collect() + }) + }) + }; + page_filters.push(PivotFilter { source_col: name, selected }); + } + let mut axis_filters = Vec::new(); + let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters); + let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters); + pt.rows = rows; + pt.columns = columns; + // Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last + // field by default. In Columns it always comes last, as the column layout puts it there. + pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false)); + pt.values_position = + if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None }; + filters.extend(page_filters); + filters.extend(axis_filters); + pt.filters = filters; + + // Values. + if let Some(df) = x.child("dataFields") { + for d in df.kids("dataField") { + let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue }; + let Some(f) = cache.fields.get(i) else { continue }; + let func = match d.attr("subtotal") { + None => PivotFunc::Sum, + Some(s) => func_from(s).unwrap_or_else(|| { + cx.warn(format!("PivotTable function {s} is not supported; using Sum")); + PivotFunc::Sum + }), + }; + let ext = d + .child("extLst") + .into_iter() + .flat_map(|l| l.kids("ext")) + .filter_map(|e| e.child("dataField")) + .find_map(|e| e.attr("pivotShowAs").map(str::to_string)); + let show_as = match (ext.as_deref(), d.attr("showDataAs")) { + (Some("rankDescending"), _) => PivotShowAs::Rank, + (Some(other), _) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + (None, None | Some("normal")) => PivotShowAs::Normal, + (None, Some("percentOfTotal")) => PivotShowAs::PercentOfGrandTotal, + (None, Some("percentOfCol")) => PivotShowAs::PercentOfColumnTotal, + (None, Some("percentOfRow")) => PivotShowAs::PercentOfRowTotal, + (None, Some("runTotal")) => PivotShowAs::RunningTotal, + (None, Some(other)) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + }; + let number_format = d + .attr_u32("numFmtId") + .filter(|id| *id != 0) + .and_then(|id| crate::tables::builtin_format(id).map(str::to_string).or_else(|| cx.num_fmts.get(&id).cloned())); + let name = d.attr("name").map(str::to_string).unwrap_or_else(|| format!("{} of {}", func_caption(func), f.name)); + pt.values.push(PivotValue { source_col: f.name.clone(), func, name, show_as, number_format }); + } + } + + // Calculated fields of the cache (each PivotTable on it can use them). + for f in &cache.fields { + if let Some(formula) = &f.formula + && !f.name.trim().is_empty() + && !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name)) + { + let formula = formula.trim(); + let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; + pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula }); + } + } + + pt.anchor = loc.start; + let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 }; + pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end)); + Some(pt) +} diff --git a/crates/xlsx/src/pivot/write.rs b/crates/xlsx/src/pivot/write.rs new file mode 100644 index 000000000..a56bbf707 --- /dev/null +++ b/crates/xlsx/src/pivot/write.rs @@ -0,0 +1,907 @@ +use super::*; + +#[derive(Default)] +struct Stats { + blank: bool, + text: bool, + number: bool, + non_int: bool, + boolean: bool, + error: bool, + long: bool, + min: f64, + max: f64, +} + +impl Stats { + fn add(&mut self, v: &Value) { + match v { + Value::Empty => self.blank = true, + Value::Number(n) => { + if !self.number { + self.min = *n; + self.max = *n; + } + self.number = true; + self.min = self.min.min(*n); + self.max = self.max.max(*n); + if n.fract() != 0.0 { + self.non_int = true; + } + } + Value::Text(t) => { + self.text = true; + if t.chars().count() > 255 { + self.long = true; + } + } + Value::Bool(_) => self.boolean = true, + Value::Error(_) => self.error = true, + Value::Array(_) => self.add(&v.scalar()), + } + } + /// Excel counts booleans and errors as strings in the cache field flags. + fn strings(&self) -> bool { + self.text || self.boolean || self.error + } + /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers. + fn kinds(&self) -> usize { + usize::from(self.strings()) + usize::from(self.number) + } +} + +/// Date grouping of a cache field. +struct Group { + by: PivotDateGroup, + /// The database field the grouping applies to. + base: usize, + /// The next (coarser) grouping field of the same base. + par: Option, + start: f64, + end: f64, + /// Group item captions, including the `end` items. + items: Vec, +} + +struct CField { + name: String, + /// Column offset in the source (database fields only). + col: Option, + stats: Stats, + /// Shared items (written when `itemized`). + items: Vec, + index: HashMap, + itemized: bool, + group: Option, + /// A calculated field's formula (without `=`); such fields aren't database fields. + formula: Option, +} + +impl CField { + /// Item captions the PivotTable's `` indices refer to. + fn item_labels(&self) -> Vec { + match &self.group { + Some(g) => g.items.clone(), + None => self.items.iter().map(label).collect(), + } + } +} + +/// A cache built from a PivotTable's source. +struct Cache { + fields: Vec, + /// Pivot field → cache field, by the pivot field's position in rows, then columns. + axis_map: Vec>, + source_xml: String, + records: Option<(u64, String)>, +} + +/// PivotTables being written; the tables themselves are finished after the styles part (their +/// value number formats need style number-format ids). +#[derive(Default)] +pub struct PivotWriter { + /// (cache id, workbook-relative part name). + caches: Vec<(u32, String)>, + pending: Vec, + tables: u32, +} + +struct Pending { + part: String, + sheet: usize, + idx: usize, + cache_id: u32, + cache: Cache, +} + +impl PivotWriter { + /// Writes the caches of a sheet's PivotTables and adds the sheet's relationships to its + /// PivotTable parts. Pivots whose source can't be resolved are not written. + pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { + let Some(sheet) = wb.sheet(si) else { return }; + for (idx, pt) in sheet.pivots.iter().enumerate() { + let Some(cache) = build_cache(wb, pt, si) else { continue }; + let n = self.caches.len() as u32 + 1; + let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); + let rec_rid = if let Some((count, xml)) = &cache.records { + let mut r = Rels::default(); + let rid = r.add("pivotCacheRecords", &format!("pivotCacheRecords{n}.xml")); + out.part(&format!("xl/pivotCache/_rels/pivotCacheDefinition{n}.xml.rels"), None, r.xml().into_bytes()); + let s = format!("{XML_DECL}{xml}"); + out.part(&format!("xl/pivotCache/pivotCacheRecords{n}.xml"), Some(CT_PIVOT_CACHE_REC), s.into_bytes()); + Some(rid) + } else { + None + }; + out.part(&cache_part, Some(CT_PIVOT_CACHE_DEF), cache_xml(&cache, rec_rid.as_deref(), wb.date_system).into_bytes()); + self.caches.push((n, format!("pivotCache/pivotCacheDefinition{n}.xml"))); + self.tables += 1; + let t = self.tables; + rels.add("pivotTable", &format!("../pivotTables/pivotTable{t}.xml")); + let mut tr = Rels::default(); + tr.add("pivotCacheDefinition", &format!("../pivotCache/pivotCacheDefinition{n}.xml")); + out.part(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels"), None, tr.xml().into_bytes()); + self.pending.push(Pending { part: format!("xl/pivotTables/pivotTable{t}.xml"), sheet: si, idx, cache_id: n, cache }); + } + } + + /// Adds the workbook relationships to the caches; returns the `` element (empty + /// when there are none). + pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { + if self.caches.is_empty() { + return String::new(); + } + let mut s = String::from(""); + for (id, target) in &self.caches { + let rid = wb_rels.add("pivotCacheDefinition", target); + let _ = write!(s, ""); + } + s.push_str(""); + s + } + + /// Number formats used by value fields (to be registered in the styles part). + pub fn number_formats(&self, wb: &Workbook) -> Vec { + let mut v = Vec::new(); + for p in &self.pending { + if let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) { + for val in &pt.values { + if let Some(f) = &val.number_format + && !v.contains(f) + { + v.push(f.clone()); + } + } + } + } + v + } + + /// Writes the PivotTable parts. `fmt_id` maps a number format code to its styles id. + pub fn finish(self, wb: &Workbook, out: &mut Out, fmt_id: &dyn Fn(&str) -> Option) { + for p in self.pending { + let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) else { continue }; + let xml = table_xml(pt, p.idx, p.cache_id, &p.cache, fmt_id); + out.part(&p.part, Some(CT_PIVOT_TABLE), xml.into_bytes()); + } + } +} + +/// Resolves a pivot source to (sheet, range including the header row, worksheetSource XML). +fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Option<(usize, RangeRef, String)> { + let t = text.trim().trim_start_matches('=').trim(); + if t.is_empty() || depth > 4 { + return None; + } + if let Some((si, ti)) = wb.table(t) + && let Some(sh) = wb.sheet(si) + && let Some(tb) = sh.tables.get(ti) + { + if !tb.header_row { + return None; + } + let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); + let r = RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)); + // Table names are sanitized when written; refer by range when this one would change. + let valid = !tb.name.is_empty() + && tb.name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') + && tb.name.starts_with(|c: char| c.is_alphabetic() || c == '_') + && CellRef::parse(&tb.name).is_none(); + let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; + return Some((si, r, xml)); + } + let (sheet, body) = gridcraft_core::split_sheet(t); + if let Some(r) = RangeRef::parse(body) { + let si = match &sheet { + Some(n) => wb.sheet_index(n)?, + None => default_sheet, + }; + let sh = wb.sheet(si)?; + let r = if r.is_full_cols() || r.is_full_rows() { r.intersection(&sh.used_range()?)? } else { r }; + return Some((si, r, range_source_xml(&sh.name, r))); + } + if sheet.is_none() + && let Some(n) = wb.name(t, default_sheet) + { + let (si, r, _) = resolve_source(wb, &n.formula.clone(), default_sheet, depth + 1)?; + let xml = if n.scope.is_none() { + format!("", esc_attr(&n.name)) + } else { + wb.sheet(si).map(|s| range_source_xml(&s.name, r))? + }; + return Some((si, r, xml)); + } + None +} + +fn range_source_xml(sheet: &str, r: RangeRef) -> String { + let name: String = sheet.chars().take(31).collect(); + format!("", r.a1(), esc_attr(&name)) +} + +fn header_text(v: &Value) -> String { + match v { + Value::Empty => String::new(), + v => label(v).trim().to_string(), + } +} + +fn cache_fields(sh: &gridcraft_model::Sheet, r: RangeRef) -> Vec { + let mut fields: Vec = Vec::new(); + // Lower-case names taken, and the next suffix for each header (linear for repeated headers). + let mut taken = std::collections::HashSet::new(); + let mut next_suffix: HashMap = HashMap::new(); + for (k, c) in (r.start.col..=r.end.col).enumerate() { + let mut h = header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()); + if h.is_empty() { + h = format!("Column{}", k + 1); + } + let mut n = h.clone(); + let mut i = next_suffix.get(&h.to_lowercase()).copied().unwrap_or(2); + while taken.contains(&n.to_lowercase()) { + n = format!("{h}{i}"); + i += 1; + } + next_suffix.insert(h.to_lowercase(), i); + taken.insert(n.to_lowercase()); + fields.push(CField { + name: n, + col: Some(k as u32), + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: None, + formula: None, + }); + } + fields +} + +fn group_cache_fields(fields: &mut Vec, axis: &[&PivotField], date_system: DateSystem) { + let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + let groupable = |f: &CField| { + let s = &f.stats; + s.number + && !s.text + && !s.boolean + && !s.error + && datetime_from_serial(date_system, s.min).is_some() + && datetime_from_serial(date_system, s.max).is_some() + }; + // (base column field, groupings fine → coarse) + let mut groupings: Vec<(usize, Vec)> = Vec::new(); + for pf in axis { + let Some(b) = find(fields, &pf.source_col) else { continue }; + if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { + continue; + } + match groupings.iter_mut().find(|(x, _)| *x == b) { + Some((_, v)) => { + if !v.contains(&pf.date_group) { + v.push(pf.date_group); + } + } + None => groupings.push((b, vec![pf.date_group])), + } + } + let fineness = |g: &PivotDateGroup| match g { + PivotDateGroup::Days => 0, + PivotDateGroup::Months => 1, + PivotDateGroup::Quarters => 2, + PivotDateGroup::Years => 3, + PivotDateGroup::None => 4, + }; + for (b, list) in groupings.iter_mut() { + list.sort_by_key(fineness); + let (min, max) = fields.get(*b).map(|f| (f.stats.min, f.stats.max)).unwrap_or((0.0, 0.0)); + let (start, end) = (min.floor(), max.floor() + 1.0); + let make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(date_system, by, start, end) }; + let mut prev = *b; + for (k, by) in list.iter().enumerate() { + if k == 0 { + if let Some(f) = fields.get_mut(*b) { + f.group = Some(make(*by)); + } + continue; + } + let new = fields.len(); + let caption = group_caption(*by); + let mut n = caption.to_string(); + let mut i = 2; + while fields.iter().any(|f| same(&f.name, &n)) { + n = format!("{caption}{i}"); + i += 1; + } + if let Some(g) = fields.get_mut(prev).and_then(|f| f.group.as_mut()) { + g.par = Some(new); + } + fields.push(CField { + name: n, + col: None, + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: Some(make(*by)), + formula: None, + }); + prev = new; + } + } +} + +fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { + let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; + if r.count() > MAX_SOURCE_CELLS { + return None; + } + let sh = wb.sheet(si)?; + let mut fields = cache_fields(sh, r); + let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + + // Records (rows that aren't entirely blank), read once. + let mut rows: Vec = Vec::new(); + if r.end.row > r.start.row { + for row in r.start.row + 1..=r.end.row { + let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); + if !blank { + rows.push(row); + } + } + } + for &row in &rows { + for f in fields.iter_mut() { + let c = r.start.col + f.col.unwrap_or(0); + f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); + } + } + + // Which fields are on an axis, and how dates are grouped: per base column, the base field + // takes the finest grouping and coarser ones become extra (non-database) fields chained + // through `par`, as Excel lays them out. + let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); + group_cache_fields(&mut fields, &axis, wb.date_system); + let mut axis_map: Vec> = Vec::with_capacity(axis.len()); + for pf in &axis { + let Some(b) = find(&fields, &pf.source_col) else { + axis_map.push(None); + continue; + }; + let grouped = fields.iter().position(|f| f.group.as_ref().is_some_and(|g| g.base == b && g.by == pf.date_group)); + match grouped { + Some(i) if pf.date_group != PivotDateGroup::None => axis_map.push(Some(i)), + _ => { + if let Some(f) = fields.get_mut(b) + && f.group.is_none() + { + f.itemized = true; + } + axis_map.push(Some(b)); + } + } + } + // Page fields (filters not on an axis) need items too. + for f in &pt.filters { + let on_axis = axis.iter().any(|a| same(&a.source_col, &f.source_col)); + if !on_axis + && let Some(i) = find(&fields, &f.source_col) + && let Some(cf) = fields.get_mut(i) + { + cf.itemized = true; + } + } + for f in fields.iter_mut() { + if f.group.is_some() { + f.itemized = false; + } else if f.col.is_some() && (f.stats.text || f.stats.boolean || f.stats.error) { + f.itemized = true; + } + } + // Shared items, in order of first appearance. + for &row in &rows { + for f in fields.iter_mut().filter(|f| f.itemized) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + let k = key(&v); + if !f.index.contains_key(&k) && f.items.len() < MAX_ITEMS { + f.index.insert(k, f.items.len() as u32); + f.items.push(v); + } + } + } + // Records snapshot. + let db_fields = fields.iter().filter(|f| f.col.is_some()).count() as u64; + let records = if (rows.len() as u64).saturating_mul(db_fields) <= MAX_RECORD_CELLS { + let mut s = String::new(); + for &row in &rows { + s.push_str(""); + for f in fields.iter().filter(|f| f.col.is_some()) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + if f.itemized { + match f.index.get(&key(&v)) { + Some(i) => { + let _ = write!(s, ""); + } + None => s.push_str(&item_xml(&v, wb.date_system, false)), + } + } else { + s.push_str(&item_xml(&v, wb.date_system, f.group.is_some())); + } + } + s.push_str(""); + } + Some((rows.len() as u64, s)) + } else { + None + }; + // Calculated fields come after the database and grouping fields. + for cf in &pt.calculated_fields { + let f = cf.formula.trim(); + let f = f.strip_prefix('=').unwrap_or(f).trim(); + if cf.name.trim().is_empty() || f.is_empty() || fields.iter().any(|x| same(&x.name, &cf.name)) { + continue; + } + fields.push(CField { + name: cf.name.clone(), + col: None, + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: None, + formula: Some(f.to_string()), + }); + } + Some(Cache { fields, axis_map, source_xml, records }) +} + +fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec { + let mut v = vec![format!("<{}", mdy(sys, start))]; + match by { + PivotDateGroup::Years => { + let y0 = datetime_from_serial(sys, start).map(|d| d.year).unwrap_or(1900); + let y1 = datetime_from_serial(sys, end - 1.0).map(|d| d.year).unwrap_or(y0).max(y0); + for y in y0..=y1.min(y0 + 10_000) { + v.push(y.to_string()); + } + } + PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), + PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::to_string)), + PivotDateGroup::Days | PivotDateGroup::None => { + const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for (m, n) in MONTHS.iter().zip(DAYS) { + for d in 1..=n { + v.push(format!("{d}-{m}")); + } + } + } + } + v.push(format!(">{}", mdy(sys, end))); + v +} + +/// A cache value element (``, ``, ``…); `date` writes numbers as ``. +fn item_xml(v: &Value, sys: DateSystem, date: bool) -> String { + match v { + Value::Empty => "".into(), + Value::Number(n) => { + if date && let Some(d) = iso(sys, *n) { + return format!(""); + } + format!("", crate::xml::num(*n)) + } + Value::Text(t) => format!("", esc_attr(t)), + Value::Bool(b) => format!("", u8::from(*b)), + Value::Error(e) => format!("", esc_attr(e.as_str())), + Value::Array(_) => item_xml(&v.scalar(), sys, date), + } +} + +fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { + let mut s = format!("{XML_DECL} { + let _ = write!(s, " r:id=\"{rid}\" recordCount=\"{count}\""); + } + _ => s.push_str(" saveData=\"0\""), + } + s.push_str(" refreshOnLoad=\"1\" refreshedBy=\"GridCraft\" createdVersion=\"6\" refreshedVersion=\"6\" minRefreshableVersion=\"3\">"); + let _ = write!(s, "{}", c.source_xml); + let _ = write!(s, "", c.fields.len()); + for f in &c.fields { + let fmt = if f.group.is_some() { 14 } else { 0 }; + let _ = write!(s, "'); + let st = &f.stats; + if f.col.is_some() { + s.push_str(" 1 { + s.push_str(" containsMixedTypes=\"1\""); + } + if st.number { + s.push_str(" containsNumber=\"1\""); + if !st.non_int { + s.push_str(" containsInteger=\"1\""); + } + let _ = write!(s, " minValue=\"{}\" maxValue=\"{}\"", crate::xml::num(st.min), crate::xml::num(st.max)); + } + if st.long { + s.push_str(" longText=\"1\""); + } + } + if f.itemized { + let _ = write!(s, " count=\"{}\">", f.items.len()); + for v in &f.items { + s.push_str(&item_xml(v, sys, false)); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + if let Some(g) = &f.group { + s.push_str("", g.base); + let _ = write!(s, "", g.items.len()); + for it in &g.items { + let _ = write!(s, "", esc_attr(it)); + } + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +struct PivotTableLayout<'a> { + row_fields: Vec, + column_fields: Vec, + hidden_fields: HashMap, + page_fields: Vec<(usize, &'a PivotFilter)>, + values: Vec<(usize, &'a PivotValue)>, +} + +fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { + let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + let row_fields: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); + let column_fields: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); + // Filters: on an axis field → hidden items there; otherwise a page field. + let mut hidden: HashMap = HashMap::new(); + let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); + for f in &pt.filters { + let axis_pos = pt.rows.iter().chain(pt.columns.iter()).position(|a| same(&a.source_col, &f.source_col)); + match axis_pos { + Some(p) => { + if let Some(Some(i)) = c.axis_map.get(p) { + hidden.insert(*i, f); + } + } + None => { + if let Some(i) = find_db(&f.source_col) + && !pages.iter().any(|(j, _)| *j == i) + { + pages.push((i, f)); + hidden.insert(i, f); + } + } + } + } + let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); + let values: Vec<(usize, &PivotValue)> = + pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); + let layout = PivotTableLayout { row_fields, column_fields, hidden_fields: hidden, page_fields: pages, values }; + + let compact = pt.layout == PivotLayout::Compact; + let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; + let mut s = format!( + "{XML_DECL} s.push_str(" outline=\"1\" outlineData=\"1\""), + PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), + } + if pt.values_on_rows { + s.push_str(" dataOnRows=\"1\""); + } + if !pt.show_headers { + s.push_str(" showHeaders=\"0\""); + } + if !pt.enable_show_details { + s.push_str(" enableDrill=\"0\""); + } + // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"), + // `colGrandTotals` the grand total column at its right ("On for Rows"). + if !pt.grand_totals_cols { + s.push_str(" rowGrandTotals=\"0\""); + } + if !pt.grand_totals_rows { + s.push_str(" colGrandTotals=\"0\""); + } + s.push_str(" multipleFieldFilters=\"0\">"); + + // Location: the body (report filters sit above it). + let a = pt.anchor; + let end = pt.last_range.map(|r| r.end).unwrap_or(a); + let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); + let k = layout.values.len(); + // Σ Values (two or more value fields): its index among the written fields of its axis. + let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); + let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, layout.row_fields.len())); + // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. + let col_values = (k > 1 && !pt.values_on_rows).then_some(layout.column_fields.len()); + let first_data_row = if layout.column_fields.is_empty() { 1 } else { 1 + layout.column_fields.len() + usize::from(col_values.is_some()) }; + let row_levels = layout.row_fields.len() + usize::from(row_values.is_some()); + let first_data_col = if row_levels == 0 && layout.column_fields.is_empty() { + 0 + } else if compact { + 1 + } else { + row_levels.max(1) + }; + // Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row. + let hide_values_row = col_values.is_some() && layout.column_fields.is_empty(); + let first_header_row = usize::from(!hide_values_row); + let _ = write!( + s, + ""); + + write_pivot_fields(&mut s, pt, c, &layout); + axis_fields_xml(&mut s, "rowFields", &layout.row_fields, row_values); + axis_fields_xml(&mut s, "colFields", &layout.column_fields, col_values); + write_page_fields(&mut s, c, &layout); + write_data_fields(&mut s, c, &layout, fmt_id); + s.push_str(""); + if hide_values_row { + let _ = write!( + s, + "" + ); + } + s.push_str(""); + s +} + +fn axis_field<'a>(pt: &'a PivotTable, c: &Cache, i: usize) -> Option<&'a PivotField> { + let p = c.axis_map.iter().position(|x| *x == Some(i))?; + pt.rows.iter().chain(pt.columns.iter()).nth(p) +} + +fn write_pivot_fields(s: &mut String, pt: &PivotTable, c: &Cache, layout: &PivotTableLayout<'_>) { + let _ = write!(s, "", c.fields.len()); + for (i, f) in c.fields.iter().enumerate() { + s.push_str(" {} + PivotLayout::Outline => s.push_str(" compact=\"0\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" outline=\"0\""), + } + if f.group.is_some() { + s.push_str(" numFmtId=\"14\""); + } + let filter = layout.hidden_fields.get(&i).copied(); + let labels = f.item_labels(); + let is_page = axis == Some("axisPage"); + // Hidden items; never hide every item (a field needs one visible item). + let mut hide: Vec = match filter.and_then(|f| f.selected.as_ref()) { + Some(sel) if !(is_page && sel.len() == 1) => labels.iter().map(|l| !sel.iter().any(|x| same(x, l))).collect(), + _ => vec![false; labels.len()], + }; + if !hide.is_empty() && hide.iter().all(|h| *h) { + hide.iter_mut().for_each(|h| *h = false); + } + if is_page && hide.iter().any(|h| *h) { + s.push_str(" multipleItemSelectionAllowed=\"1\""); + } + s.push_str(" showAll=\"0\""); + if let Some(af) = axis_field(pt, c, i) { + match af.sort { + PivotSort::Asc => s.push_str(" sortType=\"ascending\""), + PivotSort::Desc => s.push_str(" sortType=\"descending\""), + PivotSort::None => {} + } + } + if !pt.subtotals { + s.push_str(" defaultSubtotal=\"0\""); + } + if !pt.subtotals_top { + s.push_str(" subtotalTop=\"0\""); + } + if axis.is_some() { + let collapsed: &[String] = axis_field(pt, c, i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); + let count = labels.len() + usize::from(pt.subtotals); + let _ = write!(s, ">"); + for (x, l) in labels.iter().enumerate() { + let _ = write!(s, ""); + } + if pt.subtotals { + s.push_str(""); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + s.push_str(""); +} + +fn write_page_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>) { + if layout.page_fields.is_empty() { + return; + } + let _ = write!(s, "", layout.page_fields.len()); + for (i, filter) in &layout.page_fields { + let _ = write!(s, ""); + } + s.push_str(""); +} + +fn write_data_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>, fmt_id: &dyn Fn(&str) -> Option) { + if layout.values.is_empty() { + return; + } + let _ = write!(s, "", layout.values.len()); + let mut used: Vec = Vec::new(); + for (i, value) in &layout.values { + let base = if value.name.trim().is_empty() { + format!("{} of {}", func_caption(value.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) + } else { + value.name.clone() + }; + let mut name = base.clone(); + let mut suffix = 2; + while used.iter().any(|existing| same(existing, &name)) { + name = format!("{base}{suffix}"); + suffix += 1; + } + used.push(name.clone()); + let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::RunningTotal => { + let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); + } + PivotShowAs::Rank => { + let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); + } + } + if let Some(id) = value.number_format.as_deref().and_then(fmt_id) { + let _ = write!(s, " numFmtId=\"{id}\""); + } + if value.show_as == PivotShowAs::Rank { + let _ = write!( + s, + ">" + ); + } else { + s.push_str("/>"); + } + } + s.push_str(""); +} + +/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. +fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { + let n = idx.len() + usize::from(values_at.is_some()); + if n == 0 { + return; + } + let _ = write!(s, "<{tag} count=\"{n}\">"); + for (j, i) in idx.iter().enumerate() { + if values_at == Some(j) { + s.push_str(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index d7b9240e2..1cce76b6d 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -98,90 +98,10 @@ pub fn find_rel<'r>(rels: &'r [Rel], kind: &str) -> Option<&'r Rel> { rels.iter().find(|r| r.kind == kind) } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - let pkg = Package::open(bytes)?; - let mut cx = Ctx { - pkg, - report: ReadReport::default(), - sst: vec![], - phonetics: vec![], - xf_map: vec![], - dxfs: vec![], - palette: crate::tables::INDEXED.to_vec(), - date_system: DateSystem::D1900, - styles: StyleTable::default(), - persons: Default::default(), - next_id: 1, - num_fmts: Default::default(), - cell_pictures: vec![], - cf_ext_ids: vec![], - python: Default::default(), - }; - let root_rels = cx.pkg.rels("")?; - let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); - let wb_xml = match cx.pkg.read_xml(&wb_part)? { - Some(x) => x, - None => { - if cx.pkg.has("xl/workbook.xml") && wb_part != "xl/workbook.xml" { - cx.pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))? - } else if cx.pkg.has("content.xml") { - return Err(IoError::Format("this is an OpenDocument file, not XLSX".into())); - } else { - return Err(IoError::Format("no workbook part".into())); - } - } - }; - if wb_xml.name != "workbook" { - return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name))); - } - let wb_rels = cx.pkg.rels(&wb_part)?; - let mut kept = crate::passthrough::KeptReader::new(&mut cx); - cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { - Ok(pictures) => pictures, - Err(error) => { - cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained")); - vec![] - } - }; - // The code of Python cells, which their formulas name by index. - cx.python = crate::python_part::read(&mut cx, &wb_rels)?; - let mut wb = Workbook::new(); - wb.sheets.clear(); - - // Theme, then styles: a font's link to the theme's body font is read against it. - if let Some(r) = find_rel(&wb_rels, "theme").cloned() - && let Some(x) = cx.optional_xml(&r.target)? - { - wb.theme = crate::theme::read_theme(&x); - } - // Cell checkboxes live in the feature property bags, which styles refer to. - let mut checkboxes = Vec::new(); - if let Some(r) = find_rel(&wb_rels, "FeaturePropertyBag").cloned() - && let Some(x) = cx.optional_xml(&r.target)? - { - checkboxes = crate::feature_bags::checkbox_complements(&x); - } - if let Some(r) = find_rel(&wb_rels, "styles").cloned() { - if let Some(x) = cx.optional_xml(&r.target)? { - let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font); - cx.num_fmts = num_fmts; - // Cell format 0 is the Normal cell format: everything on it (font, number format, - // fill, borders, alignment, protection) is what a cell without its own format shows - // (#1013). - if let Some(normal) = xfs.first() { - cx.styles = StyleTable::with_default(normal.clone()); - } - cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); - cx.dxfs = dxfs; - cx.palette = palette; - wb.cell_styles = named; - } - } else { - cx.warn("no styles part"); - } +fn read_shared_strings(cx: &mut Ctx<'_>, wb_rels: &[Rel]) -> Result<(), IoError> { // Shared strings, streamed item by item. Phonetic (furigana) runs are kept next to the text // they annotate, so an open + save does not delete them. - if let Some(r) = find_rel(&wb_rels, "sharedStrings").cloned() { + if let Some(r) = find_rel(wb_rels, "sharedStrings").cloned() { match cx.pkg.read(&r.target)? { Some(b) => { let mut sst = Vec::new(); @@ -211,18 +131,67 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } } - if let Some(r) = find_rel(&wb_rels, "person").cloned() - && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) + Ok(()) +} + +fn read_workbook_styles(cx: &mut Ctx<'_>, wb: &mut Workbook, wb_rels: &[Rel]) -> Result<(), IoError> { + // Theme, then styles: a font's link to the theme's body font is read against it. + if let Some(r) = find_rel(wb_rels, "theme").cloned() + && let Some(x) = cx.optional_xml(&r.target)? { - for p in x.kids("person") { - if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) { - cx.persons.insert(id.to_string(), n.to_string()); + wb.theme = crate::theme::read_theme(&x); + } + // Cell checkboxes live in the feature property bags, which styles refer to. + let mut checkboxes = Vec::new(); + if let Some(r) = find_rel(wb_rels, "FeaturePropertyBag").cloned() + && let Some(x) = cx.optional_xml(&r.target)? + { + checkboxes = crate::feature_bags::checkbox_complements(&x); + } + if let Some(r) = find_rel(wb_rels, "styles").cloned() { + if let Some(x) = cx.optional_xml(&r.target)? { + let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font); + cx.num_fmts = num_fmts; + // Cell format 0 is the Normal cell format: everything on it (font, number format, + // fill, borders, alignment, protection) is what a cell without its own format shows + // (#1013). + if let Some(normal) = xfs.first() { + cx.styles = StyleTable::with_default(normal.clone()); + } + cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect(); + cx.dxfs = dxfs; + cx.palette = palette; + wb.cell_styles = named; + } + } else { + cx.warn("no styles part"); + } + Ok(()) +} + +/// Read the workbook XML selected by the package's root relationships. +fn workbook_xml(pkg: &mut Package<'_>, part: &str) -> Result { + let xml = match pkg.read_xml(part)? { + Some(xml) => xml, + None => { + if pkg.has("xl/workbook.xml") && part != "xl/workbook.xml" { + pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))? + } else if pkg.has("content.xml") { + return Err(IoError::Format("this is an OpenDocument file, not XLSX".into())); + } else { + return Err(IoError::Format("no workbook part".into())); } } + }; + if xml.name != "workbook" { + return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", xml.name))); } + Ok(xml) +} - // Workbook properties. - if let Some(p) = wb_xml.child("workbookPr") { +/// Read workbook-level settings before processing the individual sheets. +fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { + if let Some(p) = xml.child("workbookPr") { if p.flag("date1904", false) { cx.date_system = DateSystem::D1904; } @@ -233,7 +202,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { }; } wb.date_system = cx.date_system; - if let Some(c) = wb_xml.child("calcPr") { + if let Some(c) = xml.child("calcPr") { wb.calc.mode = match c.attr("calcMode") { Some("manual") => CalcMode::Manual, Some("autoNoTable") => CalcMode::AutomaticExceptTables, @@ -248,10 +217,62 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } wb.calc.precision_as_displayed = !c.flag("fullPrecision", true); } - if let Some(p) = wb_xml.child("workbookProtection") { + if let Some(p) = xml.child("workbookProtection") { wb.protected_structure = p.flag("lockStructure", false); wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None }; } +} + +pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let pkg = Package::open(bytes)?; + let mut cx = Ctx { + pkg, + report: ReadReport::default(), + sst: vec![], + phonetics: vec![], + xf_map: vec![], + dxfs: vec![], + palette: crate::tables::INDEXED.to_vec(), + date_system: DateSystem::D1900, + styles: StyleTable::default(), + persons: Default::default(), + next_id: 1, + num_fmts: Default::default(), + cell_pictures: vec![], + cf_ext_ids: vec![], + python: Default::default(), + }; + let root_rels = cx.pkg.rels("")?; + let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); + let wb_xml = workbook_xml(&mut cx.pkg, &wb_part)?; + let wb_rels = cx.pkg.rels(&wb_part)?; + let mut kept = crate::passthrough::KeptReader::new(&mut cx); + cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) { + Ok(pictures) => pictures, + Err(error) => { + cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained")); + vec![] + } + }; + // The code of Python cells, which their formulas name by index. + cx.python = crate::python_part::read(&mut cx, &wb_rels)?; + let mut wb = Workbook::new(); + wb.sheets.clear(); + + read_workbook_styles(&mut cx, &mut wb, &wb_rels)?; + read_shared_strings(&mut cx, &wb_rels)?; + + if let Some(r) = find_rel(&wb_rels, "person").cloned() + && let Ok(Some(x)) = cx.pkg.read_xml(&r.target) + { + for p in x.kids("person") { + if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) { + cx.persons.insert(id.to_string(), n.to_string()); + } + } + } + + 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; // Sheets. `file_to_model[i]` maps the i-th to a model sheet index. @@ -434,7 +455,10 @@ pub fn parse_print_area(text: &str) -> Option { RangeRef::parse(&r) } -pub fn parse_print_titles(text: &str) -> (Option<(u32, u32)>, Option<(u32, u32)>) { +/// A print-titles span: the first and last row (or column) repeated on each page. +type TitleSpan = Option<(u32, u32)>; + +pub fn parse_print_titles(text: &str) -> (TitleSpan, TitleSpan) { let mut rows = None; let mut cols = None; for part in text.split(',') { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 5945a9669..864c97c6d 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -20,6 +20,13 @@ use crate::styles::read_color; use crate::tables::{col_width_to_px, paper_name, pt_to_px}; use crate::xml::{self, El}; +mod parts; +mod rules; + +pub use parts::{read_autofilter, read_table}; +use parts::{read_comments, read_hyperlinks, read_note_shapes, read_threaded}; +use rules::{read_cf, read_validation}; + struct Shared { anchor: CellRef, expr: Option, @@ -91,6 +98,24 @@ const MAX_DYNAMIC_CHECKS: u64 = 16_000_000; /// rule copies its element's whole range list, so rules × ranges must stay bounded. const MAX_COND_CELLS: u64 = 4_000_000; +fn read_sheet_format(e: &El, sheet: &mut Sheet) { + if let Some(h) = e.attr_f64("defaultRowHeight") { + let px = pt_to_px(h); + if px > 0.0 { + sheet.default_row_height = px; + } + } + if let Some(w) = e.attr_f64("defaultColWidth") { + let px = col_width_to_px(w); + if px > 0.0 { + sheet.default_col_width = px; + } + } else if let Some(b) = e.attr_f64("baseColWidth") { + let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0; + sheet.default_col_width = px as f32; + } +} + pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; let rels = cx.pkg.rels(part)?; @@ -128,23 +153,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_views(e, &mut sheet), - "sheetFormatPr" => { - if let Some(h) = e.attr_f64("defaultRowHeight") { - let px = pt_to_px(h); - if px > 0.0 { - sheet.default_row_height = px; - } - } - if let Some(w) = e.attr_f64("defaultColWidth") { - let px = col_width_to_px(w); - if px > 0.0 { - sheet.default_col_width = px; - } - } else if let Some(b) = e.attr_f64("baseColWidth") { - let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0; - sheet.default_col_width = px as f32; - } - } + "sheetFormatPr" => read_sheet_format(e, &mut sheet), "cols" => read_cols(cx, e, &mut sheet, &mut column_assignments_left)?, "mergeCells" => { for m in e.kids("mergeCell") { @@ -642,452 +651,6 @@ pub fn parse_sqref(s: Option<&str>) -> Vec { s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } -fn cf_op(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greaterThan" => CfOperator::Greater, - "lessThan" => CfOperator::Less, - "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -fn cfvo(e: &El) -> CfValueKind { - let val = e.attr("val").unwrap_or(""); - let n = val.trim().parse::().ok().filter(|v| v.is_finite()); - match e.attr("type").unwrap_or("num") { - "min" | "autoMin" => CfValueKind::Min, - "max" | "autoMax" => CfValueKind::Max, - "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "formula" => CfValueKind::Formula(from_file(val)), - _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - } -} - -/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. -fn cfvos(e: &El) -> Vec { - e.kids("cfvo") - .map(|c| { - if c.attr("val").is_none() - && let Some(f) = c.child("f") - { - let mut c2 = c.clone(); - c2.attrs.push(("val".into(), f.text.clone())); - return cfvo(&c2); - } - cfvo(c) - }) - .collect() -} - -fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { - let ranges = parse_sqref(e.attr("sqref")); - if ranges.is_empty() { - return; - } - for r in e.kids("cfRule") { - let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); - let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); - let f0 = formulas.first().cloned().unwrap_or_default(); - let text = r.attr("text").unwrap_or("").to_string(); - // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). - let ext_id = - r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); - let rule = match r.attr("type").unwrap_or("") { - "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, - "expression" => CfRule::Expression { formula: f0, style }, - "containsText" => CfRule::ContainsText { text, style }, - "notContainsText" => CfRule::NotContainsText { text, style }, - "beginsWith" => CfRule::BeginsWith { text, style }, - "endsWith" => CfRule::EndsWith { text, style }, - "containsBlanks" => CfRule::Blanks { style }, - "notContainsBlanks" => CfRule::NoBlanks { style }, - "containsErrors" => CfRule::Errors { style }, - "notContainsErrors" => CfRule::NoErrors { style }, - "duplicateValues" => CfRule::Duplicate { style }, - "uniqueValues" => CfRule::Unique { style }, - "top10" => { - CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } - } - "aboveAverage" => CfRule::AboveAverage { - below: !r.flag("aboveAverage", true), - equal: r.flag("equalAverage", false), - std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, - style, - }, - "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, - "colorScale" => { - let Some(cs) = r.child("colorScale") else { continue }; - let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); - CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } - } - "dataBar" => { - let Some(db) = r.child("dataBar") else { continue }; - let v = cfvos(db); - CfRule::DataBar { - min: v.first().cloned().unwrap_or(CfValueKind::Min), - max: v.get(1).cloned().unwrap_or(CfValueKind::Max), - color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), - gradient: true, - show_value: db.flag("showValue", true), - } - } - "iconSet" => { - let Some(is) = r.child("iconSet") else { continue }; - CfRule::IconSet { - set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), - thresholds: cfvos(is), - reverse: is.flag("reverse", false), - show_value: is.flag("showValue", true), - } - } - other => { - cx.warn(format!("conditional format type \"{other}\" is not supported")); - continue; - } - }; - if let Some(id) = ext_id { - cx.cf_ext_ids.push((id, sheet.cond_formats.len())); - } - let rules = sheet.cond_formats.len(); - let area = ranges.iter().map(RangeRef::count).sum::().saturating_mul(1 + rules as u64); - if area > MAX_COND_CELLS { - cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped")); - break; - } - sheet.cond_formats.push(CondFormat { - ranges: ranges.clone(), - rule, - priority: r.attr_u32("priority").unwrap_or(0), - stop_if_true: r.flag("stopIfTrue", false), - }); - } -} - -/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). -fn read_validation(v: &El) -> Option { - let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); - let ranges = parse_sqref(sq.as_deref()); - if ranges.is_empty() { - return None; - } - let formula = |n: &str| -> Option { - let f = v.child(n)?; - let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); - if t.is_empty() { None } else { Some(t.to_string()) } - }; - let kind = match v.attr("type").unwrap_or("none") { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - _ => ValidationKind::Any, - }; - let mut f1 = formula("formula1").unwrap_or_default(); - if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { - f1 = format!("={}", from_file(&f1)); - } else if !f1.starts_with('"') && !f1.is_empty() { - f1 = from_file(&f1); - } - Some(Validation { - ranges, - kind, - op: cf_op(v.attr("operator")), - f1, - f2: formula("formula2").map(|f| from_file(&f)), - allow_blank: v.flag("allowBlank", false), - // `showDropDown="1"` *hides* the in-cell list arrow. - in_cell_dropdown: !v.flag("showDropDown", false), - input_title: v.attr("promptTitle").unwrap_or("").to_string(), - input_message: v.attr("prompt").unwrap_or("").to_string(), - show_input: v.flag("showInputMessage", false), - error_title: v.attr("errorTitle").unwrap_or("").to_string(), - error_message: v.attr("error").unwrap_or("").to_string(), - error_style: match v.attr("errorStyle") { - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - _ => ErrorStyle::Stop, - }, - show_error: v.flag("showErrorMessage", false), - }) -} - -fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { - let mut cells = 0u64; - let mut bytes = 0u64; - for h in e.kids("hyperlink") { - let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; - let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); - // A relationship is a file or web page; a bare `location` is a place in this workbook. - let external = !target.is_empty(); - if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { - if target.is_empty() { - target = loc.trim_start_matches('#').to_string(); - } else { - target = format!("{target}#{loc}"); - } - } - if target.is_empty() { - continue; - } - let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external }; - let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64; - for c in range.iter() { - if cells >= MAX_HYPERLINK_CELLS { - cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); - return; - } - if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES { - cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped")); - return; - } - cells += 1; - bytes += link_bytes; - sheet.hyperlinks.insert(c, link.clone()); - } - } -} - -fn filter_op(s: Option<&str>) -> &'static str { - match s.unwrap_or("equal") { - "lessThan" => "<", - "lessThanOrEqual" => "<=", - "notEqual" => "<>", - "greaterThanOrEqual" => ">=", - "greaterThan" => ">", - _ => "=", - } -} - -pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { - let range = e.attr("ref").and_then(RangeRef::parse)?; - let mut criteria = vec![]; - for fc in e.kids("filterColumn") { - let Some(col) = fc.attr_u32("colId") else { continue }; - let crit = if let Some(f) = fc.child("filters") { - FilterCriterion::Values { - values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), - blanks: f.flag("blank", false), - } - } else if let Some(cf) = fc.child("customFilters") { - let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); - let Some(a) = it.next() else { continue }; - FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } - } else if let Some(t) = fc.child("top10") { - FilterCriterion::Top10 { - bottom: !t.flag("top", true), - percent: t.flag("percent", false), - count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, - } - } else if let Some(d) = fc.child("dynamicFilter") { - match d.attr("type") { - Some("aboveAverage") => FilterCriterion::AboveAverage(true), - Some("belowAverage") => FilterCriterion::AboveAverage(false), - _ => { - cx.warn("date filters in AutoFilters are not supported"); - continue; - } - } - } else if let Some(c) = fc.child("colorFilter") { - let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; - if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } - } else if let Some(i) = fc.child("iconFilter") { - let Some(set) = i.attr("iconSet") else { continue }; - FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } - } else { - continue; - }; - criteria.push((col, crit)); - } - Some(AutoFilter { range, criteria }) -} - -pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
"), HAlign::General), + ("B2", Value::Number(3.0), HAlign::General), + ("A3", Value::text("Total"), HAlign::Right), + ], + ); + wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); + assert_eq!( + to_html(&wb, 0).unwrap(), + "Sheet1\ + \ + \ + \ +
NameQty
Apples & <pears>3
Total
" + ); + let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; + assert_eq!( + copied(&wb, "A1:B2"), + format!( + "\ + \ +
NameQty
Apples & <pears>3
" + ) + ); +} + +#[test] +fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near
{ - let range = x.attr("ref").and_then(RangeRef::parse)?; - let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); - let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; - let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; - let columns = x - .child("tableColumns") - .map(|tc| { - tc.kids("tableColumn") - .map(|c| { - let totals = match c.attr("totalsRowFunction").unwrap_or("none") { - "average" => TotalsFn::Average, - "count" => TotalsFn::Count, - "countNums" => TotalsFn::CountNums, - "max" => TotalsFn::Max, - "min" => TotalsFn::Min, - "sum" => TotalsFn::Sum, - "stdDev" => TotalsFn::StdDev, - "var" => TotalsFn::Var, - "custom" => TotalsFn::Custom, - _ => TotalsFn::None, - }; - let totals_label = if totals == TotalsFn::Custom { - c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) - } else { - c.attr("totalsRowLabel").map(str::to_string) - }; - TableColumn { - name: xml::decode_xstring(c.attr("name").unwrap_or("")), - totals, - totals_label, - formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), - unique_name: c.attr("uniqueName").map(str::to_string), - xml: c.child("xmlColumnPr").and_then(|x| { - Some(XmlColumnPr { - map_id: x.attr_u32("mapId")?, - xpath: x.attr("xpath")?.to_string(), - data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), - denormalized: x.flag("denormalized", false), - }) - }), - } - }) - .collect() - }) - .unwrap_or_default(); - let si = x.child("tableStyleInfo"); - // Only XML tables are kept as such: a query table's definition hangs off the table part, which - // is rewritten, so it would lose it. - let xml_table = x.attr("tableType") == Some("xml"); - Some(Table { - id: x.attr_u32("id").unwrap_or(0), - name, - range, - header_row, - totals_row, - columns, - style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), - banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), - banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), - first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), - last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), - filter_button: x.child("autoFilter").is_some(), - filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), - table_type: xml_table.then(|| "xml".to_string()), - connection_id: x.attr_u32("connectionId").filter(|_| xml_table), - }) -} - -fn read_comments(x: &El, sheet: &mut Sheet) { - let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); - let Some(list) = x.child("commentList") else { return }; - for c in list.kids("comment") { - let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; - let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); - let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); - // Excel starts a note with a bold "Author:" line. - if !author.is_empty() - && let Some(rest) = text.strip_prefix(&format!("{author}:")) - { - text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); - } - sheet - .comments - .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); - } -} - -/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its -/// `` and style size become the note's box. Shapes may sit in groups. -fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { - if depth > xml::MAX_DEPTH { - return; - } - for e in &x.children { - if e.name != "shape" { - read_note_shapes(e, sheet, depth + 1); - continue; - } - let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; - let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); - let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; - let cell = CellRef::new(row, col); - let Some(c) = sheet.comments.get_mut(&cell) else { continue }; - if cd.child("Visible").is_some() && !c.threaded { - c.visible = true; - } - if let Some(anchor) = cd.child("Anchor") { - c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); - } - } -} - -/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, -/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, -/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet -/// numbers. -fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { - let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; - let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; - if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { - return None; - } - let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { - col: i64::from(c) - i64::from(cell.col), - col_px: dx, - row: i64::from(r) - i64::from(cell.row), - row_px: dy, - }; - let pt = |key: &str| { - style - .split(';') - .filter_map(|d| d.split_once(':')) - .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) - .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) - }; - Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) -} - -fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { - let mut by_id: HashMap = HashMap::new(); - let mut replies: Vec<(String, String, String, String)> = vec![]; - for t in x.kids("threadedComment") { - let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; - let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); - let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); - let date = t.attr("dT").unwrap_or("").to_string(); - match t.attr("parentId") { - Some(p) => replies.push((p.to_string(), author, text, date)), - None => { - if let Some(id) = t.attr("id") { - by_id.insert(id.to_string(), cell); - } - let dates = if date.is_empty() { vec![] } else { vec![date] }; - sheet.comments.insert( - cell, - Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, - ); - } - } - } - // Replies in file order; ones whose thread is missing are dropped. - for (parent, author, text, date) in replies { - if let Some(c) = by_id.get(&parent) - && let Some(cm) = sheet.comments.get_mut(c) - { - if !date.is_empty() { - // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. - let at = cm.replies.len() + 1; - if cm.dates.len() < at { - cm.dates.resize(at, String::new()); - } - cm.dates.push(date); - } - cm.replies.push((author, text)); - } - } -} - /// Worksheet extensions: x14 data validations and sparklines. fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { for ext in e.kids("ext") { diff --git a/crates/xlsx/src/sheet_read/parts.rs b/crates/xlsx/src/sheet_read/parts.rs new file mode 100644 index 000000000..3ff672289 --- /dev/null +++ b/crates/xlsx/src/sheet_read/parts.rs @@ -0,0 +1,273 @@ +use super::{ + AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS, MAX_ROWS, + NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml, +}; + +pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) { + let mut cells = 0u64; + let mut bytes = 0u64; + for h in e.kids("hyperlink") { + let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue }; + let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default(); + // A relationship is a file or web page; a bare `location` is a place in this workbook. + let external = !target.is_empty(); + if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { + if target.is_empty() { + target = loc.trim_start_matches('#').to_string(); + } else { + target = format!("{target}#{loc}"); + } + } + if target.is_empty() { + continue; + } + let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external }; + let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64; + for c in range.iter() { + if cells >= MAX_HYPERLINK_CELLS { + cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped")); + return; + } + if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES { + cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped")); + return; + } + cells += 1; + bytes += link_bytes; + sheet.hyperlinks.insert(c, link.clone()); + } + } +} + +fn filter_op(s: Option<&str>) -> &'static str { + match s.unwrap_or("equal") { + "lessThan" => "<", + "lessThanOrEqual" => "<=", + "notEqual" => "<>", + "greaterThanOrEqual" => ">=", + "greaterThan" => ">", + _ => "=", + } +} + +pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { + let range = e.attr("ref").and_then(RangeRef::parse)?; + let mut criteria = vec![]; + for fc in e.kids("filterColumn") { + let Some(col) = fc.attr_u32("colId") else { continue }; + let crit = if let Some(f) = fc.child("filters") { + FilterCriterion::Values { + values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), + blanks: f.flag("blank", false), + } + } else if let Some(cf) = fc.child("customFilters") { + let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); + let Some(a) = it.next() else { continue }; + FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } + } else if let Some(t) = fc.child("top10") { + FilterCriterion::Top10 { + bottom: !t.flag("top", true), + percent: t.flag("percent", false), + count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, + } + } else if let Some(d) = fc.child("dynamicFilter") { + match d.attr("type") { + Some("aboveAverage") => FilterCriterion::AboveAverage(true), + Some("belowAverage") => FilterCriterion::AboveAverage(false), + _ => { + cx.warn("date filters in AutoFilters are not supported"); + continue; + } + } + } else if let Some(c) = fc.child("colorFilter") { + let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; + if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } + } else if let Some(i) = fc.child("iconFilter") { + let Some(set) = i.attr("iconSet") else { continue }; + FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } + } else { + continue; + }; + criteria.push((col, crit)); + } + Some(AutoFilter { range, criteria }) +} + +pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
{ + let range = x.attr("ref").and_then(RangeRef::parse)?; + let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); + let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; + let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; + let columns = x + .child("tableColumns") + .map(|tc| { + tc.kids("tableColumn") + .map(|c| { + let totals = match c.attr("totalsRowFunction").unwrap_or("none") { + "average" => TotalsFn::Average, + "count" => TotalsFn::Count, + "countNums" => TotalsFn::CountNums, + "max" => TotalsFn::Max, + "min" => TotalsFn::Min, + "sum" => TotalsFn::Sum, + "stdDev" => TotalsFn::StdDev, + "var" => TotalsFn::Var, + "custom" => TotalsFn::Custom, + _ => TotalsFn::None, + }; + let totals_label = if totals == TotalsFn::Custom { + c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) + } else { + c.attr("totalsRowLabel").map(str::to_string) + }; + TableColumn { + name: xml::decode_xstring(c.attr("name").unwrap_or("")), + totals, + totals_label, + formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), + unique_name: c.attr("uniqueName").map(str::to_string), + xml: c.child("xmlColumnPr").and_then(|x| { + Some(XmlColumnPr { + map_id: x.attr_u32("mapId")?, + xpath: x.attr("xpath")?.to_string(), + data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), + denormalized: x.flag("denormalized", false), + }) + }), + } + }) + .collect() + }) + .unwrap_or_default(); + let si = x.child("tableStyleInfo"); + // Only XML tables are kept as such: a query table's definition hangs off the table part, which + // is rewritten, so it would lose it. + let xml_table = x.attr("tableType") == Some("xml"); + Some(Table { + id: x.attr_u32("id").unwrap_or(0), + name, + range, + header_row, + totals_row, + columns, + style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), + banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), + banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), + first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), + last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), + filter_button: x.child("autoFilter").is_some(), + filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), + table_type: xml_table.then(|| "xml".to_string()), + connection_id: x.attr_u32("connectionId").filter(|_| xml_table), + }) +} + +pub(super) fn read_comments(x: &El, sheet: &mut Sheet) { + let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); + let Some(list) = x.child("commentList") else { return }; + for c in list.kids("comment") { + let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; + let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); + let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); + // Excel starts a note with a bold "Author:" line. + if !author.is_empty() + && let Some(rest) = text.strip_prefix(&format!("{author}:")) + { + text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); + } + sheet + .comments + .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); + } +} + +/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its +/// `` and style size become the note's box. Shapes may sit in groups. +pub(super) fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { + if depth > xml::MAX_DEPTH { + return; + } + for e in &x.children { + if e.name != "shape" { + read_note_shapes(e, sheet, depth + 1); + continue; + } + let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; + let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); + let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; + let cell = CellRef::new(row, col); + let Some(c) = sheet.comments.get_mut(&cell) else { continue }; + if cd.child("Visible").is_some() && !c.threaded { + c.visible = true; + } + if let Some(anchor) = cd.child("Anchor") { + c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); + } + } +} + +/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, +/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, +/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet +/// numbers. +fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { + let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; + if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { + return None; + } + let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { + col: i64::from(c) - i64::from(cell.col), + col_px: dx, + row: i64::from(r) - i64::from(cell.row), + row_px: dy, + }; + let pt = |key: &str| { + style + .split(';') + .filter_map(|d| d.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) + }; + Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) +} + +pub(super) fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { + let mut by_id: HashMap = HashMap::new(); + let mut replies: Vec<(String, String, String, String)> = vec![]; + for t in x.kids("threadedComment") { + let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; + let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); + let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); + let date = t.attr("dT").unwrap_or("").to_string(); + match t.attr("parentId") { + Some(p) => replies.push((p.to_string(), author, text, date)), + None => { + if let Some(id) = t.attr("id") { + by_id.insert(id.to_string(), cell); + } + let dates = if date.is_empty() { vec![] } else { vec![date] }; + sheet.comments.insert( + cell, + Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, + ); + } + } + } + // Replies in file order; ones whose thread is missing are dropped. + for (parent, author, text, date) in replies { + if let Some(c) = by_id.get(&parent) + && let Some(cm) = sheet.comments.get_mut(c) + { + if !date.is_empty() { + // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. + let at = cm.replies.len() + 1; + if cm.dates.len() < at { + cm.dates.resize(at, String::new()); + } + cm.dates.push(date); + } + cm.replies.push((author, text)); + } + } +} diff --git a/crates/xlsx/src/sheet_read/rules.rs b/crates/xlsx/src/sheet_read/rules.rs new file mode 100644 index 000000000..4e4500707 --- /dev/null +++ b/crates/xlsx/src/sheet_read/rules.rs @@ -0,0 +1,181 @@ +use super::{ + CfOperator, CfRule, CfValueKind, CondFormat, Ctx, El, ErrorStyle, MAX_COND_CELLS, RangeRef, Sheet, Validation, ValidationKind, from_file, + parse_sqref, read_color, +}; + +fn cf_op(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greaterThan" => CfOperator::Greater, + "lessThan" => CfOperator::Less, + "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +fn cfvo(e: &El) -> CfValueKind { + let val = e.attr("val").unwrap_or(""); + let n = val.trim().parse::().ok().filter(|v| v.is_finite()); + match e.attr("type").unwrap_or("num") { + "min" | "autoMin" => CfValueKind::Min, + "max" | "autoMax" => CfValueKind::Max, + "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "formula" => CfValueKind::Formula(from_file(val)), + _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + } +} + +/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. +fn cfvos(e: &El) -> Vec { + e.kids("cfvo") + .map(|c| { + if c.attr("val").is_none() + && let Some(f) = c.child("f") + { + let mut c2 = c.clone(); + c2.attrs.push(("val".into(), f.text.clone())); + return cfvo(&c2); + } + cfvo(c) + }) + .collect() +} + +pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { + let ranges = parse_sqref(e.attr("sqref")); + if ranges.is_empty() { + return; + } + for r in e.kids("cfRule") { + let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); + let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); + let f0 = formulas.first().cloned().unwrap_or_default(); + let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = + r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); + let rule = match r.attr("type").unwrap_or("") { + "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, + "expression" => CfRule::Expression { formula: f0, style }, + "containsText" => CfRule::ContainsText { text, style }, + "notContainsText" => CfRule::NotContainsText { text, style }, + "beginsWith" => CfRule::BeginsWith { text, style }, + "endsWith" => CfRule::EndsWith { text, style }, + "containsBlanks" => CfRule::Blanks { style }, + "notContainsBlanks" => CfRule::NoBlanks { style }, + "containsErrors" => CfRule::Errors { style }, + "notContainsErrors" => CfRule::NoErrors { style }, + "duplicateValues" => CfRule::Duplicate { style }, + "uniqueValues" => CfRule::Unique { style }, + "top10" => { + CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } + } + "aboveAverage" => CfRule::AboveAverage { + below: !r.flag("aboveAverage", true), + equal: r.flag("equalAverage", false), + std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, + style, + }, + "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, + "colorScale" => { + let Some(cs) = r.child("colorScale") else { continue }; + let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); + CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } + } + "dataBar" => { + let Some(db) = r.child("dataBar") else { continue }; + let v = cfvos(db); + CfRule::DataBar { + min: v.first().cloned().unwrap_or(CfValueKind::Min), + max: v.get(1).cloned().unwrap_or(CfValueKind::Max), + color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), + gradient: true, + show_value: db.flag("showValue", true), + } + } + "iconSet" => { + let Some(is) = r.child("iconSet") else { continue }; + CfRule::IconSet { + set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), + thresholds: cfvos(is), + reverse: is.flag("reverse", false), + show_value: is.flag("showValue", true), + } + } + other => { + cx.warn(format!("conditional format type \"{other}\" is not supported")); + continue; + } + }; + if let Some(id) = ext_id { + cx.cf_ext_ids.push((id, sheet.cond_formats.len())); + } + let rules = sheet.cond_formats.len(); + let area = ranges.iter().map(RangeRef::count).sum::().saturating_mul(1 + rules as u64); + if area > MAX_COND_CELLS { + cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped")); + break; + } + sheet.cond_formats.push(CondFormat { + ranges: ranges.clone(), + rule, + priority: r.attr_u32("priority").unwrap_or(0), + stop_if_true: r.flag("stopIfTrue", false), + }); + } +} + +/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). +pub(super) fn read_validation(v: &El) -> Option { + let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); + let ranges = parse_sqref(sq.as_deref()); + if ranges.is_empty() { + return None; + } + let formula = |n: &str| -> Option { + let f = v.child(n)?; + let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + let kind = match v.attr("type").unwrap_or("none") { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + _ => ValidationKind::Any, + }; + let mut f1 = formula("formula1").unwrap_or_default(); + if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { + f1 = format!("={}", from_file(&f1)); + } else if !f1.starts_with('"') && !f1.is_empty() { + f1 = from_file(&f1); + } + Some(Validation { + ranges, + kind, + op: cf_op(v.attr("operator")), + f1, + f2: formula("formula2").map(|f| from_file(&f)), + allow_blank: v.flag("allowBlank", false), + // `showDropDown="1"` *hides* the in-cell list arrow. + in_cell_dropdown: !v.flag("showDropDown", false), + input_title: v.attr("promptTitle").unwrap_or("").to_string(), + input_message: v.attr("prompt").unwrap_or("").to_string(), + show_input: v.flag("showInputMessage", false), + error_title: v.attr("errorTitle").unwrap_or("").to_string(), + error_message: v.attr("error").unwrap_or("").to_string(), + error_style: match v.attr("errorStyle") { + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + _ => ErrorStyle::Stop, + }, + show_error: v.flag("showErrorMessage", false), + }) +} diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 5203adc1b..98f0bce37 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -16,60 +16,19 @@ use crate::tables::{paper_id, px_to_col_width, px_to_pt}; use crate::write::{ CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL, }; -use crate::xml::{encode_xstring, esc, esc_attr, num}; +use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_u64}; + +mod parts; +mod rules; + +pub(crate) use parts::password_attrs; +use parts::{autofilter_xml, comments_xml, protected_ranges_xml, table_columns, table_name, table_xml, thread_ids, threaded_xml, vml_xml}; +use rules::{cf_rule_xml, solid_bar_id, validation_xml, x14_cfvo}; fn sqref(ranges: &[RangeRef]) -> String { ranges.iter().map(|r| r.a1()).collect::>().join(" ") } -fn cf_op_name(op: CfOperator) -> &'static str { - match op { - CfOperator::Between => "between", - CfOperator::NotBetween => "notBetween", - CfOperator::Equal => "equal", - CfOperator::NotEqual => "notEqual", - CfOperator::Greater => "greaterThan", - CfOperator::Less => "lessThan", - CfOperator::GreaterOrEqual => "greaterThanOrEqual", - CfOperator::LessOrEqual => "lessThanOrEqual", - } -} - -/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` -/// (Microsoft "Names in formulas" rules). -fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { - let mut s: String = - name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); - if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { - s = format!("Table{n}"); - } - let mut out = s.clone(); - let mut k = 2; - while !used.insert(out.to_ascii_lowercase()) { - out = format!("{s}{k}"); - k += 1; - } - out -} - -/// Column names for a table: one per column of its range, unique and non-empty. -fn table_columns(t: &Table) -> Vec { - let w = t.range.width() as usize; - let mut used = HashSet::new(); - (0..w) - .map(|i| { - let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base}{k}"); - k += 1; - } - name - }) - .collect() -} - pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (String, Rels) { let mut rels = Rels::default(); let Some(sheet) = wb.sheet(si) else { return (String::new(), rels) }; @@ -167,70 +126,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } let _ = write!(s, "", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into())); - // sheetViews - s.push_str(""); - let active = sheet.view_active.a1(); - match sheet.freeze { - Some((rows, cols)) if rows > 0 || cols > 0 => { - let pane = match (rows > 0, cols > 0) { - (true, true) => "bottomRight", - (true, false) => "bottomLeft", - _ => "topRight", - }; - s.push_str(" 0 { - let _ = write!(s, " xSplit=\"{cols}\""); - } - if rows > 0 { - let _ = write!(s, " ySplit=\"{rows}\""); - } - let _ = write!( - s, - " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", - CellRef::new( - rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), - cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) - ) - .a1() - ); - let _ = write!(s, ""); - } - _ => { - let _ = write!(s, ""); - } - } - s.push_str(""); + write_sheet_views(&mut s, sheet, selected); // sheetFormatPr let max_row_outline = sheet.rows.values().map(|r| r.outline).max().unwrap_or(0); @@ -295,59 +191,248 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( s.push_str(""); } - // sheetData - let mut keys: BTreeSet = sheet.cells.iter().map(|(c, _)| c).collect(); - keys.extend(sheet.cell_pictures.keys().copied()); - keys.extend(extra.keys().copied()); - keys.extend(overrides.keys().copied()); - let mut rows: BTreeMap> = BTreeMap::new(); - for k in keys { - rows.entry(k.row).or_default().push(k); + write_sheet_data(&mut s, SheetCellData { workbook: wb, index: si, sheet, overrides: &overrides, extra: &extra, dynamic: &dynamic }, &sid, out); + + let prio = write_sheet_rules(&mut s, sheet, &mut rels, out); + let kept = write_sheet_printing(&mut s, sheet, &mut rels, out); + + write_sheet_drawing(&mut s, wb, si, sheet, &mut rels, out); + write_sheet_notes(&mut s, sheet, si, &kept, &mut rels, out); + // Tables. + if !tables.is_empty() { + let mut parts = String::new(); + let mut used_names: HashSet = HashSet::new(); + for (t, cols) in &tables { + out.tables += 1; + out.table_id += 1; + let n = out.tables; + let name = table_name(&t.name, &mut used_names, out.table_id); + let xml = table_xml(t, cols, &name, out.table_id, out); + out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes()); + let rid = rels.add("table", &format!("../tables/table{n}.xml")); + let _ = write!(parts, ""); + } + let _ = write!(s, "{parts}", tables.len()); } - for r in sheet.rows.keys() { - rows.entry(*r).or_default(); + write_sheet_extensions(&mut s, sheet, &prio); + s.push_str(""); + (s, rels) +} + +struct CellWriteInput<'a> { + reference: CellRef, + cell: Option<&'a Cell>, + picture: Option<&'a std::sync::Arc>, + header_override: Option<&'a String>, + spill_value: Option<&'a Value>, + dynamic_range: Option<&'a RangeRef>, +} + +fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) { + // Excel 2010 extensions: solid data bars, then sparklines. + let solid_bars: Vec<(&CondFormat, u32)> = sheet + .cond_formats + .iter() + .enumerate() + .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) + .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) + .collect(); + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); } - s.push_str(""); - for (r, cells) in &rows { - let info = sheet.rows.get(r).copied().unwrap_or_default(); - if cells.is_empty() && info == LineInfo::default() { - continue; + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + for (cf, p) in &solid_bars { + if let CfRule::DataBar { min, max, .. } = &cf.rule { + let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); + let _ = write!(s, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } } - let _ = write!(s, ""); + } + if !sheet.sparklines.is_empty() { + let _ = write!(s, ""); + for sp in &sheet.sparklines { + let ty = match sp.kind { + SparklineKind::Line => "", + SparklineKind::Column => " type=\"column\"", + SparklineKind::WinLoss => " type=\"stacked\"", + }; + let src = + if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) }; + let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) }; + let _ = write!( + s, + "{}{}", + if sp.markers { " markers=\"1\"" } else { "" }, + esc(&src), + sp.cell.a1() + ); } - if let Some(h) = info.size { - let _ = write!(s, " ht=\"{}\"{}", num(px_to_pt(h)), if info.custom { " customHeight=\"1\"" } else { "" }); + s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } +} + +fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) { + // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note + // (its text with the replies as lines, for readers without threaded comments) linked by id. + if !sheet.comments.is_empty() { + out.comments += 1; + let n = out.comments; + let ids = thread_ids(sheet, si); + out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); + if !ids.is_empty() { + let x = threaded_xml(sheet, &ids, &mut out.persons); + out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); + rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); } - if info.hidden { - s.push_str(" hidden=\"1\""); + out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); + let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); + let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); + out.part(&vml_name, None, vml.into_bytes()); + rels.add("comments", &format!("../comments{n}.xml")); + let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); + let _ = write!(s, ""); + } else if let Some(rid) = kept.lone_vml(rels, out) { + let _ = write!(s, ""); + } + s.push_str(&kept.elements()); +} + +fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) { + // Drawing: pictures, charts, shapes. + if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { + out.drawings += 1; + let dn = out.drawings; + let mut drels = Rels::default(); + let mut objs: Vec> = vec![]; + for img in &sheet.images { + if img.data.is_empty() { + continue; + } + let ext = image_ext(&img.mime, &img.data); + out.default_type(ext, image_content_type(ext)); + let file = out.image_part(ext, &img.data); + let rid = drels.add("image", &format!("../media/{file}")); + objs.push(Obj::Image(img, rid)); } - if info.outline > 0 { - let _ = write!(s, " outlineLevel=\"{}\"", info.outline.min(7)); + for ch in &sheet.charts { + out.charts += 1; + let rid = if crate::chartex::is_chartex(ch.kind) { + let n = out.charts; + let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names); + out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes()); + // Excel expects a chartex part's style and colour parts. + out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes()); + out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes()); + let mut crels = Rels::default(); + crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml")); + crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml")); + out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes()); + drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml")) + } else { + let name = format!("xl/charts/chart{}.xml", out.charts); + out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes()); + drels.add("chart", &format!("../charts/chart{}.xml", out.charts)) + }; + objs.push(Obj::Chart(ch, rid)); } - if info.collapsed { - s.push_str(" collapsed=\"1\""); + for sh in &sheet.shapes { + objs.push(Obj::Shape(sh)); } - if cells.is_empty() { - s.push_str("/>"); - continue; + out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); + if !drels.is_empty() { + out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes()); + } + let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml")); + let _ = write!(s, ""); + } +} + +fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> crate::passthrough::SheetKept<'a> { + // Printing. + let p = &sheet.print; + if p.gridlines || p.headings || p.center_h || p.center_v { + s.push_str(""); + } + // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). + let kept = crate::passthrough::SheetKept::write(sheet, rels, out); + let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); + let _ = + write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); + s.push_str(""); + if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { + s.push_str("'); - for c in cells { - let cell = sheet.cells.get(*c); - let ov = overrides.get(c); - let ex = extra.get(c); - let array = dynamic.get(c).copied().or_else(|| { - let f = cell?.formula.as_ref()?; - (f.array.is_none() && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c)) - }); - write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, array.as_ref(), &sid, out); + // CT_HeaderFooter's element order. + for (tag, text) in [ + ("oddHeader", &p.header), + ("oddFooter", &p.footer), + ("evenHeader", &p.even_header), + ("evenFooter", &p.even_footer), + ("firstHeader", &p.first_header), + ("firstFooter", &p.first_footer), + ] { + if !text.is_empty() { + let _ = write!(s, "<{tag}>{}", esc(text)); + } + } + s.push_str(""); + } + for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { + let list: Vec = list.iter().copied().filter(|&b| b > 0).collect(); + if !list.is_empty() { + let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len()); + for b in list { + let _ = write!(s, ""); + } + let _ = write!(s, ""); } - s.push_str(""); } - s.push_str(""); + kept +} + +fn write_sheet_rules(s: &mut String, sheet: &Sheet, rels: &mut Rels, out: &mut Out) -> Vec { // sheetProtection if let Some(p) = &sheet.protection { s.push_str(" ( } s.push_str(""); } - // Printing. - let p = &sheet.print; - if p.gridlines || p.headings || p.center_h || p.center_v { - s.push_str(""); - } - // Unmodelled content kept from the source file (printer settings, controls, OLE objects…). - let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out); - let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64); - let _ = - write!(s, "", m(0), m(1), m(2), m(3), m(4), m(5)); - s.push_str(""); - if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { - s.push_str("'); - // CT_HeaderFooter's element order. - for (tag, text) in [ - ("oddHeader", &p.header), - ("oddFooter", &p.footer), - ("evenHeader", &p.even_header), - ("evenFooter", &p.even_footer), - ("firstHeader", &p.first_header), - ("firstFooter", &p.first_footer), - ] { - if !text.is_empty() { - let _ = write!(s, "<{tag}>{}", esc(text)); - } - } - s.push_str(""); - } - for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { - let list: Vec = list.iter().copied().filter(|&b| b > 0).collect(); - if !list.is_empty() { - let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len()); - for b in list { - let _ = write!(s, ""); - } - let _ = write!(s, ""); - } - } - - // Drawing: pictures, charts, shapes. - if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() { - out.drawings += 1; - let dn = out.drawings; - let mut drels = Rels::default(); - let mut objs: Vec> = vec![]; - for img in &sheet.images { - if img.data.is_empty() { - continue; - } - let ext = image_ext(&img.mime, &img.data); - out.default_type(ext, image_content_type(ext)); - let file = out.image_part(ext, &img.data); - let rid = drels.add("image", &format!("../media/{file}")); - objs.push(Obj::Image(img, rid)); - } - for ch in &sheet.charts { - out.charts += 1; - let rid = if crate::chartex::is_chartex(ch.kind) { - let n = out.charts; - let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names); - out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes()); - // Excel expects a chartex part's style and colour parts. - out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes()); - out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes()); - let mut crels = Rels::default(); - crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml")); - crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml")); - out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes()); - drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml")) - } else { - let name = format!("xl/charts/chart{}.xml", out.charts); - out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes()); - drels.add("chart", &format!("../charts/chart{}.xml", out.charts)) - }; - objs.push(Obj::Chart(ch, rid)); - } - for sh in &sheet.shapes { - objs.push(Obj::Shape(sh)); - } - out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes()); - if !drels.is_empty() { - out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes()); - } - let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml")); - let _ = write!(s, ""); - } - // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note - // (its text with the replies as lines, for readers without threaded comments) linked by id. - if !sheet.comments.is_empty() { - out.comments += 1; - let n = out.comments; - let ids = thread_ids(sheet, si); - out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); - if !ids.is_empty() { - let x = threaded_xml(sheet, &ids, &mut out.persons); - out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); - rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); - } - out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); - let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); - let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); - out.part(&vml_name, None, vml.into_bytes()); - rels.add("comments", &format!("../comments{n}.xml")); - let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); - let _ = write!(s, ""); - } else if let Some(rid) = kept.lone_vml(&mut rels, out) { - let _ = write!(s, ""); - } - s.push_str(&kept.elements()); - // Tables. - if !tables.is_empty() { - let mut parts = String::new(); - let mut used_names: HashSet = HashSet::new(); - for (t, cols) in &tables { - out.tables += 1; - out.table_id += 1; - let n = out.tables; - let name = table_name(&t.name, &mut used_names, out.table_id); - let xml = table_xml(t, cols, &name, out.table_id, out); - out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes()); - let rid = rels.add("table", &format!("../tables/table{n}.xml")); - let _ = write!(parts, ""); - } - let _ = write!(s, "{parts}", tables.len()); - } - // Excel 2010 extensions: solid data bars, then sparklines. - let solid_bars: Vec<(&CondFormat, u32)> = sheet - .cond_formats - .iter() - .enumerate() - .filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. })) - .map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1))) - .collect(); - if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { - s.push_str(""); - } - if !solid_bars.is_empty() { - let _ = write!( - s, - "" - ); - for (cf, p) in &solid_bars { - if let CfRule::DataBar { min, max, .. } = &cf.rule { - let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max)); - let _ = write!(s, "{lo}{hi}"); - let _ = write!(s, "{}", sqref(&cf.ranges)); - } - } - s.push_str(""); - } - if !sheet.sparklines.is_empty() { - let _ = write!(s, ""); - for sp in &sheet.sparklines { - let ty = match sp.kind { - SparklineKind::Line => "", - SparklineKind::Column => " type=\"column\"", - SparklineKind::WinLoss => " type=\"stacked\"", - }; - let src = - if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) }; - let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) }; - let _ = write!( - s, - "{}{}", - if sp.markers { " markers=\"1\"" } else { "" }, - esc(&src), - sp.cell.a1() - ); - } - s.push_str(""); - } - if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { - s.push_str(""); - } - s.push_str(""); - (s, rels) + prio } -fn write_cell( - s: &mut String, - c: CellRef, - cell: Option<&Cell>, - picture: Option<&std::sync::Arc>, - ov: Option<&String>, - extra: Option<&Value>, - dynamic: Option<&RangeRef>, - sid: &dyn Fn(gridcraft_model::StyleId) -> u32, - out: &mut Out, -) { +struct SheetCellData<'a> { + workbook: &'a Workbook, + index: usize, + sheet: &'a Sheet, + overrides: &'a BTreeMap, + extra: &'a BTreeMap, + dynamic: &'a BTreeMap, +} + +fn write_sheet_data(s: &mut String, data: SheetCellData<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) { + let SheetCellData { workbook, index, sheet, overrides, extra, dynamic } = data; + // Cell storage is already row-major; sort only when extra cells need merging. + let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len()); + keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell)))); + if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) { + let mut all: BTreeSet = keys.iter().map(|(c, _)| *c).collect(); + all.extend(sheet.cell_pictures.keys().copied()); + all.extend(extra.keys().copied()); + all.extend(overrides.keys().copied()); + keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect(); + } + s.reserve(keys.len().saturating_mul(32)); + s.push_str(""); + let mut row_infos = sheet.rows.iter().peekable(); + let mut next = 0; + loop { + let cell_row = keys.get(next).map(|(cell, _)| cell.row); + let info_row = row_infos.peek().map(|(row, _)| **row); + let row = match (cell_row, info_row) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + let info = match row_infos.next_if(|(entry_row, _)| **entry_row == row) { + Some((_, info)) => *info, + None => LineInfo::default(), + }; + let first = next; + while keys.get(next).is_some_and(|(cell, _)| cell.row == row) { + next += 1; + } + let cells = keys.get(first..next).unwrap_or_default(); + if cells.is_empty() && info == LineInfo::default() { + continue; + } + s.push_str(""); + } + s.push_str(""); +} + +fn write_sheet_views(s: &mut String, sheet: &Sheet, selected: bool) { + s.push_str(""); + let active = sheet.view_active.a1(); + match sheet.freeze { + Some((rows, cols)) if rows > 0 || cols > 0 => { + let pane = match (rows > 0, cols > 0) { + (true, true) => "bottomRight", + (true, false) => "bottomLeft", + _ => "topRight", + }; + s.push_str(" 0 { + let _ = write!(s, " xSplit=\"{cols}\""); + } + if rows > 0 { + let _ = write!(s, " ySplit=\"{rows}\""); + } + let _ = write!( + s, + " topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>", + CellRef::new( + rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1), + cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1) + ) + .a1() + ); + let _ = write!(s, ""); + } + _ => { + let _ = write!(s, ""); + } + } + s.push_str(""); +} + +fn write_cell(s: &mut String, input: CellWriteInput<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) { + let CellWriteInput { reference: c, cell, picture, header_override: ov, spill_value: extra, dynamic_range: dynamic } = input; let style = cell.map(|x| sid(x.style)).unwrap_or(0); - let _ = write!(s, " String { - match v { - CfValueKind::Min => "".into(), - CfValueKind::Max => "".into(), - CfValueKind::Number(n) => format!("", num(*n)), - CfValueKind::Percent(n) => format!("", num(*n)), - CfValueKind::Percentile(n) => format!("", num(*n)), - CfValueKind::Formula(f) => format!("", esc_attr(&text_to_file(f))), - } -} - -fn quote(t: &str) -> String { - format!("\"{}\"", t.replace('"', "\"\"")) -} - const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; -/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. -fn solid_bar_id(priority: u32) -> String { - format!("{{00000000-0000-0000-0000-{priority:012}}}") -} - -/// The `cfRule` extension that points a solid data bar at its x14 rule. -fn solid_bar_ext(priority: u32) -> String { - let id = solid_bar_id(priority); - format!("{id}") -} - -/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. -fn x14_cfvo(v: &CfValueKind) -> String { - let (ty, val) = match v { - CfValueKind::Min => return "".into(), - CfValueKind::Max => return "".into(), - CfValueKind::Number(n) => ("num", num(*n)), - CfValueKind::Percent(n) => ("percent", num(*n)), - CfValueKind::Percentile(n) => ("percentile", num(*n)), - CfValueKind::Formula(f) => ("formula", text_to_file(f)), - }; - format!("{}", esc(&val)) -} - -fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { - let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); - let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; - let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); - let f = |x: &str| format!("{}", esc(&text_to_file(x))); - let simple = |ty: &str, st: &Style, extra: &str, body: String, dxf: &mut dyn FnMut(&Style) -> u32| { - format!("{body}", dxf(st)) - }; - match &cf.rule { - CfRule::CellIs { op, a, b, style } => { - let mut body = f(a); - if let Some(b) = b { - body.push_str(&f(b)); - } - simple("cellIs", style, &format!(" operator=\"{}\"", cf_op_name(*op)), body, &mut dxf) - } - CfRule::Expression { formula, style } => simple("expression", style, "", f(formula), &mut dxf), - CfRule::ContainsText { text, style } => simple( - "containsText", - style, - &format!(" operator=\"containsText\" text=\"{}\"", esc_attr(text)), - f(&format!("NOT(ISERROR(SEARCH({},{tl})))", quote(text))), - &mut dxf, - ), - CfRule::NotContainsText { text, style } => simple( - "notContainsText", - style, - &format!(" operator=\"notContains\" text=\"{}\"", esc_attr(text)), - f(&format!("ISERROR(SEARCH({},{tl}))", quote(text))), - &mut dxf, - ), - CfRule::BeginsWith { text, style } => simple( - "beginsWith", - style, - &format!(" operator=\"beginsWith\" text=\"{}\"", esc_attr(text)), - f(&format!("LEFT({tl},LEN({0}))={0}", quote(text))), - &mut dxf, - ), - CfRule::EndsWith { text, style } => simple( - "endsWith", - style, - &format!(" operator=\"endsWith\" text=\"{}\"", esc_attr(text)), - f(&format!("RIGHT({tl},LEN({0}))={0}", quote(text))), - &mut dxf, - ), - CfRule::Blanks { style } => simple("containsBlanks", style, "", f(&format!("LEN(TRIM({tl}))=0")), &mut dxf), - CfRule::NoBlanks { style } => simple("notContainsBlanks", style, "", f(&format!("LEN(TRIM({tl}))>0")), &mut dxf), - CfRule::Errors { style } => simple("containsErrors", style, "", f(&format!("ISERROR({tl})")), &mut dxf), - CfRule::NoErrors { style } => simple("notContainsErrors", style, "", f(&format!("NOT(ISERROR({tl}))")), &mut dxf), - CfRule::Duplicate { style } => simple("duplicateValues", style, "", String::new(), &mut dxf), - CfRule::Unique { style } => simple("uniqueValues", style, "", String::new(), &mut dxf), - CfRule::Top10 { bottom, percent, rank, style } => { - let extra = format!("{}{} rank=\"{}\"", if *percent { " percent=\"1\"" } else { "" }, if *bottom { " bottom=\"1\"" } else { "" }, rank); - simple("top10", style, &extra, String::new(), &mut dxf) - } - CfRule::AboveAverage { below, equal, std_dev, style } => { - let mut extra = String::new(); - if *below { - extra.push_str(" aboveAverage=\"0\""); - } - if *equal { - extra.push_str(" equalAverage=\"1\""); - } - if *std_dev > 0 { - let _ = write!(extra, " stdDev=\"{std_dev}\""); - } - simple("aboveAverage", style, &extra, String::new(), &mut dxf) - } - CfRule::TimePeriod { period, style } => { - let d = format!("FLOOR({tl},1)"); - let body = match period.as_str() { - "yesterday" => format!("{d}=TODAY()-1"), - "tomorrow" => format!("{d}=TODAY()+1"), - "last7Days" => format!("AND(TODAY()-{d}<=6,{d}<=TODAY())"), - "thisMonth" => format!("AND(MONTH({tl})=MONTH(TODAY()),YEAR({tl})=YEAR(TODAY()))"), - "lastMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0-1)),YEAR({tl})=YEAR(EDATE(TODAY(),0-1)))"), - "nextMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0+1)),YEAR({tl})=YEAR(EDATE(TODAY(),0+1)))"), - "thisWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)<=WEEKDAY(TODAY())-1,ROUNDDOWN({tl},0)-TODAY()<=7-WEEKDAY(TODAY()))"), - "lastWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN({tl},0)<(WEEKDAY(TODAY())+7))"), - "nextWeek" => format!("AND(ROUNDDOWN({tl},0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN({tl},0)-TODAY()<(15-WEEKDAY(TODAY())))"), - _ => format!("{d}=TODAY()"), - }; - simple("timePeriod", style, &format!(" timePeriod=\"{}\"", esc_attr(period)), f(&body), &mut dxf) - } - CfRule::ColorScale { stops } => { - let mut b = String::from(""); - for (v, _) in stops { - b.push_str(&cfvo_xml(v)); - } - for (_, c) in stops { - let _ = write!(b, "", color_attrs(c)); - } - b.push_str(""); - format!("{b}") - } - CfRule::DataBar { min, max, color, show_value, gradient } => format!( - "{}{}{}", - if *show_value { "" } else { " showValue=\"0\"" }, - cfvo_xml(min), - cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, - if *gradient { String::new() } else { solid_bar_ext(priority) } - ), - CfRule::IconSet { set, thresholds, reverse, show_value } => { - let mut b = format!("'); - // CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound. - let k = icon_count(set); - let defaults = default_icon_thresholds(set); - let cfvos: Vec<&CfValueKind> = match thresholds.len() { - n if n == k => thresholds.iter().collect(), - n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(), - _ => defaults.iter().collect(), - }; - for t in cfvos { - b.push_str(&cfvo_xml(t)); - } - b.push_str(""); - format!("{b}") - } - } -} - -fn validation_formula(f: &str) -> String { - let f = f.trim(); - if f.starts_with('"') { - return esc(f); - } - esc(&text_to_file(f.strip_prefix('=').unwrap_or(f))) -} - -fn validation_xml(v: &Validation) -> String { - let ty = match v.kind { - ValidationKind::Any => "none", - ValidationKind::Whole => "whole", - ValidationKind::Decimal => "decimal", - ValidationKind::List => "list", - ValidationKind::Date => "date", - ValidationKind::Time => "time", - ValidationKind::TextLength => "textLength", - ValidationKind::Custom => "custom", - }; - let mut s = format!(" {} - ErrorStyle::Warning => s.push_str(" errorStyle=\"warning\""), - ErrorStyle::Information => s.push_str(" errorStyle=\"information\""), - } - if v.op != CfOperator::Between && !matches!(v.kind, ValidationKind::List | ValidationKind::Custom | ValidationKind::Any) { - let _ = write!(s, " operator=\"{}\"", cf_op_name(v.op)); - } - if v.allow_blank { - s.push_str(" allowBlank=\"1\""); - } - if !v.in_cell_dropdown { - s.push_str(" showDropDown=\"1\""); - } - if v.show_input { - s.push_str(" showInputMessage=\"1\""); - } - if v.show_error { - s.push_str(" showErrorMessage=\"1\""); - } - for (a, t) in [("errorTitle", &v.error_title), ("error", &v.error_message), ("promptTitle", &v.input_title), ("prompt", &v.input_message)] { - if !t.is_empty() { - let _ = write!(s, " {a}=\"{}\"", esc_attr(t)); - } - } - let _ = write!(s, " sqref=\"{}\">", sqref(&v.ranges)); - if !v.f1.is_empty() { - let _ = write!(s, "{}", validation_formula(&v.f1)); - } - if let Some(f2) = v.f2.as_ref().filter(|f| !f.is_empty()) { - let _ = write!(s, "{}", validation_formula(f2)); - } - s.push_str(""); - s -} - -fn filter_operator(op: &str) -> &'static str { - match op.trim() { - "<" => " operator=\"lessThan\"", - "<=" => " operator=\"lessThanOrEqual\"", - "<>" => " operator=\"notEqual\"", - ">=" => " operator=\"greaterThanOrEqual\"", - ">" => " operator=\"greaterThan\"", - _ => "", - } -} - -fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String { - let mut s = format!(""); - return s; - } - s.push('>'); - for (col, c) in criteria { - let _ = write!(s, ""); - match c { - FilterCriterion::Values { values, blanks } => { - let _ = write!(s, "", if *blanks { " blank=\"1\"" } else { "" }); - for v in values { - let _ = write!(s, "", esc_attr(v)); - } - s.push_str(""); - } - FilterCriterion::Custom { a, b, and } => { - let _ = write!(s, "", if *and { " and=\"1\"" } else { "" }); - for (op, v) in std::iter::once(a).chain(b.iter()) { - let _ = write!(s, "", filter_operator(op), esc_attr(v)); - } - s.push_str(""); - } - FilterCriterion::Top10 { bottom, percent, count } => { - let _ = - write!(s, "", if *bottom { " top=\"0\"" } else { "" }, if *percent { " percent=\"1\"" } else { "" }); - } - FilterCriterion::AboveAverage(above) => { - let _ = write!(s, "", if *above { "aboveAverage" } else { "belowAverage" }); - } - FilterCriterion::FillColor(c) => { - let st = Style { fill: gridcraft_model::Fill::solid(*c), ..Default::default() }; - let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); - } - FilterCriterion::FontColor(c) => { - let mut st = Style::default(); - st.font.color = *c; - let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); - } - FilterCriterion::Icon { set, id } => { - let _ = write!(s, ""); - } - } - s.push_str(""); - } - s.push_str(""); - s -} - -fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String { - let mut s = - format!("{XML_DECL}
'); - if t.header_row && t.filter_button { - let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; - let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col)); - s.push_str(&autofilter_xml(r, &t.filter, out)); - } - let _ = write!(s, "", cols.len()); - for (i, name) in cols.iter().enumerate() { - let col = t.columns.get(i); - let _ = write!(s, " None, - TotalsFn::Average => Some("average"), - TotalsFn::Count => Some("count"), - TotalsFn::CountNums => Some("countNums"), - TotalsFn::Max => Some("max"), - TotalsFn::Min => Some("min"), - TotalsFn::Sum => Some("sum"), - TotalsFn::StdDev => Some("stdDev"), - TotalsFn::Var => Some("var"), - TotalsFn::Custom => Some("custom"), - }; - if let Some(f) = f { - let _ = write!(s, " totalsRowFunction=\"{f}\""); - } else if let Some(l) = col.and_then(|c| c.totals_label.as_ref()) { - let _ = write!(s, " totalsRowLabel=\"{}\"", esc_attr(l)); - } - } - let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); - let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; - let xml = col.and_then(|c| c.xml.as_ref()); - if calc.is_none() && custom.is_none() && xml.is_none() { - s.push_str("/>"); - continue; - } - s.push('>'); - if let Some(f) = calc { - let _ = write!(s, "{}", esc(&text_to_file(f))); - } - if let Some(f) = custom { - let _ = write!(s, "{}", esc(&text_to_file(f))); - } - if let Some(x) = xml { - let _ = write!(s, "", esc_attr(&x.data_type)); - } - s.push_str(""); - } - s.push_str(""); - let style = if t.style.is_empty() { "TableStyleMedium2" } else { &t.style }; - let _ = write!( - s, - "
", - esc_attr(style), - t.first_col as u8, - t.last_col as u8, - t.banded_rows as u8, - t.banded_cols as u8 - ); - s -} - -/// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped -/// and deterministic; they only need to be unique within the file. -fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap> { - let mut seq: u64 = 0; - let mut ids = BTreeMap::new(); - for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { - let v: Vec = (0..=c.replies.len()) - .map(|_| { - seq += 1; - format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF) - }) - .collect(); - ids.insert(*cell, v); - } - ids -} - -fn comments_xml(sheet: &Sheet, ids: &BTreeMap>) -> String { - // A threaded comment's legacy note is authored by `tc={thread id}`. - let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) { - Some(id) => format!("tc={id}"), - None => c.author.clone(), - }; - let mut authors: Vec = vec![]; - for (cell, c) in &sheet.comments { - let a = author_of(cell, c); - if !authors.contains(&a) { - authors.push(a); - } - } - let mut s = if ids.is_empty() { - format!("{XML_DECL}") - } else { - format!( - "{XML_DECL}" - ) - }; - for a in &authors { - let _ = write!(s, "{}", esc(a)); - } - s.push_str(""); - for (cell, c) in &sheet.comments { - let a = author_of(cell, c); - let aid = authors.iter().position(|x| *x == a).unwrap_or(0); - let mut text = c.text.clone(); - // Threaded replies are kept as text lines. - for (who, reply) in &c.replies { - let _ = write!(text, "\n{who}: {reply}"); - } - let link = match ids.get(cell).and_then(|v| v.first()) { - Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""), - None => String::new(), - }; - let _ = write!(s, "{}", cell.a1(), crate::xml::t_el(&text)); - } - s.push_str(""); - s -} - -/// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts. -/// Anything else is left out rather than written into the file. -fn iso_datetime(d: &str) -> bool { - let b = d.as_bytes(); - let digits = |r: std::ops::Range| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit)); - d.len() <= 40 - && digits(0..4) - && b.get(4) == Some(&b'-') - && digits(5..7) - && b.get(7) == Some(&b'-') - && digits(8..10) - && b.get(10) == Some(&b'T') - && digits(11..13) - && b.get(13) == Some(&b':') - && digits(14..16) - && b.get(16) == Some(&b':') - && digits(17..19) - && b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c))) -} - -/// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`). -fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &mut Vec) -> String { - let mut person = |name: &str| { - let i = persons.iter().position(|p| p == name).unwrap_or_else(|| { - persons.push(name.to_string()); - persons.len() - 1 - }); - crate::write::person_guid(i) - }; - let mut s = format!("{XML_DECL}"); - for (cell, id) in ids { - let Some(c) = sheet.comments.get(cell) else { continue }; - let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t))); - for (k, ((author, text), own)) in entries.zip(id).enumerate() { - let date = match c.dates.get(k).filter(|d| iso_datetime(d)) { - Some(d) => format!(" dT=\"{}\"", esc_attr(d)), - None => String::new(), - }; - let tail = match (k, id.first()) { - (0, _) if c.resolved => " done=\"1\"".to_string(), - (0, _) | (_, None) => String::new(), - (_, Some(parent)) => format!(" parentId=\"{parent}\""), - }; - let _ = write!( - s, - "{}", - cell.a1(), - person(author), - esc(text) - ); - } - } - s.push_str(""); - s -} - -/// A protection password hash as attributes, each name preceded by `prefix` (see -/// [`crate::sheet_read::read_password`]). -pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { - let name = |base: &str| crate::sheet_read::password_attr(prefix, base); - match h { - gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), - gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( - " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", - name("algorithmName"), - esc_attr(algorithm), - name("hashValue"), - esc_attr(hash), - name("saltValue"), - esc_attr(salt), - name("spinCount") - ), - } -} - -/// Allow Edit Ranges: `` (after `sheetProtection`). -fn protected_ranges_xml(sheet: &Sheet) -> String { - let mut s = String::new(); - for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { - let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); - let _ = write!(s, ""); - } - if s.is_empty() { s } else { format!("{s}") } -} - /// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt. /// A box read from a file that equals it is kept as `None`, so a note's model survives a save. pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox { @@ -1322,36 +825,3 @@ pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox { size_pt: Some(DEFAULT_NOTE_SIZE_PT), }; const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25); - -/// A note box's `` text and its width and height in points: the box read from the file -/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet. -fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { - let b = b.unwrap_or(&DEFAULT_NOTE_BOX); - let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1); - let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px); - (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) -} - -/// Legacy VML shapes Excel uses to display notes. -fn vml_xml(sheet: &Sheet, n: u32) -> String { - let mut s = format!( - "" - ); - for (i, (cell, c)) in sheet.comments.iter().enumerate() { - let id = n as u64 * 1024 + 1 + i as u64; - let vis = if c.visible { "visible" } else { "hidden" }; - let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); - let _ = write!( - s, - "
{anchor}False{}{}{}
", - num(w), - num(h), - i + 1, - cell.row, - cell.col, - if c.visible { "" } else { "" } - ); - } - s.push_str("
"); - s -} diff --git a/crates/xlsx/src/sheet_write/parts.rs b/crates/xlsx/src/sheet_write/parts.rs new file mode 100644 index 000000000..2e40d0b26 --- /dev/null +++ b/crates/xlsx/src/sheet_write/parts.rs @@ -0,0 +1,373 @@ +use super::*; + +/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` +/// (Microsoft "Names in formulas" rules). +pub(super) fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { + let mut s: String = + name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { + s = format!("Table{n}"); + } + let mut out = s.clone(); + let mut k = 2; + while !used.insert(out.to_ascii_lowercase()) { + out = format!("{s}{k}"); + k += 1; + } + out +} + +/// Column names for a table: one per column of its range, unique and non-empty. +pub(super) fn table_columns(t: &Table) -> Vec { + let w = t.range.width() as usize; + let mut used = HashSet::new(); + (0..w) + .map(|i| { + let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); + let mut name = base.clone(); + let mut k = 2; + while !used.insert(name.to_lowercase()) { + name = format!("{base}{k}"); + k += 1; + } + name + }) + .collect() +} + +fn filter_operator(op: &str) -> &'static str { + match op.trim() { + "<" => " operator=\"lessThan\"", + "<=" => " operator=\"lessThanOrEqual\"", + "<>" => " operator=\"notEqual\"", + ">=" => " operator=\"greaterThanOrEqual\"", + ">" => " operator=\"greaterThan\"", + _ => "", + } +} + +pub(super) fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String { + let mut s = format!(""); + return s; + } + s.push('>'); + for (col, c) in criteria { + let _ = write!(s, ""); + match c { + FilterCriterion::Values { values, blanks } => { + let _ = write!(s, "", if *blanks { " blank=\"1\"" } else { "" }); + for v in values { + let _ = write!(s, "", esc_attr(v)); + } + s.push_str(""); + } + FilterCriterion::Custom { a, b, and } => { + let _ = write!(s, "", if *and { " and=\"1\"" } else { "" }); + for (op, v) in std::iter::once(a).chain(b.iter()) { + let _ = write!(s, "", filter_operator(op), esc_attr(v)); + } + s.push_str(""); + } + FilterCriterion::Top10 { bottom, percent, count } => { + let _ = + write!(s, "", if *bottom { " top=\"0\"" } else { "" }, if *percent { " percent=\"1\"" } else { "" }); + } + FilterCriterion::AboveAverage(above) => { + let _ = write!(s, "", if *above { "aboveAverage" } else { "belowAverage" }); + } + FilterCriterion::FillColor(c) => { + let st = Style { fill: gridcraft_model::Fill::solid(*c), ..Default::default() }; + let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); + } + FilterCriterion::FontColor(c) => { + let mut st = Style::default(); + st.font.color = *c; + let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); + } + FilterCriterion::Icon { set, id } => { + let _ = write!(s, ""); + } + } + s.push_str(""); + } + s.push_str(""); + s +} + +pub(super) fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String { + let mut s = + format!("{XML_DECL}'); + if t.header_row && t.filter_button { + let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; + let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col)); + s.push_str(&autofilter_xml(r, &t.filter, out)); + } + let _ = write!(s, "", cols.len()); + for (i, name) in cols.iter().enumerate() { + let col = t.columns.get(i); + let _ = write!(s, " None, + TotalsFn::Average => Some("average"), + TotalsFn::Count => Some("count"), + TotalsFn::CountNums => Some("countNums"), + TotalsFn::Max => Some("max"), + TotalsFn::Min => Some("min"), + TotalsFn::Sum => Some("sum"), + TotalsFn::StdDev => Some("stdDev"), + TotalsFn::Var => Some("var"), + TotalsFn::Custom => Some("custom"), + }; + if let Some(f) = f { + let _ = write!(s, " totalsRowFunction=\"{f}\""); + } else if let Some(l) = col.and_then(|c| c.totals_label.as_ref()) { + let _ = write!(s, " totalsRowLabel=\"{}\"", esc_attr(l)); + } + } + let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); + let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; + let xml = col.and_then(|c| c.xml.as_ref()); + if calc.is_none() && custom.is_none() && xml.is_none() { + s.push_str("/>"); + continue; + } + s.push('>'); + if let Some(f) = calc { + let _ = write!(s, "{}", esc(&text_to_file(f))); + } + if let Some(f) = custom { + let _ = write!(s, "{}", esc(&text_to_file(f))); + } + if let Some(x) = xml { + let _ = write!(s, "", esc_attr(&x.data_type)); + } + s.push_str(""); + } + s.push_str(""); + let style = if t.style.is_empty() { "TableStyleMedium2" } else { &t.style }; + let _ = write!( + s, + "
", + esc_attr(style), + t.first_col as u8, + t.last_col as u8, + t.banded_rows as u8, + t.banded_cols as u8 + ); + s +} + +/// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped +/// and deterministic; they only need to be unique within the file. +pub(super) fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap> { + let mut seq: u64 = 0; + let mut ids = BTreeMap::new(); + for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { + let v: Vec = (0..=c.replies.len()) + .map(|_| { + seq += 1; + format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF) + }) + .collect(); + ids.insert(*cell, v); + } + ids +} + +pub(super) fn comments_xml(sheet: &Sheet, ids: &BTreeMap>) -> String { + // A threaded comment's legacy note is authored by `tc={thread id}`. + let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) { + Some(id) => format!("tc={id}"), + None => c.author.clone(), + }; + let mut authors: Vec = vec![]; + for (cell, c) in &sheet.comments { + let a = author_of(cell, c); + if !authors.contains(&a) { + authors.push(a); + } + } + let mut s = if ids.is_empty() { + format!("{XML_DECL}") + } else { + format!( + "{XML_DECL}" + ) + }; + for a in &authors { + let _ = write!(s, "{}", esc(a)); + } + s.push_str(""); + for (cell, c) in &sheet.comments { + let a = author_of(cell, c); + let aid = authors.iter().position(|x| *x == a).unwrap_or(0); + let mut text = c.text.clone(); + // Threaded replies are kept as text lines. + for (who, reply) in &c.replies { + let _ = write!(text, "\n{who}: {reply}"); + } + let link = match ids.get(cell).and_then(|v| v.first()) { + Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""), + None => String::new(), + }; + let _ = write!(s, "{}", cell.a1(), crate::xml::t_el(&text)); + } + s.push_str(""); + s +} + +/// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts. +/// Anything else is left out rather than written into the file. +fn iso_datetime(d: &str) -> bool { + let b = d.as_bytes(); + let digits = |r: std::ops::Range| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit)); + d.len() <= 40 + && digits(0..4) + && b.get(4) == Some(&b'-') + && digits(5..7) + && b.get(7) == Some(&b'-') + && digits(8..10) + && b.get(10) == Some(&b'T') + && digits(11..13) + && b.get(13) == Some(&b':') + && digits(14..16) + && b.get(16) == Some(&b':') + && digits(17..19) + && b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c))) +} + +/// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`). +pub(super) fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &mut Vec) -> String { + let mut person = |name: &str| { + let i = persons.iter().position(|p| p == name).unwrap_or_else(|| { + persons.push(name.to_string()); + persons.len() - 1 + }); + crate::write::person_guid(i) + }; + let mut s = format!("{XML_DECL}"); + for (cell, id) in ids { + let Some(c) = sheet.comments.get(cell) else { continue }; + let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t))); + for (k, ((author, text), own)) in entries.zip(id).enumerate() { + let date = match c.dates.get(k).filter(|d| iso_datetime(d)) { + Some(d) => format!(" dT=\"{}\"", esc_attr(d)), + None => String::new(), + }; + let tail = match (k, id.first()) { + (0, _) if c.resolved => " done=\"1\"".to_string(), + (0, _) | (_, None) => String::new(), + (_, Some(parent)) => format!(" parentId=\"{parent}\""), + }; + let _ = write!( + s, + "{}", + cell.a1(), + person(author), + esc(text) + ); + } + } + s.push_str(""); + s +} + +/// A protection password hash as attributes, each name preceded by `prefix` (see +/// [`crate::sheet_read::read_password`]). +pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { + let name = |base: &str| crate::sheet_read::password_attr(prefix, base); + match h { + gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), + gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( + " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", + name("algorithmName"), + esc_attr(algorithm), + name("hashValue"), + esc_attr(hash), + name("saltValue"), + esc_attr(salt), + name("spinCount") + ), + } +} + +/// Allow Edit Ranges: `` (after `sheetProtection`). +pub(super) fn protected_ranges_xml(sheet: &Sheet) -> String { + let mut s = String::new(); + for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { + let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); + let _ = write!(s, ""); + } + if s.is_empty() { s } else { format!("{s}") } +} + +/// A note box's `` text and its width and height in points: the box read from the file +/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet. +fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { + let b = b.unwrap_or(&DEFAULT_NOTE_BOX); + let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1); + let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px); + (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) +} + +/// Legacy VML shapes Excel uses to display notes. +pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { + let mut s = format!( + "" + ); + for (i, (cell, c)) in sheet.comments.iter().enumerate() { + let id = n as u64 * 1024 + 1 + i as u64; + let vis = if c.visible { "visible" } else { "hidden" }; + let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); + let _ = write!( + s, + "
{anchor}False{}{}{}
", + num(w), + num(h), + i + 1, + cell.row, + cell.col, + if c.visible { "" } else { "" } + ); + } + s.push_str("
"); + s +} diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs new file mode 100644 index 000000000..821ec4cef --- /dev/null +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -0,0 +1,238 @@ +use super::*; + +fn cf_op_name(op: CfOperator) -> &'static str { + match op { + CfOperator::Between => "between", + CfOperator::NotBetween => "notBetween", + CfOperator::Equal => "equal", + CfOperator::NotEqual => "notEqual", + CfOperator::Greater => "greaterThan", + CfOperator::Less => "lessThan", + CfOperator::GreaterOrEqual => "greaterThanOrEqual", + CfOperator::LessOrEqual => "lessThanOrEqual", + } +} + +fn cfvo_xml(v: &CfValueKind) -> String { + match v { + CfValueKind::Min => "".into(), + CfValueKind::Max => "".into(), + CfValueKind::Number(n) => format!("", num(*n)), + CfValueKind::Percent(n) => format!("", num(*n)), + CfValueKind::Percentile(n) => format!("", num(*n)), + CfValueKind::Formula(f) => format!("", esc_attr(&text_to_file(f))), + } +} + +fn quote(t: &str) -> String { + format!("\"{}\"", t.replace('"', "\"\"")) +} + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +pub(super) fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +pub(super) fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + +pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { + let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); + let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; + let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); + let f = |x: &str| format!("{}", esc(&text_to_file(x))); + let simple = |ty: &str, st: &Style, extra: &str, body: String, dxf: &mut dyn FnMut(&Style) -> u32| { + format!("{body}", dxf(st)) + }; + match &cf.rule { + CfRule::CellIs { op, a, b, style } => { + let mut body = f(a); + if let Some(b) = b { + body.push_str(&f(b)); + } + simple("cellIs", style, &format!(" operator=\"{}\"", cf_op_name(*op)), body, &mut dxf) + } + CfRule::Expression { formula, style } => simple("expression", style, "", f(formula), &mut dxf), + CfRule::ContainsText { text, style } => simple( + "containsText", + style, + &format!(" operator=\"containsText\" text=\"{}\"", esc_attr(text)), + f(&format!("NOT(ISERROR(SEARCH({},{tl})))", quote(text))), + &mut dxf, + ), + CfRule::NotContainsText { text, style } => simple( + "notContainsText", + style, + &format!(" operator=\"notContains\" text=\"{}\"", esc_attr(text)), + f(&format!("ISERROR(SEARCH({},{tl}))", quote(text))), + &mut dxf, + ), + CfRule::BeginsWith { text, style } => simple( + "beginsWith", + style, + &format!(" operator=\"beginsWith\" text=\"{}\"", esc_attr(text)), + f(&format!("LEFT({tl},LEN({0}))={0}", quote(text))), + &mut dxf, + ), + CfRule::EndsWith { text, style } => simple( + "endsWith", + style, + &format!(" operator=\"endsWith\" text=\"{}\"", esc_attr(text)), + f(&format!("RIGHT({tl},LEN({0}))={0}", quote(text))), + &mut dxf, + ), + CfRule::Blanks { style } => simple("containsBlanks", style, "", f(&format!("LEN(TRIM({tl}))=0")), &mut dxf), + CfRule::NoBlanks { style } => simple("notContainsBlanks", style, "", f(&format!("LEN(TRIM({tl}))>0")), &mut dxf), + CfRule::Errors { style } => simple("containsErrors", style, "", f(&format!("ISERROR({tl})")), &mut dxf), + CfRule::NoErrors { style } => simple("notContainsErrors", style, "", f(&format!("NOT(ISERROR({tl}))")), &mut dxf), + CfRule::Duplicate { style } => simple("duplicateValues", style, "", String::new(), &mut dxf), + CfRule::Unique { style } => simple("uniqueValues", style, "", String::new(), &mut dxf), + CfRule::Top10 { bottom, percent, rank, style } => { + let extra = format!("{}{} rank=\"{}\"", if *percent { " percent=\"1\"" } else { "" }, if *bottom { " bottom=\"1\"" } else { "" }, rank); + simple("top10", style, &extra, String::new(), &mut dxf) + } + CfRule::AboveAverage { below, equal, std_dev, style } => { + let mut extra = String::new(); + if *below { + extra.push_str(" aboveAverage=\"0\""); + } + if *equal { + extra.push_str(" equalAverage=\"1\""); + } + if *std_dev > 0 { + let _ = write!(extra, " stdDev=\"{std_dev}\""); + } + simple("aboveAverage", style, &extra, String::new(), &mut dxf) + } + CfRule::TimePeriod { period, style } => { + let d = format!("FLOOR({tl},1)"); + let body = match period.as_str() { + "yesterday" => format!("{d}=TODAY()-1"), + "tomorrow" => format!("{d}=TODAY()+1"), + "last7Days" => format!("AND(TODAY()-{d}<=6,{d}<=TODAY())"), + "thisMonth" => format!("AND(MONTH({tl})=MONTH(TODAY()),YEAR({tl})=YEAR(TODAY()))"), + "lastMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0-1)),YEAR({tl})=YEAR(EDATE(TODAY(),0-1)))"), + "nextMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0+1)),YEAR({tl})=YEAR(EDATE(TODAY(),0+1)))"), + "thisWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)<=WEEKDAY(TODAY())-1,ROUNDDOWN({tl},0)-TODAY()<=7-WEEKDAY(TODAY()))"), + "lastWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN({tl},0)<(WEEKDAY(TODAY())+7))"), + "nextWeek" => format!("AND(ROUNDDOWN({tl},0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN({tl},0)-TODAY()<(15-WEEKDAY(TODAY())))"), + _ => format!("{d}=TODAY()"), + }; + simple("timePeriod", style, &format!(" timePeriod=\"{}\"", esc_attr(period)), f(&body), &mut dxf) + } + CfRule::ColorScale { stops } => { + let mut b = String::from(""); + for (v, _) in stops { + b.push_str(&cfvo_xml(v)); + } + for (_, c) in stops { + let _ = write!(b, "", color_attrs(c)); + } + b.push_str(""); + format!("{b}") + } + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", + if *show_value { "" } else { " showValue=\"0\"" }, + cfvo_xml(min), + cfvo_xml(max), + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } + ), + CfRule::IconSet { set, thresholds, reverse, show_value } => { + let mut b = format!("'); + // CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound. + let k = icon_count(set); + let defaults = default_icon_thresholds(set); + let cfvos: Vec<&CfValueKind> = match thresholds.len() { + n if n == k => thresholds.iter().collect(), + n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(), + _ => defaults.iter().collect(), + }; + for t in cfvos { + b.push_str(&cfvo_xml(t)); + } + b.push_str(""); + format!("{b}") + } + } +} + +fn validation_formula(f: &str) -> String { + let f = f.trim(); + if f.starts_with('"') { + return esc(f); + } + esc(&text_to_file(f.strip_prefix('=').unwrap_or(f))) +} + +pub(super) fn validation_xml(v: &Validation) -> String { + let ty = match v.kind { + ValidationKind::Any => "none", + ValidationKind::Whole => "whole", + ValidationKind::Decimal => "decimal", + ValidationKind::List => "list", + ValidationKind::Date => "date", + ValidationKind::Time => "time", + ValidationKind::TextLength => "textLength", + ValidationKind::Custom => "custom", + }; + let mut s = format!(" {} + ErrorStyle::Warning => s.push_str(" errorStyle=\"warning\""), + ErrorStyle::Information => s.push_str(" errorStyle=\"information\""), + } + if v.op != CfOperator::Between && !matches!(v.kind, ValidationKind::List | ValidationKind::Custom | ValidationKind::Any) { + let _ = write!(s, " operator=\"{}\"", cf_op_name(v.op)); + } + if v.allow_blank { + s.push_str(" allowBlank=\"1\""); + } + if !v.in_cell_dropdown { + s.push_str(" showDropDown=\"1\""); + } + if v.show_input { + s.push_str(" showInputMessage=\"1\""); + } + if v.show_error { + s.push_str(" showErrorMessage=\"1\""); + } + for (a, t) in [("errorTitle", &v.error_title), ("error", &v.error_message), ("promptTitle", &v.input_title), ("prompt", &v.input_message)] { + if !t.is_empty() { + let _ = write!(s, " {a}=\"{}\"", esc_attr(t)); + } + } + let _ = write!(s, " sqref=\"{}\">", sqref(&v.ranges)); + if !v.f1.is_empty() { + let _ = write!(s, "{}", validation_formula(&v.f1)); + } + if let Some(f2) = v.f2.as_ref().filter(|f| !f.is_empty()) { + let _ = write!(s, "{}", validation_formula(f2)); + } + s.push_str(""); + s +} 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/testing.rs b/crates/xlsx/src/testing.rs index 5729250bb..a82283644 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -21,7 +21,13 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, const SECRET_KEY: [u8; 32] = [64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]; const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]; -const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127]; +/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the +/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes. +const HMAC_KEY: [u8; 64] = [ + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, + 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, + 170, 171, 172, 173, 174, 175, +]; /// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption /// (AES-256, SHA-512) inside a compound file. @@ -237,6 +243,11 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec { /// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the /// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it. pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { + encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY) +} + +/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`. +pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec, Vec) { let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; let mut body = vec![]; for (i, segment) in plain.chunks(SEGMENT).enumerate() { @@ -244,8 +255,8 @@ pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec } let stream = package(plain.len(), body); let (hmac_key_b64, hmac_value_b64) = ( - base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)), - base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))), ); (with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream) } diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests.rs similarity index 99% rename from crates/xlsx/src/tests/mod.rs rename to crates/xlsx/src/tests.rs index a7f6f1eb7..73be1a064 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests.rs @@ -23,6 +23,7 @@ mod richdata_tests; mod roundtrip; mod shape_text_rtl; mod shared_media; +mod sheet_data_pin; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 08da55991..6b9dff341 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -127,6 +127,12 @@ fn styles_theme_and_sizes() { let bytes = minimal(body, &[("xl/styles.xml", styles), ("xl/theme/theme1.xml", theme)], rels, ""); let (wb, _) = read_xlsx(&bytes).unwrap(); let s = wb.sheet(0).unwrap(); + assert_style_definitions(&wb, s); + assert_theme(&wb); + assert_sheet_settings(s); +} + +fn assert_style_definitions(wb: &gridcraft_model::Workbook, s: &gridcraft_model::Sheet) { let st1 = wb.styles.get(s.cell(at("A1")).unwrap().style); assert_eq!(st1.num_fmt.as_str(), "0.000\"kg\""); assert!(st1.font.bold && st1.font.italic && st1.font.strike); @@ -152,6 +158,9 @@ fn styles_theme_and_sizes() { assert_eq!(st3.fill.pattern, PatternType::DarkGrid); assert_eq!(st3.fill.fg, Color::Theme(5, 0)); assert_eq!(wb.styles.get(s.cell(at("D1")).unwrap().style).num_fmt.as_str(), "General"); +} + +fn assert_theme(wb: &gridcraft_model::Workbook) { // Theme: file order dk1, lt1… maps to lt1, dk1… in the model. assert_eq!(wb.theme.colors[0], 0xFFFFFF); assert_eq!(wb.theme.colors[1], 0x000000); @@ -159,6 +168,9 @@ fn styles_theme_and_sizes() { assert_eq!(wb.theme.colors[3], 0x44546A); assert_eq!(wb.theme.colors[4], 0x4472C4); assert_eq!(wb.theme.major_font, "Calibri Light"); +} + +fn assert_sheet_settings(s: &gridcraft_model::Sheet) { // Sheet settings. assert_eq!(s.tab_color, Some(Color::Rgb(0x00B050))); assert_eq!(s.freeze, Some((2, 1))); diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 4ee815e86..0fb9f1b43 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -46,6 +46,20 @@ fn missing_parts() { assert!(rep.warnings.iter().any(|w| w.contains("no worksheets"))); } +#[test] +fn workbook_part_selection_and_validation() { + let root_rels = super::ROOT_RELS.replace("xl/workbook.xml", "xl/missing.xml"); + let workbook = r#""#; + let fallback = make_zip(&[("_rels/.rels", root_rels.as_bytes()), ("xl/workbook.xml", workbook.as_bytes())]); + assert!(read_xlsx(&fallback).is_ok()); + + let wrong_root = make_zip(&[("xl/workbook.xml", b"")]); + assert!(matches!(read_xlsx(&wrong_root), Err(IoError::Format(message)) if message.contains("unexpected root element "))); + + let ods = make_zip(&[("content.xml", b"")]); + assert!(matches!(read_xlsx(&ods), Err(IoError::Format(message)) if message.contains("OpenDocument"))); +} + #[test] fn huge_coordinates_and_dimensions() { let body = r#" diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 343bfa9ee..28113be0d 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -213,6 +213,14 @@ fn unmodelled_parts_survive_a_round_trip() { let out = write_xlsx(&wb).unwrap(); let u = Unpacked::new(&out); u.assert_valid(); + assert_kept_workbook_parts(&u); + let sheet = sheet_part(&u, "Data"); + let sx = u.text(&sheet); + assert_kept_sheet_parts(&u, &sheet, &sx); + assert_round_trip_kept_content(&wb, &out); +} + +fn assert_kept_workbook_parts(u: &Unpacked) { // Every kept part is in the output byte for byte. for d in [ PRINTER, @@ -244,34 +252,37 @@ fn unmodelled_parts_survive_a_round_trip() { let item = u.find(CUSTOM_XML.as_bytes())[0].clone(); assert!(u.rels(&item).iter().any(|r| r.1.ends_with("/customXmlProps"))); // Formulas using the link are written as they were. - let sheet = sheet_part(&u, "Data"); + let sheet = sheet_part(u, "Data"); let sx = u.text(&sheet); assert!(sx.contains("[1]Sheet1!$A$1"), "{sx}"); assert!(sx.contains("SUM([1]Sheet1!A1:A3)*2"), "{sx}"); +} + +fn assert_kept_sheet_parts(u: &Unpacked, sheet: &str, sx: &str) { // Printer settings (#324). - let ps = attr_of(&sx, "pageSetup", "r:id"); - let (ty, target) = u.target(&sheet, &ps); + let ps = attr_of(sx, "pageSetup", "r:id"); + let (ty, target) = u.target(sheet, &ps); assert!(ty.ends_with("/printerSettings")); assert_eq!(u.parts.get(&target).unwrap(), PRINTER); // The OLE object and its preview image, re-linked. - let ole = attr_of(&sx, "oleObject", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE); - let preview = attr_of(&sx, "objectPr", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF); + let ole = attr_of(sx, "oleObject", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &ole).1).unwrap(), OLE); + let preview = attr_of(sx, "objectPr", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &preview).1).unwrap(), EMF); // Namespace declarations travel with the fragment. - assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); - assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); + assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006"); + assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); // The form control. - let ctrl = attr_of(&sx, "control", "r:id"); - assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); + let ctrl = attr_of(sx, "control", "r:id"); + assert_eq!(u.parts.get(&u.target(sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes()); // Schema order: pageSetup < legacyDrawing < oleObjects < controls. let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}")); assert!(pos(">) { let ct = xml::parse(parts.get("[Content_Types].xml").unwrap()).unwrap(); let overrides: HashMap = ct .kids("Override") .map(|o| (o.attr("PartName").unwrap().trim_start_matches('/').to_string(), o.attr("ContentType").unwrap().to_string())) .collect(); let defaults: HashSet = ct.kids("Default").map(|d| d.attr("Extension").unwrap().to_string()).collect(); - // Every override names an existing part; every part has a content type. - for p in overrides.keys() { - assert!(parts.contains_key(p), "override for missing part {p}"); + for part in overrides.keys() { + assert!(parts.contains_key(part), "override for missing part {part}"); } - for p in parts.keys() { - if p == "[Content_Types].xml" { - continue; - } - let ext = p.rsplit('.').next().unwrap(); - assert!(overrides.contains_key(p) || defaults.contains(ext), "no content type for {p}"); + for part in parts.keys().filter(|p| p.as_str() != "[Content_Types].xml") { + let ext = part.rsplit('.').next().unwrap(); + assert!(overrides.contains_key(part) || defaults.contains(ext), "no content type for {part}"); } - let count = |ct: &str| overrides.values().filter(|v| v.as_str() == ct).count(); + let count = |content_type: &str| overrides.values().filter(|value| value.as_str() == content_type).count(); assert_eq!(count(crate::pivot::CT_PIVOT_TABLE), 3); assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_DEF), 3); assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_REC), 3); - // Every relationship target exists. - let mut kinds: Vec = vec![]; - for (name, data) in &parts { - if !name.ends_with(".rels") { - continue; - } +} + +fn assert_relationship_targets_exist(parts: &HashMap>) { + let mut kinds: Vec = Vec::new(); + for (name, data) in parts.iter().filter(|(name, _)| name.ends_with(".rels")) { let dir = name.trim_end_matches(".rels").rsplit_once("/_rels/").map(|(d, _)| d).unwrap_or(""); - let rels = xml::parse(data).unwrap(); - for r in rels.kids("Relationship") { - if r.attr("TargetMode") == Some("External") { + for relation in xml::parse(data).unwrap().kids("Relationship") { + if relation.attr("TargetMode") == Some("External") { continue; } - let target = crate::package::resolve(dir, r.attr("Target").unwrap()); + let target = crate::package::resolve(dir, relation.attr("Target").unwrap()); assert!(parts.contains_key(&target), "{name} points at missing {target}"); - kinds.push(r.attr("Type").unwrap().rsplit('/').next().unwrap().to_string()); + kinds.push(relation.attr("Type").unwrap().rsplit('/').next().unwrap().to_string()); } } - for k in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] { - assert!(kinds.iter().any(|x| x == k), "no {k} relationship"); + for kind in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] { + assert!(kinds.iter().any(|found| found == kind), "no {kind} relationship"); } - // The workbook lists the caches by id with relationships of the right type. - let wbx = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap(); - let wb_rels = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap(); - let caches: Vec<&xml::El> = wbx.child("pivotCaches").unwrap().kids("pivotCache").collect(); +} + +fn assert_workbook_cache_relationships(parts: &HashMap>) -> HashSet { + let workbook = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap(); + let relationships = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap(); + let caches: Vec<&xml::El> = workbook.child("pivotCaches").unwrap().kids("pivotCache").collect(); assert_eq!(caches.len(), 3); let mut ids = HashSet::new(); - for c in &caches { - assert!(ids.insert(c.attr("cacheId").unwrap().to_string())); - let rid = c.attr("id").unwrap(); - let rel = wb_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); - assert!(rel.attr("Type").unwrap().ends_with("/pivotCacheDefinition")); + for cache in &caches { + assert!(ids.insert(cache.attr("cacheId").unwrap().to_string())); + let rid = cache.attr("id").unwrap(); + let relation = relationships.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); + assert!(relation.attr("Type").unwrap().ends_with("/pivotCacheDefinition")); } - // Each table's cacheId is one of the workbook's caches and its field counts agree. - for t in 1..=3 { - let pt = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{t}.xml")).unwrap()).unwrap(); - assert!(ids.contains(pt.attr("cacheId").unwrap())); - let trels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels")).unwrap()).unwrap(); - let target = crate::package::resolve("xl/pivotTables", trels.kids("Relationship").next().unwrap().attr("Target").unwrap()); - let cache = xml::parse(parts.get(&target).unwrap()).unwrap(); - let nf = cache.child("cacheFields").unwrap().kids("cacheField").count(); - assert_eq!(pt.child("pivotFields").unwrap().kids("pivotField").count(), nf); - assert_eq!(cache.child("cacheFields").unwrap().attr_u32("count"), Some(nf as u32)); - // Item indices stay inside the shared/group items. - for (i, pf) in pt.child("pivotFields").unwrap().kids("pivotField").enumerate() { - let cf = cache.child("cacheFields").unwrap().kids("cacheField").nth(i).unwrap(); - let n = cf - .path(&["fieldGroup", "groupItems"]) - .map(|g| g.children.len()) - .unwrap_or_else(|| cf.child("sharedItems").map(|s| s.children.len()).unwrap_or(0)); - if let Some(items) = pf.child("items") { - assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32)); - for it in items.kids("item") { - if let Some(x) = it.attr_u32("x") { - assert!((x as usize) < n, "item x={x} out of {n} in table {t} field {i}"); - } + ids +} + +fn assert_pivot_tables_reference_caches(parts: &HashMap>, ids: &HashSet) { + for table_number in 1..=3 { + let table = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{table_number}.xml")).unwrap()).unwrap(); + assert!(ids.contains(table.attr("cacheId").unwrap())); + let table_rels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{table_number}.xml.rels")).unwrap()).unwrap(); + let cache_path = crate::package::resolve("xl/pivotTables", table_rels.kids("Relationship").next().unwrap().attr("Target").unwrap()); + let cache = xml::parse(parts.get(&cache_path).unwrap()).unwrap(); + assert_pivot_table_fields_match_cache(&table, &cache); + assert_pivot_cache_records_match_fields(parts, &cache, &cache_path); + } +} + +fn assert_pivot_table_fields_match_cache(table: &xml::El, cache: &xml::El) { + let cache_fields = cache.child("cacheFields").unwrap(); + let field_count = cache_fields.kids("cacheField").count(); + assert_eq!(table.child("pivotFields").unwrap().kids("pivotField").count(), field_count); + assert_eq!(cache_fields.attr_u32("count"), Some(field_count as u32)); + for (field_index, pivot_field) in table.child("pivotFields").unwrap().kids("pivotField").enumerate() { + let cache_field = cache_fields.kids("cacheField").nth(field_index).unwrap(); + let item_count = cache_field + .path(&["fieldGroup", "groupItems"]) + .map(|items| items.children.len()) + .unwrap_or_else(|| cache_field.child("sharedItems").map(|items| items.children.len()).unwrap_or(0)); + if let Some(items) = pivot_field.child("items") { + assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32)); + for item in items.kids("item") { + if let Some(item_index) = item.attr_u32("x") { + assert!((item_index as usize) < item_count, "item x={item_index} out of {item_count} in field {field_index}"); } } } - // Records match the database fields. - if let Some(rid) = cache.attr("id") { - let crels_name = target.replace("pivotCache/", "pivotCache/_rels/") + ".rels"; - let crels = xml::parse(parts.get(&crels_name).unwrap()).unwrap(); - let rel = crels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); - let rec = xml::parse(parts.get(&crate::package::resolve("xl/pivotCache", rel.attr("Target").unwrap())).unwrap()).unwrap(); - let db = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count(); - assert_eq!(rec.attr_u32("count"), cache.attr_u32("recordCount")); - assert_eq!(rec.kids("r").count(), 8); - for r in rec.kids("r") { - assert_eq!(r.children.len(), db); - } - } } - // Sheet 2 (Report) relates to all three tables. - let srels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap(); - assert_eq!(srels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3); +} + +fn assert_pivot_cache_records_match_fields(parts: &HashMap>, cache: &xml::El, cache_path: &str) { + let Some(rid) = cache.attr("id") else { return }; + let rels_path = cache_path.replace("pivotCache/", "pivotCache/_rels/") + ".rels"; + let cache_rels = xml::parse(parts.get(&rels_path).unwrap()).unwrap(); + let relation = cache_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap(); + let records_path = crate::package::resolve("xl/pivotCache", relation.attr("Target").unwrap()); + let records = xml::parse(parts.get(&records_path).unwrap()).unwrap(); + let database_fields = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count(); + assert_eq!(records.attr_u32("count"), cache.attr_u32("recordCount")); + assert_eq!(records.kids("r").count(), 8); + for record in records.kids("r") { + assert_eq!(record.children.len(), database_fields); + } +} + +fn assert_sheet_pivot_relationships(parts: &HashMap>) { + let sheet_rels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap(); + assert_eq!(sheet_rels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3); } #[test] diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index fe01f284e..dbadb0793 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -490,7 +490,16 @@ fn full_roundtrip() { assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic"); let (back, rep) = read_xlsx(&bytes).unwrap(); assert!(rep.warnings.iter().all(|w| !w.contains("skipped") || w.contains("chart")), "{:?}", rep.warnings); + assert_workbook_metadata_roundtrip(&wb, &back); + let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap()); + assert_sheet_cells_and_settings(&wb, &back, a, b); + assert_table_roundtrip(a, b); + assert_drawing_and_chart_roundtrip(a, b); + assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0)); +} + +fn assert_workbook_metadata_roundtrip(wb: &Workbook, back: &Workbook) { assert_eq!(back.date_system, wb.date_system); assert_eq!(back.calc, wb.calc); assert!(back.protected_structure); @@ -505,8 +514,9 @@ fn full_roundtrip() { assert_eq!(back.sheets[2].visibility, Visibility::VeryHidden); assert_eq!(back.sheets[2].name, "2024"); assert_eq!(back.sheets[2].comments, wb.sheets[2].comments); +} - let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap()); +fn assert_sheet_cells_and_settings(wb: &Workbook, back: &Workbook, a: &Sheet, b: &Sheet) { assert_eq!(b.name, a.name); // Cells: values, formulas and resolved styles. let skip = [at("H2"), at("H3")]; @@ -542,6 +552,9 @@ fn full_roundtrip() { let mut sp = a.sparklines.clone(); sp[0].source = "'Data & Stuff'!B2:B3".into(); assert_eq!(b.sparklines, sp); +} + +fn assert_table_roundtrip(a: &Sheet, b: &Sheet) { // Tables: name sanitized, ids reassigned. let (ta, tb) = (&a.tables[0], &b.tables[0]); assert_eq!(tb.name, "Sales_Table"); @@ -549,6 +562,9 @@ fn full_roundtrip() { assert_eq!((tb.banded_rows, tb.banded_cols, tb.first_col, tb.last_col, tb.filter_button), (true, true, true, false, true)); assert_eq!(tb.filter, ta.filter, "the table's filter criteria survive (SUBTOTAL tells filtered rows apart)"); assert_eq!(b.value(at("P1")), Value::text("Region"), "header cells carry the column names"); +} + +fn assert_drawing_and_chart_roundtrip(a: &Sheet, b: &Sheet) { // Drawings. assert_eq!(b.images.len(), 1); assert_eq!(b.images[0].data, a.images[0].data); @@ -587,7 +603,6 @@ fn full_roundtrip() { } assert!(close(y.anchor.width, x.anchor.width) && close(y.anchor.height, x.anchor.height)); } - assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0)); } #[test] diff --git a/crates/xlsx/src/tests/sheet_data_pin.rs b/crates/xlsx/src/tests/sheet_data_pin.rs new file mode 100644 index 000000000..ef295fa2a --- /dev/null +++ b/crates/xlsx/src/tests/sheet_data_pin.rs @@ -0,0 +1,69 @@ +//! Pins the exact `` the writer produces: row order with row settings between and +//! around cell rows, cells added outside the store (cached spill values), every value kind, number +//! spellings, escaping, cell references far out in the grid and formulas with and without a file +//! spelling. The expected text was produced by the writer before its per-cell path was made +//! allocation-free, so any change in the bytes shows up here. + +use std::sync::Arc; + +use gridcraft_core::{CellError, CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId, Workbook}; + +use crate::package::Package; +use crate::write_xlsx; + +fn at(a: &str) -> CellRef { + CellRef::parse(a).unwrap() +} + +fn formula(text: &str, value: Value, style: StyleId) -> Cell { + Cell { value, formula: Some(Arc::new(Formula::new(text))), style, py: None } +} + +fn sheet_data(wb: &Workbook) -> String { + let bytes = write_xlsx(wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + let start = xml.find("").unwrap(); + let end = xml.find("").unwrap() + "".len(); + xml[start..end].to_string() +} + +#[test] +fn sheet_data_is_written_byte_for_byte_as_pinned() { + let mut wb = Workbook::new(); + let money = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("0.00")); + let sh = wb.sheet_mut(0).unwrap(); + sh.rows.insert(1, LineInfo { hidden: true, ..LineInfo::default() }); + sh.rows.insert(4, LineInfo { size: Some(30.0), custom: true, ..LineInfo::default() }); + sh.rows.insert(7, LineInfo { outline: 2, style: Some(money), ..LineInfo::default() }); + let set = + |sh: &mut gridcraft_model::Sheet, a: &str, value: Value, style: StyleId| sh.cells.set(at(a), Cell { value, formula: None, style, py: None }); + set(sh, "A3", Value::text("a & \"q\""), StyleId::DEFAULT); + set(sh, "B3", Value::Number(-5.0), StyleId::DEFAULT); + set(sh, "C3", Value::Number(0.1), money); + set(sh, "D3", Value::Bool(true), StyleId::DEFAULT); + set(sh, "E3", Value::Error(CellError::NA), StyleId::DEFAULT); + set(sh, "Z3", Value::Number(-0.0), StyleId::DEFAULT); + set(sh, "AA3", Value::Number(-2.5), StyleId::DEFAULT); + set(sh, "A5", Value::Number(1e20), money); + set(sh, "B5", Value::Number(1.5e-7), StyleId::DEFAULT); + set(sh, "C5", Value::Number(123_456_789_012_345.0), StyleId::DEFAULT); + set(sh, "D5", Value::Number(-1e15), StyleId::DEFAULT); + set(sh, "E5", Value::Number(f64::NAN), StyleId::DEFAULT); + set(sh, "F5", Value::text("Grüße\r\n\u{1}_x0041_ 日本"), StyleId::DEFAULT); + set(sh, "G5", Value::text("a & \"q\""), StyleId::DEFAULT); + set(sh, "H5", Value::Empty, money); + sh.cells.set(at("B1"), formula("SEQUENCE(2)", Value::Number(1.0), StyleId::DEFAULT)); + sh.spill_ranges.insert(at("B1"), RangeRef::parse("B1:B2").unwrap()); + sh.spill.insert(at("B2"), Value::Number(2.0)); + sh.cells.set(at("AB10000"), formula("SUM($A$1:B2,Sheet2!C:C,3:$5,'My Sheet'!$XFD$1)*2", Value::Number(0.25), money)); + sh.cells.set(at("AC10000"), formula("A1&\"\"", Value::text("t & "), StyleId::DEFAULT)); + sh.cells.set(at("AD10000"), formula("LET(x,1,x+XLOOKUP(1,A:A,B:B))", Value::Bool(false), StyleId::DEFAULT)); + sh.cells.set(at("AE10000"), formula("1/0", Value::Error(CellError::Div0), StyleId::DEFAULT)); + sh.cells.set(at("AF10000"), formula("A1", Value::Empty, StyleId::DEFAULT)); + sh.cells.set(at("XFD1048576"), Cell::value(Value::Number(7.0))); + assert_eq!(sheet_data(&wb), EXPECTED); +} + +const EXPECTED: &str = r#"_xlfn.SEQUENCE(2)10-50.11#N/A0-2.51E201.5E-7123456789012345-1000000000000000.0010SUM($A$1:B2,Sheet2!C:C,3:$5,'My Sheet'!$XFD$1)*20.25A1&"<x>"t & <u>_xlfn.LET(_xlpm.x,1,_xlpm.x+_xlfn.XLOOKUP(1,A:A,B:B))01/0#DIV/0!A17"#; diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index da65de014..bd21b4939 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -307,58 +307,74 @@ fn reads_values_formulas_formats_and_layout() { assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings); assert!(report.warnings.iter().any(|w| w.contains("Chart sheets"))); assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); - assert_eq!(v(&wb, 0, "A1"), Value::from("Hello")); - assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5)); - assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0)); - assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld")); - assert_eq!(v(&wb, 0, "A2"), Value::Bool(true)); - assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0)); - assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0)); - assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5)); - // Formulas: text plus the cached result. - assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2")); - assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0)); - assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)")); - assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\"")); - assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!")); - assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1")); - assert_eq!(v(&wb, 0, "D3"), Value::Bool(true)); - assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2")); - assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)")); - assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1")); - assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA)); - // Shared formula: relative offsets resolve per cell. - assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10")); - assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10")); - // Array formula: the anchor holds it; the other cell's cached result is dropped so that - // the formula fills the range on recalculation (as when an .xlsx is read, #156). - let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); - assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); - assert_eq!(ftext(&wb, "C6"), None); - assert_eq!(v(&wb, 0, "C6"), Value::Empty); - // Number formats, merges, sizes, names, print area; embedded chart records ignored. - let sh = &wb.sheets[0]; - assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); - assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); - assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); - assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); - assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); - assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); - assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); - assert_eq!(v(&wb, 0, "J10"), Value::Empty); - assert_eq!(wb.names.len(), 1); - assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); - assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0)); - assert_eq!(wb.date_system, DateSystem::D1900); - // The import saves as .xlsx and reads back. - let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); - assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); - assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); + assert_values_roundtrip(&wb); + assert_formula_roundtrip(&wb); + assert_layout_roundtrip(&wb); + assert_xlsx_conversion(&wb); } let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); assert_eq!(wb.date_system, DateSystem::D1904); } +fn assert_values_roundtrip(wb: &gridcraft_model::Workbook) { + assert_eq!(v(wb, 0, "A1"), Value::from("Hello")); + assert_eq!(v(wb, 0, "B1"), Value::Number(1.5)); + assert_eq!(v(wb, 0, "C1"), Value::Number(42.0)); + assert_eq!(v(wb, 0, "D1"), Value::from("Wörld")); + assert_eq!(v(wb, 0, "A2"), Value::Bool(true)); + assert_eq!(v(wb, 0, "B2"), Value::Error(CellError::Div0)); + assert_eq!(v(wb, 0, "C2"), Value::Number(7.0)); + assert_eq!(v(wb, 0, "D2"), Value::Number(1.5)); +} + +fn assert_formula_roundtrip(wb: &gridcraft_model::Workbook) { + // Formulas: text plus the cached result. + assert_eq!(ftext(wb, "A3").as_deref(), Some("B1*2")); + assert_eq!(v(wb, 0, "A3"), Value::Number(3.0)); + assert_eq!(ftext(wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(ftext(wb, "C3").as_deref(), Some("A1&\"!\"")); + assert_eq!(v(wb, 0, "C3"), Value::from("Hello!")); + assert_eq!(ftext(wb, "D3").as_deref(), Some("B1>1")); + assert_eq!(v(wb, 0, "D3"), Value::Bool(true)); + assert_eq!(ftext(wb, "E3").as_deref(), Some("Rate*2")); + assert_eq!(ftext(wb, "F3").as_deref(), Some("ROUND(B1,0)")); + assert_eq!(ftext(wb, "G3").as_deref(), Some("Data!A1")); + assert_eq!(v(wb, 0, "H3"), Value::Error(CellError::NA)); + // Shared formula: relative offsets resolve per cell. + assert_eq!(ftext(wb, "B5").as_deref(), Some("A5*10")); + assert_eq!(ftext(wb, "B6").as_deref(), Some("A6*10")); + // Array formula: the anchor holds it; the other cell's cached result is dropped so that + // the formula fills the range on recalculation (as when an .xlsx is read, #156). + let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); + assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); + assert_eq!(ftext(wb, "C6"), None); + assert_eq!(v(wb, 0, "C6"), Value::Empty); +} + +fn assert_layout_roundtrip(wb: &gridcraft_model::Workbook) { + // Number formats, merges, sizes, names, print area; embedded chart records ignored. + let sh = &wb.sheets[0]; + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); + assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); + assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); + assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); + assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); + assert_eq!(v(wb, 0, "J10"), Value::Empty); + assert_eq!(wb.names.len(), 1); + assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); + assert_eq!(v(wb, 1, "A1"), Value::Number(7.0)); + assert_eq!(wb.date_system, DateSystem::D1900); +} + +fn assert_xlsx_conversion(wb: &gridcraft_model::Workbook) { + // The import saves as .xlsx and reads back. + let (back, _) = read_xlsx(&write_xlsx(wb).unwrap()).unwrap(); + assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); + assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); +} + #[test] fn text_result_of_a_formula_kept_as_its_value() { // A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 41ab3e8a3..49f5b738d 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -246,7 +246,6 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) out.default_type("xml", "application/xml"); // Which sheet is active (must be visible), and at least one visible sheet. - let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) { wb.active_sheet() } else { @@ -254,92 +253,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) }; let mut wb_rels = Rels::default(); - // Kept content first: the chart sheets among the worksheets need their relationship ids. - let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out); - let mut pivots = crate::pivot::PivotWriter::default(); - let mut sheets_xml = String::new(); - let mut ids = crate::passthrough::SheetIds::new(wb, &kept); - for (i, sheet) in wb.sheets.iter().enumerate() { - sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids)); - let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out); - pivots.add_sheet(wb, i, &mut out, &mut rels); - let name = format!("xl/worksheets/sheet{}.xml", i + 1); - out.part(&name, Some(CT_WORKSHEET), xml.into_bytes()); - if !rels.is_empty() { - out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes()); - } - let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1)); - let state = match sheet.visibility { - Visibility::Visible => "", - _ if !any_visible && i == active => "", - Visibility::Hidden => " state=\"hidden\"", - Visibility::VeryHidden => " state=\"veryHidden\"", - }; - let name: String = sheet.name.chars().take(31).collect(); - let sheet_id = ids.take(sheet.file_id); - let _ = write!(sheets_xml, "", esc_attr(&name)); - } - sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids)); + let (sheets_xml, kept, pivots) = write_sheets(wb, &mut out, active, &mut wb_rels); - // Defined names, then print areas and titles. - let mut names = String::new(); - let mut seen = std::collections::HashSet::new(); - for n in &wb.names { - if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) { - continue; - } - if let Some(sc) = n.scope - && sc >= wb.sheets.len() - { - continue; - } - let _ = write!(names, "", esc(&crate::fmla::text_to_file(&n.formula))); - } - for (i, f) in out.chart_names.iter().enumerate() { - let _ = write!( - names, - "", - crate::chartex::NAME_PREFIX, - esc(&crate::fmla::text_to_file(f)) - ); - } - for (i, sheet) in wb.sheets.iter().enumerate() { - let q = quote_sheet(&sheet.name); - if let Some(pa) = &sheet.print.print_area { - let _ = write!( - names, - "{}!{}", - kept.file_index(i), - esc(&q), - abs_range(pa) - ); - } - let mut titles = vec![]; - if let Some((c0, c1)) = sheet.print.title_cols { - titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1))))); - } - if let Some((r0, r1)) = sheet.print.title_rows { - titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1))))); - } - if !titles.is_empty() { - let _ = write!( - names, - "{}", - kept.file_index(i), - esc(&titles.join(",")) - ); - } - } + let names = write_defined_names(wb, &kept, &out.chart_names); let mut w = format!("{XML_DECL}"); // A workbook read from a file keeps its as it was: Excel trusts the saved values @@ -519,5 +435,106 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) zip.finish() } +fn write_sheets( + wb: &Workbook, + out: &mut Out, + active: usize, + wb_rels: &mut Rels, +) -> (String, crate::passthrough::WorkbookKept, crate::pivot::PivotWriter) { + // Kept content first: the chart sheets among the worksheets need their relationship ids. + let kept = crate::passthrough::write_workbook(wb, wb_rels, out); + let mut pivots = crate::pivot::PivotWriter::default(); + let mut sheets_xml = String::new(); + let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); + let mut ids = crate::passthrough::SheetIds::new(wb, &kept); + for (i, sheet) in wb.sheets.iter().enumerate() { + sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids)); + let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, out); + pivots.add_sheet(wb, i, out, &mut rels); + let name = format!("xl/worksheets/sheet{}.xml", i + 1); + out.part(&name, Some(CT_WORKSHEET), xml.into_bytes()); + if !rels.is_empty() { + out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes()); + } + let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1)); + let state = match sheet.visibility { + Visibility::Visible => "", + _ if !any_visible && i == active => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = sheet.name.chars().take(31).collect(); + let sheet_id = ids.take(sheet.file_id); + let _ = write!(sheets_xml, "", esc_attr(&name)); + } + sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids)); + + (sheets_xml, kept, pivots) +} + +fn write_defined_names(wb: &Workbook, kept: &crate::passthrough::WorkbookKept, chart_names: &[String]) -> String { + // Defined names, then print areas and titles. + let mut names = String::new(); + let mut seen = std::collections::HashSet::new(); + for n in &wb.names { + if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) { + continue; + } + if let Some(sc) = n.scope + && sc >= wb.sheets.len() + { + continue; + } + let _ = write!(names, "", esc(&crate::fmla::text_to_file(&n.formula))); + } + for (i, f) in chart_names.iter().enumerate() { + let _ = write!( + names, + "", + crate::chartex::NAME_PREFIX, + esc(&crate::fmla::text_to_file(f)) + ); + } + for (i, sheet) in wb.sheets.iter().enumerate() { + let q = quote_sheet(&sheet.name); + if let Some(pa) = &sheet.print.print_area { + let _ = write!( + names, + "{}!{}", + kept.file_index(i), + esc(&q), + abs_range(pa) + ); + } + let mut titles = vec![]; + if let Some((c0, c1)) = sheet.print.title_cols { + titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1))))); + } + if let Some((r0, r1)) = sheet.print.title_rows { + titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1))))); + } + if !titles.is_empty() { + let _ = write!( + names, + "{}", + kept.file_index(i), + esc(&titles.join(",")) + ); + } + } + + names +} + /// Cell metadata declaring dynamic-array formulas (MS-XLSX `XLDAPR`). const METADATA: &str = "\n"; diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls.rs similarity index 100% rename from crates/xlsx/src/xls/mod.rs rename to crates/xlsx/src/xls.rs diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 166994180..940a66261 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -325,6 +325,17 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res /// Escapes text content. pub fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); + push_esc(&mut o, s); + o +} + +/// Appends [`esc`]`(s)` to `o`. +pub fn push_esc(o: &mut String, s: &str) { + if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) { + o.push_str(s); + return; + } + o.reserve(s.len()); for c in s.chars() { match c { '&' => o.push_str("&"), @@ -335,7 +346,43 @@ pub fn esc(s: &str) -> String { _ => {} } } - o +} + +/// Appends `n` in decimal. +pub fn push_u64(o: &mut String, mut n: u64) { + let mut buf = [0u8; 20]; + let mut i = buf.len(); + loop { + i -= 1; + if let Some(d) = buf.get_mut(i) { + *d = b'0' + (n % 10) as u8; + } + n /= 10; + if n == 0 || i == 0 { + break; + } + } + for &d in buf.get(i..).unwrap_or_default() { + o.push(char::from(d)); + } +} + +/// Appends the `A1`-style text of `c` ([`gridcraft_core::CellRef::a1`]). +pub fn push_a1(o: &mut String, c: gridcraft_core::CellRef) { + let mut letters = [0u8; 8]; + let mut i = letters.len(); + let mut n = u64::from(c.col) + 1; + while n > 0 && i > 0 { + i -= 1; + if let Some(l) = letters.get_mut(i) { + *l = b'A' + ((n - 1) % 26) as u8; + } + n = (n - 1) / 26; + } + for &l in letters.get(i..).unwrap_or_default() { + o.push(char::from(l)); + } + push_u64(o, u64::from(c.row) + 1); } /// Escapes an attribute value (double-quoted). @@ -475,13 +522,30 @@ pub fn si_body(text: &str, phonetic: Option<&PhoneticString>) -> String { /// Formats a number for an XML attribute/value (shortest round-trip form). pub fn num(n: f64) -> String { + let mut s = String::new(); + push_num(&mut s, n); + s +} + +/// Appends [`num`]`(n)` to `o`. +pub fn push_num(o: &mut String, n: f64) { if !n.is_finite() { - return "0".into(); + o.push('0'); + return; } if n == n.trunc() && n.abs() < 1e15 { - return format!("{}", n as i64); + let i = n as i64; + if i < 0 { + o.push('-'); + } + push_u64(o, i.unsigned_abs()); + return; + } + let start = o.len(); + let _ = write!(o, "{n:?}"); + if let Some(e) = o.get(start..).and_then(|t| t.find('e')) { + o.replace_range(start + e..start + e + 1, "E"); } - format!("{n:?}").replace('e', "E") } #[cfg(test)] @@ -618,6 +682,62 @@ mod tests { assert_eq!(esc_attr("a\rb"), "a b"); } + /// The appending helpers the sheet writer uses write exactly what the allocating spellings + /// (`CellRef::a1`, `to_string`, the `num` formula and the `esc` character loop) write. + #[test] + fn appending_helpers_match_the_allocating_spellings() { + use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; + for c in [ + CellRef::new(0, 0), + CellRef::new(9, 25), + CellRef::new(99, 26), + CellRef::new(0, 701), + CellRef::new(0, 702), + CellRef::new(MAX_ROWS - 1, MAX_COLS - 1), + CellRef::new(u32::MAX, u32::MAX), + ] { + let mut s = String::from("x"); + push_a1(&mut s, c); + assert_eq!(s, format!("x{}", c.a1())); + } + for n in [0, 9, 10, 99, 1_048_576, u64::MAX] { + let mut s = String::from("x"); + push_u64(&mut s, n); + assert_eq!(s, format!("x{n}")); + } + for n in + [0.0, -0.0, 1.0, -3.0, 0.1, -2.5, 1e-7, 1.5e20, 1e15, -1e15, 999_999_999_999_999.0, f64::NAN, f64::INFINITY, f64::MIN_POSITIVE, f64::MAX] + { + let reference = if !n.is_finite() { + "0".to_string() + } else if n == n.trunc() && n.abs() < 1e15 { + format!("{}", n as i64) + } else { + format!("{n:?}").replace('e', "E") + }; + let mut s = String::from("x"); + push_num(&mut s, n); + assert_eq!(s, format!("x{reference}"), "{n}"); + assert_eq!(num(n), reference); + } + for t in ["plain", "", "a & > c \"q\"", "tab\tnew\nline\rcr", "\u{1}\u{8}ctl", "日本語 ü", "\u{FFFE}\u{FFFF}x", "~\u{7F}"] { + let mut reference = String::new(); + for c in t.chars() { + match c { + '&' => reference.push_str("&"), + '<' => reference.push_str("<"), + '>' => reference.push_str(">"), + '\r' => reference.push_str(" "), + c if is_xml_char(c) => reference.push(c), + _ => {} + } + } + let mut s = String::from("x"); + push_esc(&mut s, t); + assert_eq!(s, format!("x{reference}"), "{t:?}"); + } + } + #[test] fn numbers() { assert_eq!(num(1.0), "1"); diff --git a/docs/architecture.md b/docs/architecture.md index 203004573..3bb3f9212 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:** minor (Python cells as data; earlier: engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells as data; earlier: the hidden names that hold macros; macro engine on the engine building blocks) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -98,12 +98,15 @@ Actions made of many commands (macros, a debugger that runs one step per call) u every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command `automate.stopAction` on the active workbook, roll back the groups left open on a document (a debugger the person abandons, a caller that dropped its token), so Undo works there again. The + command runs on its own: a step of a script or macro may not stop the action it is part of, and + 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. - **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 - dialog (the dialog's confirming call is the step), and the buffer is independent of the journal + dialog (the dialog's confirming call is the step; a run that changed the workbook before a step + of it opened a dialog is recorded), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and `FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it, @@ -120,11 +123,17 @@ Actions made of many commands (macros, a debugger that runs one step per call) u other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most - 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. + 10,000 of them (`stash::MAX_ITEMS`) with a count of the rest, so a caller can say what it did + not list. The older single-name `_sc_` items are read as well. `formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item under (a hidden workbook-scope name of either shape; a visible one is the person's own), and `formulas.createFromSelection` skips such labels, unless `force: true` is passed. +The macro engine (`engine/src/macros/`, commands in `cmd/macros.rs`, described in +[`macros.md`](macros.md)) is built on these: a run is an undo group, the recorder reads the +recording buffer, the policy asks before a macro in a `FromFile` workbook runs, and xlsx keeps +macros as chunked hidden names (`_gc_macro__`: macro `n`, chunk `k`). + ## Python cells A Python cell is a `Cell` whose `py` holds a `model::PyCode`: the code, the return type (an Excel @@ -169,7 +178,9 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, and the limits on open | +| 2026-11-01 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, and the limits on open | +| 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 | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | diff --git a/docs/chart-parity.md b/docs/chart-parity.md index f18283f45..89358dd30 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -38,6 +38,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Element | Excel | GridCraft | |---|---|---| | Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) | +| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) | | Chart title, axis titles | text, linked to cells, full formatting | text only | | Legend | position, overlay, formatting, per-entry delete | position only | | Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag | @@ -90,6 +91,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) | | 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) | | 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | diff --git a/docs/contributors.md b/docs/contributors.md index 811fb8548..7cb4a6240 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to GridCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. Never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -37,20 +35,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 883a6af3b..3dee729d3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,8 @@ The control port is reachable by every program on the machine, and by a web page `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the - connection is closed. After that, a line that isn't JSON also closes the connection. + connection is closed. At most 16 connections may be waiting to send their token at once; past that a new + connection is closed before anything is read. After that, a line that isn't JSON also closes the connection. - `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same user on the same machine without setup). Your own client reads the file or the variable. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4e0252ee4..2626586cd 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-25 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -66,9 +66,9 @@ values) is still dropped. | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | @@ -107,7 +107,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Work | Hours | |---|---| -| Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | +| Real-world XLSX corpus run, and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | @@ -120,12 +120,13 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | +| 2026-11-01 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | | 2026-10-20 | trivial | A workbook theme's major/minor East Asian (``) and complex-script (``) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) | | 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection | +| 2026-10-26 | trivial | `file.exportCsv {values: true}` writes raw cell values (full-precision numbers, ISO 8601 dates and times) for automation; the default stays Excel's displayed text (#2648) | | 2026-10-19 | minor | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | diff --git a/docs/function-parity.md b/docs/function-parity.md index 5337204b5..255d7dd0a 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-01 · **Change:** minor (the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: 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). @@ -20,8 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. -- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR), - is in our library as a Compatibility function for files that use it. It is not counted above. +- **Not on Microsoft's list:** USDOLLAR and ECMA.CEILING, legacy functions Excel still evaluates (like DOLLAR), + are in our library as Compatibility functions for files that use them. They are not counted above. - **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks WEBSERVICE and FILTERXML; Excel for Windows has them. @@ -86,10 +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 | @@ -98,6 +102,12 @@ This improves series preparation; it does not change the model-fitting or season ## Depth (estimated) +| Function | Excel parity note | +|---|---| +| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. | +| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. | +| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. | + **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range @@ -124,7 +134,27 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | +| 2026-11-01 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | +| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | +| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel | +| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel | +| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel | +| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 | +| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 | +| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places | +| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` | +| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid | +| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` | +| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees | +| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior | +| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged | +| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` | +| 2026-10-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?` | | 2026-10-23 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 | | 2026-10-23 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns | 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/macros.md b/docs/macros.md new file mode 100644 index 000000000..3335b5ecc --- /dev/null +++ b/docs/macros.md @@ -0,0 +1,326 @@ +# Macros + +> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) + +GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft +command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro +declares the inputs it works on, runs as one undo step, rolls all workbook changes back on an +error (a file it already wrote stays), checks where its results will land before it writes them, +and does not touch files without permission. +Agents run macros with `macro.run` like any other command. + +This page describes the engine, its commands and the macro interface in the window. + + +## A macro + +```json +{ + "name": "Totals", + "description": "Adds a total next to the amounts", + "inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}], + "output": {"anchor": "Input.right(2).cell(1,2)", "onConflict": "ask"}, + "steps": [ + {"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "Total"}}, + {"type": "command", "id": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}}, + {"type": "repeat", "over": "Input.data", "of": "rows", "max": 1000, "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, + "then": [{"type": "command", "id": "home.bold", "params": {"range": "Row"}}]} + ]}, + {"type": "stop", "message": "Done."} + ], + "permissions": [] +} +``` + +- **Steps**: `command` (`id`, `params`), `repeat` (each row or column of `over`, at most `max` + times), `if` (`cell` compared with `value` by `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `empty`, + `notEmpty` or `contains`; `then` and `else` lists) and `stop` (`message`; `rollback: true` + undoes the run). Every step can carry `enabled`, `breakpoint` and `note`. The short form + `{"command": "cell.set", "params": {…}}` is accepted for a command step. +- **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; + rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), + `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work + on it. An input's name is letters, digits and `_`, starting with a letter, and is neither a + token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads as a cell + reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. The declaration + is checked as a whole: neither `rows` nor `cols` may have a least above its most, a `cell` input + has one row and one column (`[1, 1]`, or the default `[1, 0]`), no header and at most one column + type, and no input lists more column types than it allows columns. A run binds each input + to the range it is given, its default, or (for the first input) the selection, and refuses + with messages that name the cells: "Input column 2 (Amount) must hold numbers; C4 holds text." +- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep, and 64 + inputs a macro. Names (of macros and inputs) and command ids are at most 255 characters; + descriptions, step notes, Stop messages and If values (as JSON) 4,096; references and tokens + (`output.anchor`, `defaultRef`, a Repeat's `over`, an If's `cell`) 1,024; a shortcut 64. A + macro lists at most 256 permissions, each a command id, and an input at most 16,384 column + types. One-line fields hold no line breaks or other control characters, and a command id is + letters, digits, `.` and `_`. A command step's `params` are bounded only by the size of the + whole macro (4,194,304 characters of JSON). A macro read from a file that breaks any of these is + left out with a warning; it is never changed to fit. When a macro runs, a Repeat whose range has + more rows or columns than its `max` is refused, and a run executes at most 100,000 steps (each + pass through a Repeat counts), passes its commands at most 256 MB of parameters, all steps + together, and takes at most 60 seconds, all three shared by the footprint dry runs and the run + itself (`timeLimit` to change the time; the web build has no clock, so there only the step and + parameter limits apply). The debugger gives each of its actions the time limit on its own. + +## Tokens + +References in step parameters can be tokens, worked out when the step runs: + +| Token | Means | +|---|---| +| `Input`, or another input's name | the range the input is bound to | +| `Output` | the output rule's anchor | +| `Row`, `Column` | the current row or column of the Repeat around the step | +| `Name:Total` | a defined name, or a table's data | + +Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`, `cols(2..3)`, +`rows(2..)`, `cell(r,c)` (relative to the top-left cell; `cell(0,1)` is the cell above), +`offset(r,c)`, `resize(h,w)`, `data` (without the header row), `below(n)` and `right(n)`. +`col("Amount")` looks the name up in an input's header row, directly or after `data`; on a range +without a header row it is refused. A defined name can hold periods: `Name:Sales.Q1` is the name +`Sales.Q1`, and `Name:Sales.Q1.cell(1,1)` its first cell, because the longest name or table that +exists is used and methods follow it. + +Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending +in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside +any string, such as a formula; `{{{{` in a string is a literal `{{`, and `{"$text": "Output"}` +anywhere is the text as it is, never read as a token or for `{{…}}` (a defined name called +`Output`, or `Dear {{name}}`). Under the general keys that +other commands use for plain words (`to`, `from`, `at`, `area`, `source`, `target`, +`destination`, `location`), a bare `Row` or `Column` stays text (a replacement word, a pivot +area); write `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` there. Plain A1 references stay +absolute; a `Name:` anchor follows inserted rows. A reference with a sheet name is never a token, +so `Output!A1` is a cell on a sheet called Output, even when a token or input has that name. +`Input.data` on a range that is only a header row, and `Name:` of a table with no data rows, are +refused rather than read as the header. + +## Running + +`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: + +1. **Policy.** File commands other than Properties, exports and printing (such as Open, Save + and Close), app, window (including New Window, Hide and Unhide), automate and macro commands, + and Undo and Redo, never run in a macro. File › Properties runs, and so do exports and + printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) + that only produce bytes. Reading or writing a file (`data.getData`, `data.fromTextCsv`, + `insert.picture` or `pageLayout.background` with a `path`, or an export or print with a + `path`) needs the command in the macro's `permissions`. Any `path` other than `null` counts, + including a blank one and a token, and the policy is checked again on the parameters as they + are when the step runs. A macro that touches files, or that came with a workbook opened from + a file, runs once the person approves it: the call fails with `approve: …` and runs with + `approve: true`. Approval lasts for the session, until the macro changes. Every step other + than an approved file step runs inside the file sandbox that scripts use, so no other + parameter reaches a file either. +2. **Footprint.** Unless the conflict rule is `overwrite`, the macro first runs in dry-run mode on + the real workbook, and everything is put back. Cells it would write outside its inputs that + hold data, and sheets and objects it would delete, are conflicts, handled by `onConflict` (or + the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` + answers it), `fail`, `overwrite`, `shiftDown` or `shiftRight` (Insert Cells makes room, then + the footprint is checked again). Steps that touch files, and Solver, end the dry run early; + the footprint then says it is partial. Unless the rule is `overwrite`, the run also checks + every step as it goes, since the footprint can miss a write: it is partial, or a value such as + RAND or NOW sends the run another way than the dry run. A step that writes over cells, sheets + or objects that held data when the run started stops the run and rolls the workbook back (a + file already written stays): it asks (`confirm: …`) under `ask` and fails otherwise, since + shifting can't make room that late. Moving cells that hold data, as inserting or deleting rows + or cells above them does, counts as writing over them, in the footprint and in this check, and + so does a formula whose references such a move rewrites: both compare cells by place, so they + may ask about data that only moved, never the other way. +3. **Run.** One undo step, "Run Macro: Totals". Any error rolls the whole run back and names the + step: `Step 3.1 (cell.set): …` (paths are 1-based; `2.1` is inside a Repeat, `4.then.1` inside + an If). The message ends "Nothing was changed.", or names the files the run already wrote, + which stay. An input the macro doesn't have, an input that isn't reference text, and + `onConflict`, `overwrite`, `approve` or `timeLimit` of the wrong kind are refused rather than + ignored. When a step inserts, deletes or moves sheets, rows, columns or cells, the inputs, the + output and the range of a Repeat in progress move with their cells, as references in formulas + do; a step that deletes one of them, or changes the size of a range a Repeat is going over, + stops the run. + +`macro.check {name, inputs?, onConflict?, overwrite?, timeLimit?}` reports the bound inputs, the +output anchor, any approval needed and the footprint, and puts back everything the dry run +changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; +when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it +would write over data under `ask` or `fail`, shifting can't make room (the check shifts and +checks again, then puts everything back), or the run would go past +the step or parameter limit counting the dry runs before it. The random numbers RAND gives move +on, as after any recalculation. + +## Debugging + +`macro.debug {action, …}` runs a macro a step at a time. The actions are: + +- `start` with `name` and the options of `macro.run` (`inputs`, `onConflict`, `overwrite`, + `approve`, `timeLimit`), refused as `macro.run` refuses them when they are of the wrong kind. The + checks, the approval and the footprint come first, as for `macro.run`; then the run pauses + before its first step. +- `step` runs the next step; `continue` runs to the next breakpoint or the end; `runTo` (`step`: + a path) runs to that step, stopping at a breakpoint on the way. +- `stop` ends the run: `keep: true` keeps the changes as one undo step, otherwise they are rolled + back. +- `state` answers without running anything (the default while a macro is being debugged). + +`breakpoints` (a list of step paths of the macro, at most 10,000) can be given with any action and +replaces the list set before; `breakpointsOnly: true` makes the list replace the steps' own +breakpoint flags, also when it is given without a list. An unknown action, a breakpoint or `runTo` +path the macro doesn't have, and `keep` or `breakpointsOnly` that isn't `true` or `false` are +refused before anything changes. The time limit applies to each action on its own (`start` with +its dry runs, and each `step`, `continue` or `runTo`); the step and parameter limits apply to the +whole run, counting the dry runs. + +Each answer gives the next step with its parameters as they would run (a Repeat over whole rows or +columns shows the part of the sheet in use, as the run goes over it), the parameters of the step +that just ran, the steps the run has executed (the dry runs not counted), and what the run has +changed in its workbook: `changed` lists at most 500 entries (cell ranges, and sheets added, +deleted or renamed) and `changedCells` counts the changed cells on the sheets it had at the start. `shifted` names the rule that made +room at the start, and `wroteFiles` the files the run wrote, which stay when the run is rolled +back; the answer to a rolled-back run says so in `message`. A run that ends (its last step, a +Stop step, or a failed step, which rolls it back) or is stopped answers with `state` `finished`, +`stopped` or `failed`. + +While a macro is being debugged, its workbook is locked between steps: only `macro.debug`, queries +(commands that only read), commands that look around (`selection.*`, Select All, `sheet.activate`, +`sheet.next` and `sheet.previous`, Go To, Go To Special, Find, Copy and the zoom commands), Switch +Window, Close (which ends the run with its workbook) and Automate › Stop Action run there. +Everything else, undoable or not, saving included, is refused with "Finish or stop the macro +first", also when another command runs it (stopping Record Actions saves a script), and so is +`macro.check`, whose dry run runs the macro's steps. Other +workbooks stay editable. The person may look around between steps: before the next step runs, the +debugger puts the active sheet and the selection back where the run left them, so the run goes on +as `macro.run` would run it. A zoom change made meanwhile is part of the run, and is undone with +it. Stop Action rolls the run back and ends it, and its answer names the macro. The debugger runs +on its own, never as a step of a script or macro, and is never recorded. + +## Recording + +`macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute +references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's +name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+M"` or `"M"`) and its description. They are +checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a +letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name +the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins +over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy +(2)`, so stopping never replaces a macro, and a shortcut another macro took meanwhile is left off, +which the result says in `shortcutDropped`. `cancel: true` ends a recording without saving it, the +way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` +or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one +of the wrong kind. The macro is saved in the workbook where recording started, even if another one +is active; if that workbook was closed, stopping ends the recording without saving it. When saving +the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged +in that workbook), recording goes on, so stopping again keeps what was recorded. The person recorded +the macro, so in a workbook opened from a file it runs without the approval a macro that came with +the file needs, until it changes. + +A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to +10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps +recorded in another workbook, are left out and counted in `dropped`. + +With relative references the selection when recording starts is the example input: references in +and near it on its sheet become tokens (on another sheet `C4` stays that sheet's `C4`), and the +input's shape, header and column types are inferred from it as it was when recording started, +before the recorded steps changed it. Recorded text that playback would read as a token stays +text: a reference parameter that reads as one (a defined name called `Output`) is written +`{"$text": "Output"}`, and `{{` in a string is written `{{{{`. Toggles such as Bold are recorded +with the state they set (`"on": true`), also inside a script run, so playing them back sets them +rather than flipping them. + +Commands a macro may not run are left out and listed in `skipped`, also from the steps of a macro +or script run while recording. A macro run while recording is replaced by its steps when it has +no inputs, no output and only plain commands (otherwise it is left out and listed), and an +Automate script run by the steps it ran, without a step that only opened a dialog (a menu-style +call without its parameters). Both are kept as they ran, so editing or deleting the macro or +script before the recording stops changes nothing; a run whose steps did not fit in the +recording's 64 MB is left out and listed. + +Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the +step it undid stays in the recording, so playing the recording back makes that edit again. + +GridCraft keeps the Ctrl+Shift letters it uses itself on some platform: F, G, H, J, K, L, R, S, T +and Z (`macros::RESERVED_SHORTCUTS`). A shortcut a file brings with it on one of these letters is +kept but does not run the macro. + +## Other commands and storage + +`macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename +{name, to}`. Saving under a name a macro has is refused unless `replace: true` is passed, and +renaming to one is refused; names compare ignoring the case of the letters A to Z. A renamed macro is checked +against every limit again, its size included. Saving, renaming and deleting are undoable. + +Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden +defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's +limits), which other spreadsheet applications keep but do not run. A visible name of that shape +is someone's own name: opening a file leaves it alone, and saving stores the macros under indices +it does not use. + +Each macro carries its format version, `v` (1 in this version; a missing `v` reads as 1). When a +file is opened, a macro in a newer format (a `v` above 1) is kept unread, with a warning: this +version can't show or run it, and saving writes it back unchanged, so the newer +version gets it back whole. It is never read in part. Kept macros count toward the 1,000-macro +limit after the readable ones, and one too large for an xlsx file is left out. They count when a +macro is added too (saving, or stopping a recording, is refused once readable and kept macros +together reach 1,000, and the message says how many are kept), so a save holds no more macros +than the next open keeps. Their names are taken: saving or renaming a macro to one is refused, and +a recording gets a suffix. A macro that is +damaged or over the limits is left out with one warning, and the rest of the workbook and its +other macros open; a macro that was left out is not in the saved file. + +## In the window + +- **Automate › Macros group.** Macros opens the Macros dialog. Record Macro opens the Record + Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References); OK + starts recording and stays off while the name or the letter cannot be used, with the reason + under the fields. While recording, the button reads Stop Recording, the status bar shows a red + dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the + macro run last (or the only one); its menu lists the recent macros, then every macro. New + Macro opens the step editor on a new macro. +- **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros + (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. + Scripts that came with an opened file stay disabled until Trust Scripts (`automate.trustScripts`) + trusts them for the session. Run runs the selected macro on the selection or the selected + script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a + macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before + macros existed. +- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`; in a + narrow app window it is no larger than the window and its toolbar wraps onto a second row. The + step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a + category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches + are indented under their step, and rows drag to reorder within their list. Add Step opens a + command picker (control steps first; commands a macro may never run are left out); Duplicate and + Delete act on the selected step. The inspector's Details tab edits the selected step, with + reference boxes that take a typed reference or a selection on the grid and switch between a + token relative to the example input (Rel) and an absolute reference (Abs); the Inputs tab sets + each input's shape, header and column types, and Use current selection as example infers them; + the Output tab sets the anchor and what to do when results would land on data. The editor + belongs to the workbook it opened in: while another workbook is active it says so and Save, + Check, Run and Debug wait until that workbook is active again, and closing the workbook closes + the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is + refused with a message, and closing the app first asks about the editor's debug run and unsaved + steps, then about unsaved workbooks. +- **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes + green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and + Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and + `breakpointsOnly: true`, so a dot cleared while paused takes effect at once); the current step is + selected, its parameters are shown as they will run, and its target is outlined on the grid. + Stop asks whether to keep the changes so far or undo them. While a macro is debugged the status + bar says "Debugging" with the macro's name, and Show reopens the editor. +- **Questions.** A macro that needs approval offers Run; a macro whose output would land on data + offers Overwrite, Shift Down and Shift Right. Each button runs the command again with + `approve: true` or the chosen `onConflict`, in the workbook that asked: an answer given while + another workbook is active does nothing and says why. +- **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that + letter. The letters GridCraft uses itself keep their own meaning. + +Commands with empty parameters never open these dialogs: `macro.record {}` starts recording +under the next free name, and the dialogs are opened only from the ribbon, the menus and keys. + +## Automate scripts and VBA + +Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, +`deleteScript`) are an older, separate feature. They keep their commands, parameters, storage +(`_sc_scripts`) and results, and View › Macros (`view.macros`) still lists them. Macro commands +neither read nor change their storage; a recording keeps the steps a script run ran while it +recorded. A workbook can hold a script and a macro with the same name. + +A VBA project in an `.xlsm` or `.xltm` file is preserved on save and never run. GridCraft macros +are stored apart from it. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee8bcfb75..71d75f659 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 275 / 291 commands implemented (94.5%).** +**Overall: 276 / 292 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -16,7 +16,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Formulas | 23 | 23 | 100% | | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | -| View | 20 | 20 | 100% | +| View | 21 | 21 | 100% | | Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | @@ -299,7 +299,7 @@ Missing: - `review.translate` Translate (Language) - `review.showChanges` Show Changes (Changes) -## View (20 / 20, 100%) +## View (21 / 21, 100%) Implemented: @@ -323,6 +323,7 @@ Implemented: - `view.unhideWindow` Unhide (Window) - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) +- `macro.record` Record Macro (Macros) ## Automate (4 / 4, 100%) diff --git a/docs/release-playbook.md b/docs/release-playbook.md deleted file mode 100644 index b563a2400..000000000 --- a/docs/release-playbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release playbook - -The release playbook shared by every crafting app lives in craftrules: -[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md) -(`storytold/craftrules`, `release/playbook.md`). It is the canonical copy; don't duplicate it -here. The signing runbook is next to it in `release/signing-setup.md`. - -GridCraft's copy was taken from DesignCraft (`.github/workflows/release.yml`, `packaging/`, -`cargo xtask version`). GridCraft-specific details are in [releasing.md](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index 479c2c0c4..00ee84a91 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,8 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o **draft** GitHub Release named `GridCraft v`. Nobody sees a draft until a maintainer publishes it. -This is GridCraft's implementation of the shared [release playbook](release-playbook.md) -(craftrules `release/playbook.md`), copied from DesignCraft and renamed. User-facing names say +This process was copied from DesignCraft and renamed. User-facing names say **GridCraft**. Files, binaries and ids stay lowercase (`gridcraft---.`, `ai.storyteller.gridcraft`). @@ -19,25 +18,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play | Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` | | App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `` | -## Prerequisites still owned by the app crates +## Application release prerequisites -The pipeline references crates that don't exist yet; the first release run fails until they do: +The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and +`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in +`.cargo/config.toml`. The current app-specific release setup includes: -- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk - site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`. -- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs` - and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the - file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon - fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs - `#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`. -- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them - with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*. -- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and +- **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with + `winresource` (instructions at the top of the file). `package.ps1` sets + `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that + `gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets + `windows_subsystem = "windows"` for release builds. +- **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog + use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports + `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata. +- **Runtime icon:** `apps/gridcraft/src/main.rs` embeds + `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and `hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and - use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`. -- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs - `.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use - `cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly. + uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`. ## Cutting a release @@ -122,7 +120,9 @@ open dist/release/gridcraft-*-macos-*.dmg is GUI, `gridcraft-cli.exe` console), signs both executables, builds the MSI with WiX v5, signs the MSI, and zips a portable build. -- `gridcraft.wxs`: per-machine install into Program Files, advertised Start Menu shortcut, App +- `gridcraft.wxs`: a dual-scope package (per-user by default into + `%LOCALAPPDATA%\Programs\ArtCraft\GridCraft`, per-machine into `Program Files\ArtCraft\GridCraft` (`Program Files (x86)` for the 32-bit build), + see `packaging/windows/INSTALLATION.md`), advertised Start Menu shortcut, App Paths (Win+R `gridcraft`). File types: ProgIds `GridCraft.Workbook` (`.xlsx`, `.xlsm`) and `GridCraft.TextData` (`.csv`, `.tsv`), listed under each extension's `OpenWithProgids` and in `Capabilities` + `RegisteredApplications`, so GridCraft appears in *Open with* and @@ -222,7 +222,7 @@ shows a `::warning::`. Never store these as org- or repo-level Actions secrets ( `GITHUB_TOKEN` creates the release; only the final job gets `contents: write`. -The exact commands (from craftrules `release/signing-setup.md`, steps 1–3), run by an org +The repository setup commands, run by an org admin. Done for storytold/gridcraft on 2026-10-07; the `release-managers` team also has write access to the repo, which its ruleset bypass needs. @@ -272,8 +272,7 @@ the publisher. ## Icons `assets/app-icon/gridcraft.svg` is a **placeholder** (an original ledger-page drawing on -GridCraft green) until the owner makes GridCraft's engraved creature portrait (craftrules -`standards/icon-design.md`). `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px +GridCraft green) until the owner makes GridCraft's engraved creature portrait. `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px PNG, the `.icns` (iconutil), the `.ico` (`cargo xtask ico`) and the hicolor PNGs from it; it needs `resvg`. The outputs are committed, so packaging never needs those tools. Every file has a row in `ATTRIBUTION.md` (`cargo xtask assets`). @@ -287,7 +286,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`). installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner. - `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`). -**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every -build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is -skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs. +**CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on +every push to `main` and every pull request. Release artifacts are built on `release` pushes or +manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml` +also runs on `main` and PRs. - Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 33150245a..7cee60f60 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-22 · **Change:** minor (several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) 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: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **274 / 290 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **276 / 292 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -54,7 +54,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus `SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only `ADD2`: **505**, all of them Excel names. -- **Ribbon catalog:** `crates/engine/src/catalog.rs` (290 unique ids across 15 tabs) against the +- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the 313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and identical to the committed `parity-checklist.md`. The catalog is our own transcription of Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format, @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | 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) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,11 +334,14 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | | 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) | | 2026-10-15 | minor | Row auto-fit: line breaks count only in wrapping cells (Excel: wrap off shows one line, AutoFit gives one line); unwrapped multi-line cells from files no longer grow rows on open (no score change) | +| 2026-10-21 | minor | Automation: typed macros in the engine and `macro.*` commands, no interface yet (no score change); catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro) | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f35899236..0e1a98f47 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (274 / 290). +separately in [parity-checklist.md](parity-checklist.md) (276 / 292). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -106,7 +106,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+S, Ctrl+Shift+S, Ctrl+O, Ctrl+N, Ctrl+W | Cmd | save, save as, open, new, close | present | | Ctrl+F, Ctrl+H, Ctrl+G, Ctrl+K, Ctrl+T | Cmd (Ctrl+H) | find, replace, go to, hyperlink, table | present | | Ctrl+D, Ctrl+R, Ctrl+E, Ctrl+1, Ctrl+9 / 0, Ctrl+Shift+9 / 0 | Cmd (Ctrl+E on Mac: see below) | fill down/right, Flash Fill, Format Cells, hide/unhide | present | -| Ctrl+- , Ctrl+Shift+= | Cmd | Delete / Insert cells dialog | present | +| Ctrl+- , Ctrl+Shift+= , Ctrl+numpad + | Cmd | Delete / Insert cells dialog | present | | Ctrl+; , Ctrl+` | Control+; | current date, show formulas | present (Mac Control+; added) | | Arrows, Shift / Ctrl / Ctrl+Shift+arrows, Home, Ctrl+Home / End, Page Up / Down, Ctrl+Page Up / Down | Cmd+arrows | navigation and extension | present | | Tab, Shift+Tab, Enter, Shift+Enter, Ctrl+Space, Shift+Space, Delete, Esc | same | entry and selection | present | @@ -142,7 +142,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | -| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | +| Alt+F8, Ctrl+Shift+letter | Option+F8, Cmd+Shift+letter | Macros dialog, a macro's own shortcut (letters GridCraft uses keep their meaning) | **added** | +| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F11 / F12 | same | group sheets, shape cycling, full screen, window keys, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as @@ -151,9 +152,15 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one ## Dialogs +Insert Hyperlink loads an existing cell link's address, display text and ScreenTip for editing +(#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel +keeps the original link and an edit can be undone in one step. + Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +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 +editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options, Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). @@ -187,8 +194,15 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | +| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | +| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | +| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | +| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | +| 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | +| 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` | | 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) | diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index ab98e9800..feb1fc406 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -24,20 +24,29 @@ while [[ $# -gt 0 ]]; do esac done -ARCH="$(uname -m)" -case "$ARCH" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; -esac +# Cross-builds (release workflow sets CARGO_BUILD_TARGET) take the arch from the target, not the host. +if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then + case "$CARGO_BUILD_TARGET" in + aarch64-unknown-freebsd) ARCH=aarch64 ;; + x86_64-unknown-freebsd) ARCH=x86_64 ;; + *) echo "unsupported CARGO_BUILD_TARGET $CARGO_BUILD_TARGET (expected x86_64-unknown-freebsd or aarch64-unknown-freebsd)" >&2; exit 2 ;; + esac +else + ARCH="$(uname -m)" + case "$ARCH" in + amd64 | x86_64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; + esac +fi BASENAME="gridcraft-$VERSION-freebsd-$ARCH" echo "==> GridCraft $VERSION for FreeBSD $ARCH" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) + (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"}) fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" WORK="$CARGO_TARGET_DIR/freebsd-package" TREE="$WORK/$BASENAME" rm -rf "$WORK" @@ -56,6 +65,7 @@ copy_docs "$TREE/share/doc/gridcraft" tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" echo "wrote $DIST/$BASENAME.tar.gz" -"$TREE/bin/gridcraft-cli" --version +# A cross-built binary cannot run on the x86_64 host. +if [[ -z "${CARGO_BUILD_TARGET:-}" ]]; then "$TREE/bin/gridcraft-cli" --version; fi echo "==> done" ls -lh "$DIST/$BASENAME.tar.gz" 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 diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml index 2903122ed..5926f32f7 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml @@ -17,22 +17,80 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: gridcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Network access for update checks against GitHub. + - --share=network # GPU access for rendering. - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Documents and Downloads are granted for # opening workbooks by path from the command line. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (the optional --control channel listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.gridcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: gridcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml index 81b3cf08f..e493d4855 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml @@ -18,24 +18,77 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: gridcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Network access for update checks against GitHub. + - --share=network # GPU access for rendering. - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Documents and Downloads are granted for # opening workbooks by path from the command line. - --filesystem=xdg-documents - --filesystem=xdg-download - # Deliberately not granted: network (the optional --control channel listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.gridcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: gridcraft buildsystem: simple build-options: diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 4ed6d9766..ae96b7d59 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,30 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + cs + de + el + es + fr + it + ja + ko + nl + pl + pt-BR + ro + ru + sr + tr + uk + vi + zh-Hans + CFBundleDisplayName GridCraft CFBundleName diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..cc9416e9c --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/gridcraft.wxs b/packaging/windows/gridcraft.wxs index dcc441be9..44340746a 100644 --- a/packaging/windows/gridcraft.wxs +++ b/packaging/windows/gridcraft.wxs @@ -1,34 +1,14 @@ - - - + + + - + @@ -42,16 +22,10 @@ execute sequence cannot reload the old choice after the user unchecks the box. --> - - - - - + + + + @@ -60,12 +34,16 @@ - - - + + + + + + + + + @@ -104,8 +82,7 @@ - + @@ -151,7 +128,9 @@ - + + + @@ -159,54 +138,55 @@ - + - + + + - + - + - + - - - - - - + + + + + + - - + + - - - - + + + + - - - - - - - + + + + + + + + @@ -216,16 +196,13 @@ - + - + - + diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..ebe988899 --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,150 @@ +{ + "id": "gridcraft", + "name": "GridCraft", + "upgradeCode": "5511327B-F305-41BE-92D0-EB84944E7C4D", + "sources": [ + "gridcraft.wxs" + ], + "icon": "assets/app-icon/gridcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "gridcraft-cli.exe", + "gridcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\gridcraft.exe", + "", + "[#GridcraftExe]" + ], + [ + "-1", + "Software\\Classes\\GridCraft.Workbook", + "", + "GridCraft Workbook" + ], + [ + "-1", + "Software\\Classes\\GridCraft.Workbook\\DefaultIcon", + "", + "\"[#GridcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\GridCraft.Workbook\\shell\\open\\command", + "", + "\"[#GridcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\GridCraft.TextData", + "", + "GridCraft Text Data" + ], + [ + "-1", + "Software\\Classes\\GridCraft.TextData\\DefaultIcon", + "", + "\"[#GridcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\GridCraft.TextData\\shell\\open\\command", + "", + "\"[#GridcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.xlsx\\OpenWithProgids", + "GridCraft.Workbook", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\Classes\\.xlsm\\OpenWithProgids", + "GridCraft.Workbook", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\Classes\\.xlsb\\OpenWithProgids", + "GridCraft.Workbook", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\Classes\\.ods\\OpenWithProgids", + "GridCraft.Workbook", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\Classes\\.csv\\OpenWithProgids", + "GridCraft.TextData", + "GridCraft.TextData" + ], + [ + "-1", + "Software\\Classes\\.tsv\\OpenWithProgids", + "GridCraft.TextData", + "GridCraft.TextData" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities", + "ApplicationName", + "GridCraft" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities", + "ApplicationDescription", + "Open-source spreadsheet for .xlsx, CSV and TSV files" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities\\FileAssociations", + ".xlsx", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities\\FileAssociations", + ".xlsm", + "GridCraft.Workbook" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities\\FileAssociations", + ".csv", + "GridCraft.TextData" + ], + [ + "-1", + "Software\\GridCraft\\Capabilities\\FileAssociations", + ".tsv", + "GridCraft.TextData" + ], + [ + "-1", + "Software\\RegisteredApplications", + "GridCraft", + "Software\\GridCraft\\Capabilities" + ], + [ + "-1", + "Software\\GridCraft\\Installer", + "DesktopShortcut", + "[DESKTOPSHORTCUT]" + ], + [ + "-1", + "Software\\GridCraft\\Installer", + "DesktopShortcutComponent", + "#1" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..04c1597e5 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index a1c2805b1..9a1303064 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -113,6 +113,8 @@ Invoke-Native 'wix build' { -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-desktop-shortcut.ps1 b/packaging/windows/test-desktop-shortcut.ps1 index f3c25b2c8..1eae4a7fd 100644 --- a/packaging/windows/test-desktop-shortcut.ps1 +++ b/packaging/windows/test-desktop-shortcut.ps1 @@ -16,7 +16,7 @@ if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows' -or $env:RUN } if (-not $WorkDir) { $WorkDir = Join-Path $env:RUNNER_TEMP 'gridcraft-shortcut-smoke' } $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path -$InstallDir = Join-Path $env:ProgramFiles 'GridCraft' +$InstallDir = Join-Path $env:ProgramFiles 'ArtCraft\GridCraft' $DesktopLink = Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'GridCraft.lnk' $StartLink = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'GridCraft.lnk' $ChoiceKey = 'HKLM:\Software\GridCraft\Installer' @@ -65,7 +65,8 @@ function Get-ProductCode([string] $Msi) { function Invoke-Msi([string] $Name, [string[]] $Arguments) { $log = Join-Path $WorkDir "$Name.log" # /norestart is unconditional. Each operation and the workflow have time limits. - $process = Start-Process msiexec.exe -ArgumentList ($Arguments + @('/qn', '/norestart', '/l*v', "`"$log`"")) -PassThru + # Keep this existing regression on the explicit all-users route, including cross-account removal. + $process = Start-Process msiexec.exe -ArgumentList ($Arguments + @('ALLUSERS=1', '/qn', '/norestart', '/l*v', "`"$log`"")) -PassThru try { if (-not $process.WaitForExit(120000)) { $process.Kill() diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..d455d19d1 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs index 344ed7569..99cbaa9ee 100644 --- a/xtask/src/i18n_report.rs +++ b/xtask/src/i18n_report.rs @@ -116,6 +116,55 @@ pub fn catalog_keys(source: &str) -> BTreeSet { keys } +/// The character a `\u{...}` escape stands for, if it names a valid code point. +fn unicode_escape(chars: &mut std::iter::Peekable>) -> Option { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) +} + +/// The value of a string literal whose opening quote was just read, and the byte offset past +/// its closing quote (`len`, the source's length, when it is never closed). +fn string_literal(chars: &mut std::iter::Peekable>, len: usize) -> (String, usize) { + let mut value = String::new(); + let mut end = len; + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + if let Some(c) = unicode_escape(chars) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + (value, end) +} + /// Every string literal outside `//` comments: its decoded value, and the byte range from its /// opening quote to just past its closing one. fn literals(source: &str) -> Vec<(String, usize, usize)> { @@ -133,44 +182,7 @@ fn literals(source: &str) -> Vec<(String, usize, usize)> { if c != '"' { continue; } - let mut value = String::new(); - let mut end = source.len(); - while let Some((j, c)) = chars.next() { - match c { - '"' => { - end = j + 1; - break; - } - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some(c @ ('\\' | '"' | '\'')) => value.push(c), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - match c { - '}' => break, - '{' => {} - c => hex.push(c), - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation skips the newline and the next line's indentation. - Some('\n') => { - while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { - chars.next(); - } - } - _ => {} - }, - c => value.push(c), - } - } + let (value, end) = string_literal(&mut chars, source.len()); out.push((value, i, end)); } out