mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-04 09:30:22 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7bd0c315 |
@@ -21,27 +21,3 @@ 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')
|
||||
|
||||
@@ -12,7 +12,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -24,5 +24,3 @@ jobs:
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-dir: GridCraft
|
||||
install-relative-path: Programs/ArtCraft/GridCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
@@ -18,10 +20,10 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Micros
|
||||
- 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.**
|
||||
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).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **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`).
|
||||
@@ -29,8 +31,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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.**
|
||||
@@ -49,7 +51,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 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 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`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
@@ -60,13 +62,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
username has a `[people.<username>]` 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)
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
+9
-11
@@ -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 | 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 | — | |
|
||||
| `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 | — | |
|
||||
| `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 `<image>`, 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") |
|
||||
@@ -46,5 +46,3 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font |
|
||||
| Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
@@ -18,19 +20,19 @@ GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Micros
|
||||
- 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.**
|
||||
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).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** 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`).
|
||||
- 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]`.
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.**
|
||||
@@ -48,9 +50,4 @@ 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 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.
|
||||
`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`.
|
||||
|
||||
Generated
+43
-262
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf 0.14.0",
|
||||
"phf",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -220,17 +220,11 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -474,30 +468,15 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -910,12 +889,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -938,10 +917,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
@@ -952,7 +930,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -972,14 +950,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -998,7 +975,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1478,7 +1455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1516,17 +1493,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1969,7 +1935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1996,7 +1962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2008,7 +1974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -2019,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -2031,7 +1997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2042,14 +2008,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"gridcraft-numfmt",
|
||||
"gridcraft-pdf",
|
||||
@@ -2059,13 +2024,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2074,25 +2038,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
"ratex-parser",
|
||||
"ratex-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2101,11 +2055,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-math",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -2114,18 +2067,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2144,13 +2097,12 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2167,13 +2119,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
@@ -3012,7 +2962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -3507,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3588,37 +3538,17 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
@@ -3626,20 +3556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3648,22 +3565,13 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
@@ -3939,7 +3847,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -3963,7 +3871,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3987,15 +3895,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -4034,66 +3933,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "ratex-font"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
"ratex-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-layout"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-parser",
|
||||
"ratex-types",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-lexer"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e"
|
||||
dependencies = [
|
||||
"ratex-types",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-parser"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0"
|
||||
dependencies = [
|
||||
"fancy-regex",
|
||||
"ratex-font",
|
||||
"ratex-lexer",
|
||||
"ratex-types",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-types"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4193,12 +4032,6 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -4348,7 +4181,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4407,7 +4240,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4476,17 +4309,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4790,7 +4612,7 @@ dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -5019,12 +4841,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5158,7 +4974,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5254,31 +5070,6 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -5550,15 +5341,6 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
@@ -5999,8 +5781,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -6072,7 +5854,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -6151,7 +5933,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6398,7 +6180,6 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6618,7 +6399,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+5
-14
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,11 +24,9 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
gridcraft-math = { path = "crates/math" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one storytold/craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -36,8 +34,7 @@ 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", "clock"] }
|
||||
web-time = "1.1.0"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -48,13 +45,6 @@ aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
|
||||
# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release.
|
||||
ratex-parser = "=0.1.14"
|
||||
ratex-layout = "=0.1.14"
|
||||
ratex-types = "=0.1.14"
|
||||
ratex-font = "=0.1.14"
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
@@ -71,7 +61,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -82,6 +72,7 @@ too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -274,34 +274,3 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -298,15 +298,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+5
-34
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
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 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **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%) |
|
||||
| **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; 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) |
|
||||
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -135,20 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
|
||||
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
|
||||
| 2026-11-02 | 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-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
|
||||
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
|
||||
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
|
||||
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
|
||||
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
| 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 |
|
||||
@@ -179,7 +166,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
@@ -193,7 +179,6 @@ 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) |
|
||||
@@ -246,7 +231,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; 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 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-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 |
|
||||
@@ -257,20 +242,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
|
||||
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
|
||||
| 2026-11-02 | minor | Progress log: Python cells as data |
|
||||
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
|
||||
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
|
||||
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
|
||||
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
|
||||
| 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) |
|
||||
@@ -286,7 +258,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
@@ -321,5 +292,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; progress docs restructured; 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; restructured to the progress-docs standard; 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 |
|
||||
|
||||
@@ -31,41 +31,20 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
|
||||
}
|
||||
|
||||
/// Upper-cased defined names whose formula is volatile, directly or through other names.
|
||||
///
|
||||
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
|
||||
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
|
||||
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
|
||||
/// did one full pass per name.
|
||||
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
|
||||
let parsed: Vec<(String, Option<Expr>)> =
|
||||
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
|
||||
let mut set = HashSet::new();
|
||||
// Upper-cased name -> the names whose formulas refer to it.
|
||||
let mut users: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut queue = Vec::new();
|
||||
let none = HashSet::new();
|
||||
for n in &wb.names {
|
||||
let name = n.name.to_ascii_uppercase();
|
||||
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
|
||||
if is_volatile(&e, &none) {
|
||||
if set.insert(name.clone()) {
|
||||
queue.push(name.clone());
|
||||
// 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());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
e.walk(&mut |x| {
|
||||
if let Expr::Name(r) = x {
|
||||
used.insert(r.to_ascii_uppercase());
|
||||
}
|
||||
});
|
||||
for r in used {
|
||||
users.entry(r).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
while let Some(v) = queue.pop() {
|
||||
for user in users.remove(&v).unwrap_or_default() {
|
||||
if set.insert(user.clone()) {
|
||||
queue.push(user);
|
||||
}
|
||||
if set.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
set
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- round trip
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ fn local_name(name: &[u8]) -> &[u8] {
|
||||
}
|
||||
|
||||
/// `(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<Vec<XmlElement>, String> {
|
||||
fn xml_elements(xml: &[u8]) -> Result<Vec<(String, Vec<(String, String)>)>, String> {
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_reader(xml);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -131,7 +131,7 @@ fn fit(s: &str, w: usize) -> String {
|
||||
if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::<String>()) }
|
||||
}
|
||||
|
||||
fn print_file_reports(reports: &[FileReport]) {
|
||||
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
for r in reports {
|
||||
outln!("== {}", r.path);
|
||||
if let Some(e) = &r.error {
|
||||
@@ -186,9 +186,7 @@ fn print_file_reports(reports: &[FileReport]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_file_table(reports: &[FileReport]) {
|
||||
outln!("");
|
||||
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
|
||||
outln!(
|
||||
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
|
||||
@@ -222,9 +220,7 @@ fn print_file_table(reports: &[FileReport]) {
|
||||
c.volatile
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_rankings(t: &Totals, opts: &Options) {
|
||||
let t = totals(reports);
|
||||
let top = ranked(&t.functions);
|
||||
if !top.is_empty() {
|
||||
outln!("");
|
||||
@@ -243,9 +239,6 @@ fn print_rankings(t: &Totals, opts: &Options) {
|
||||
outln!(" {n:>8} {sig}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_totals(t: &Totals) {
|
||||
outln!("");
|
||||
outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed);
|
||||
outln!(
|
||||
@@ -269,12 +262,3 @@ fn print_totals(t: &Totals) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -178,28 +178,6 @@ fn volatile_names_propagate() {
|
||||
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
|
||||
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
|
||||
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
|
||||
// volatile; a name off the chain is not.
|
||||
const LEN: usize = 5_000;
|
||||
let mut wb = book(&[("A1", "=1", n(1.0))]);
|
||||
let name =
|
||||
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
for i in 0..LEN {
|
||||
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
|
||||
wb.names.push(name(format!("Chain{i}"), formula));
|
||||
}
|
||||
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
|
||||
wb.names.push(name("Other".into(), "1".into()));
|
||||
let set = volatile_names(&wb);
|
||||
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
|
||||
assert!(set.contains("PLAIN"));
|
||||
assert!(!set.contains("OTHER"));
|
||||
assert_eq!(set.len(), LEN + 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"))]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 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;
|
||||
|
||||
@@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
@@ -230,9 +230,8 @@ 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. 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");
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
||||
@@ -24,26 +24,22 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
arboard = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
|
||||
@@ -56,10 +52,6 @@ muda = { version = "0.21", default-features = false }
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda,
|
||||
# winit); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.5"
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
|
||||
# pinned exactly to the same versions.
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSRequestUserAttentionType};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
@@ -46,32 +44,7 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
|
||||
Event::Quit => {
|
||||
app.request_quit(ctx);
|
||||
if app.dialog.is_some() {
|
||||
bring_app_forward();
|
||||
}
|
||||
}
|
||||
Event::Quit => app.request_quit(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to
|
||||
/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and
|
||||
/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock
|
||||
/// icon until the user switches over.
|
||||
fn bring_app_forward() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
ns_app.unhide(None);
|
||||
for window in ns_app.windows().to_vec() {
|
||||
if window.isMiniaturized() {
|
||||
window.deminiaturize(None);
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version.
|
||||
ns_app.activateIgnoringOtherApps(true);
|
||||
if !ns_app.isActive() {
|
||||
ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,47 +12,16 @@
|
||||
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, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
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, in total.
|
||||
/// How long a new connection may take to send its token.
|
||||
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<AtomicUsize>);
|
||||
|
||||
impl Pending {
|
||||
/// A place among the `max` waiting connections, or `None` when they are all taken.
|
||||
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
|
||||
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.
|
||||
@@ -82,75 +51,35 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
|
||||
}
|
||||
|
||||
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
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() {
|
||||
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 };
|
||||
for stream in listener.incoming().flatten() {
|
||||
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, limits.timeout, Some(place)));
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
|
||||
/// `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<TcpStream>, 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),
|
||||
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, 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),
|
||||
_ => 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 _ = reader.get_ref().set_read_timeout(None);
|
||||
let _ = out.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<TcpStream>, line: &mut Vec<u8>, 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<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -219,14 +148,11 @@ 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<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
|
||||
// Authenticated or refused, the connection no longer counts as waiting.
|
||||
drop(place);
|
||||
if !allowed {
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
@@ -368,7 +294,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(), AUTH_TIMEOUT, None));
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
addr
|
||||
@@ -420,81 +346,6 @@ 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::<Json>(&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::<Json>(&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());
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! 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.
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
//!
|
||||
//! 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
|
||||
@@ -9,38 +8,12 @@
|
||||
//! 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, InstanceFlags},
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler},
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
@@ -168,55 +141,6 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
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);
|
||||
|
||||
@@ -268,10 +268,9 @@ 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();
|
||||
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());
|
||||
#[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());
|
||||
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());
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
|
||||
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
@@ -165,13 +165,9 @@ fn services() -> Services {
|
||||
})),
|
||||
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
|
||||
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
|
||||
paste_image: Some(Box::new(move || {
|
||||
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
|
||||
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
||||
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
|
||||
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
|
||||
@@ -260,7 +256,7 @@ fn main() -> eframe::Result<()> {
|
||||
}));
|
||||
let args: Vec<String> = 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(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("GridCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -315,16 +311,15 @@ 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.
|
||||
@@ -345,11 +340,8 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
let window_builder = viewport.clone();
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
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.
|
||||
|
||||
@@ -390,12 +390,11 @@ impl NativeMenu {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// 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.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_menu_has_export_as_pdf_dispatching_the_command() {
|
||||
let tree = super::tree();
|
||||
let tree = 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");
|
||||
@@ -404,23 +403,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = super::tree();
|
||||
let tree = 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 = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = 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 = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
for mode in ["dark", "system", "light"] {
|
||||
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert!(select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert_eq!(app.ui.theme_mode(), mode);
|
||||
assert!(app.message.is_none());
|
||||
}
|
||||
assert!(!super::select_theme(&mut app, "view.formulaBar"));
|
||||
assert!(!select_theme(&mut app, "view.formulaBar"));
|
||||
assert_eq!(app.ui.theme_mode(), "light");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// 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")]
|
||||
@@ -19,23 +20,12 @@ fn main() {
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut menu = native_menu::NativeMenu::install(&ctx);
|
||||
let _menu = native_menu::NativeMenu::install(&ctx);
|
||||
|
||||
let bar = nsapp.mainMenu().expect("a main menu");
|
||||
let tops: Vec<String> = (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"]);
|
||||
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");
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
@@ -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; GridCraft's hasn't been made yet.
|
||||
product owner makes (craftrules `standards/icon-design.md`); 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||
Palette: ink #0b0b0c, paper #efe9dc,
|
||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
||||
app colour #1f9d55 (proposed). -->
|
||||
<defs>
|
||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||
Palette: ink #0b0b0c, paper #efe9dc,
|
||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
||||
app colour #1f9d55 (proposed). -->
|
||||
<defs>
|
||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -10,8 +10,4 @@ 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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Contributor names for the About window.
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
# 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):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -100,12 +103,3 @@ 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
|
||||
|
||||
[people.maxm11]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare};
|
||||
use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
use gridcraft_functions::{Arg, Ctx};
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
@@ -359,10 +359,9 @@ impl<'h> Evaluator<'h> {
|
||||
return v.clone();
|
||||
}
|
||||
let wb = self.host.workbook();
|
||||
// Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as
|
||||
// is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`).
|
||||
let (scope_sheet, bare) = match n.rsplit_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(s) {
|
||||
// Sheet-qualified `Sheet1!Name`.
|
||||
let (scope_sheet, bare) = match n.split_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
|
||||
Some(i) => (i, b),
|
||||
None => return err(CellError::Name),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
|
||||
use super::*;
|
||||
|
||||
/// The aggregates that can fold a reference without building an array.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -89,14 +89,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
|
||||
/// Each operand passes its own size cap, but their broadcast product can be far larger
|
||||
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
|
||||
/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above
|
||||
/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap.
|
||||
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
|
||||
let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?;
|
||||
if cells > MAX_CELLS {
|
||||
return None;
|
||||
}
|
||||
usize::try_from(cells).ok()
|
||||
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
|
||||
}
|
||||
|
||||
fn broadcast_dim(a: usize, b: usize) -> usize {
|
||||
@@ -130,7 +124,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &
|
||||
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
|
||||
pub(super) fn single(v: Value) -> Option<Value> {
|
||||
match v {
|
||||
Value::Array(a) if a.data.len() > 1 => None,
|
||||
Value::Array(a) if a.rows * a.cols > 1 => None,
|
||||
v => Some(v.scalar()),
|
||||
}
|
||||
}
|
||||
@@ -184,24 +178,3 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
|
||||
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_CELLS, broadcast_len};
|
||||
|
||||
#[test]
|
||||
fn broadcast_len_caps_at_max_cells() {
|
||||
assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize));
|
||||
assert_eq!(broadcast_len(4_000, 4_001), None);
|
||||
assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize));
|
||||
}
|
||||
|
||||
/// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a
|
||||
/// `> MAX_CELLS` test would let through; the guard must reject the true product.
|
||||
#[test]
|
||||
fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() {
|
||||
assert_eq!(65_536u32.wrapping_mul(65_536), 0);
|
||||
assert_eq!(broadcast_len(65_536, 65_536), None);
|
||||
assert_eq!(broadcast_len(1_048_576, 16_384), None);
|
||||
}
|
||||
}
|
||||
|
||||
+147
-158
@@ -1,13 +1,40 @@
|
||||
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;
|
||||
use super::*;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
match name {
|
||||
"IF" => self.if_call(args),
|
||||
"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);
|
||||
@@ -23,10 +50,101 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
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),
|
||||
"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<Value> = (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<Value> = (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);
|
||||
@@ -62,7 +180,28 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"SHEET" | "SHEETS" => self.sheet_call(name, args),
|
||||
"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<usize> = 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),
|
||||
@@ -134,156 +273,6 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Value> = (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<Value> = (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<usize> = 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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::{CellError, Value};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
|
||||
use super::*;
|
||||
|
||||
/// CELL("format") code for a number format.
|
||||
pub(super) fn cell_format_code(code: &str) -> String {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
use super::*;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
use super::*;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use super::*;
|
||||
|
||||
/// 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 {
|
||||
@@ -163,3 +162,6 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
|
||||
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) {}
|
||||
|
||||
+111
-176
@@ -10,13 +10,14 @@
|
||||
//! and the formulas that read one are evaluated again until the loop settles
|
||||
//! (`PassHost::iterate_loops`).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{CalcMode, Workbook};
|
||||
|
||||
use crate::eval::{Evaluator, Host, precedents, stack_position};
|
||||
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
|
||||
|
||||
mod graph;
|
||||
mod pass_host;
|
||||
@@ -135,10 +136,6 @@ impl Calc {
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
}
|
||||
} else if cell.py.is_some() {
|
||||
// A Python cell lays out its kept result, so it is calculated like a formula
|
||||
// that reads nothing: its spill is blocked and unblocked like a formula's.
|
||||
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,29 +207,11 @@ impl Calc {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
None if python => {
|
||||
self.graph.insert(k, Node { areas: Vec::new(), dynamic: false });
|
||||
// New code has no result yet: the spill the cell had (its old result's, or a
|
||||
// formula's) goes, and what read it is recalculated, as for a formula
|
||||
// replaced by a constant.
|
||||
if !spills
|
||||
&& let Some(sh) = wb.sheet_mut(k.0)
|
||||
&& let Some(r) = sh.spill_ranges.remove(&k.1)
|
||||
{
|
||||
sh.clear_spill(r);
|
||||
cleared.push((k.0, r));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.graph.remove(k);
|
||||
// A formula replaced by a constant (or cleared) takes its spilled values with it.
|
||||
@@ -360,15 +339,11 @@ impl Calc {
|
||||
exprs.insert(*k, e);
|
||||
}
|
||||
}
|
||||
let python: HashMap<Key, Value> = dirty
|
||||
.iter()
|
||||
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
|
||||
.collect();
|
||||
let now = self.now();
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.calculate(*k);
|
||||
@@ -393,13 +368,105 @@ impl Calc {
|
||||
self.circular.retain(|k| !evaluated.contains(k));
|
||||
}
|
||||
self.circular.extend(cycles);
|
||||
let mut spill_changes = self.write_back(wb, &dirty, results, &spills);
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = 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));
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
@@ -421,105 +488,6 @@ impl Calc {
|
||||
self.circular.retain(|k| self.graph.nodes.contains_key(k));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_back(
|
||||
&mut self,
|
||||
wb: &mut Workbook,
|
||||
dirty: &[Key],
|
||||
results: HashMap<Key, Value>,
|
||||
spills: &HashMap<Key, std::sync::Arc<Array>>,
|
||||
) -> Vec<Key> {
|
||||
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).
|
||||
@@ -545,13 +513,21 @@ 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 {
|
||||
gridcraft_core::date::unix_now_ms().unwrap_or(0.0)
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
@@ -573,48 +549,6 @@ pub(crate) mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
|
||||
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
|
||||
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
|
||||
let n = 1_000usize;
|
||||
let data: Vec<Value> = (0..n * n).map(|i| Value::Number(i as f64)).collect();
|
||||
let array = gridcraft_core::Array::new(n, n, data.clone()).unwrap();
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let anchor = CellRef::new(0, 0);
|
||||
let py = gridcraft_model::PyCode::new("big");
|
||||
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
|
||||
let area = RangeRef::new(anchor, CellRef::new(n as u32 - 1, n as u32 - 1));
|
||||
sh.spill_ranges.insert(anchor, area);
|
||||
for (i, c) in area.iter().enumerate().skip(1) {
|
||||
sh.spill.insert(c, data[i].clone());
|
||||
}
|
||||
let mut python = HashMap::default();
|
||||
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
|
||||
(wb, python)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_beside_a_large_spill_walks_none_of_it() {
|
||||
let (wb, python) = big_python_spill();
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
|
||||
assert_eq!(host.cell_value(0, CellRef::new(0, 0)), Value::Number(0.0));
|
||||
// Beside the spill (column ALM, the 1,001st), over the rows it covers.
|
||||
let beside = RangeRef::parse("ALM1:ALM1000").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert!(host.range_values(0, beside).iter().all(Value::is_empty));
|
||||
assert!(host.visit_range(0, beside, &mut |_| true), "a disjoint read takes the fast path");
|
||||
assert_eq!(host.spill_visits, 0, "nothing of the spill is walked");
|
||||
// A read inside it still sees the spilled values, walking only the cells it reads.
|
||||
let inside = RangeRef::parse("B2:C3").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert_eq!(host.range_values(0, inside), [1_001.0, 1_002.0, 2_001.0, 2_002.0].map(Value::Number).to_vec());
|
||||
assert!(host.spill_visits <= 8, "{}", host.spill_visits);
|
||||
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
@@ -706,10 +640,11 @@ 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 started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0);
|
||||
let t = Instant::now();
|
||||
calc.recalc_all(&mut wb);
|
||||
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
@@ -720,8 +655,7 @@ pub(crate) mod tests {
|
||||
calc.cells_changed(&mut wb, &values);
|
||||
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
|
||||
calc.cells_changed(&mut wb, &formulas);
|
||||
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 elapsed = t.elapsed();
|
||||
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::<u32>()))));
|
||||
@@ -736,6 +670,7 @@ 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
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use super::RangeRef;
|
||||
use crate::eval::Area;
|
||||
use gridcraft_core::CellRef;
|
||||
use std::collections::BTreeSet;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
use super::*;
|
||||
|
||||
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
@@ -183,9 +179,6 @@ pub struct Graph {
|
||||
unsettled_wide: HashSet<usize>,
|
||||
}
|
||||
|
||||
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
|
||||
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
|
||||
|
||||
impl Graph {
|
||||
pub fn len(&self) -> usize {
|
||||
self.nodes.len()
|
||||
@@ -196,7 +189,7 @@ impl Graph {
|
||||
|
||||
/// Index entries of a formula's precedents, without duplicates: single cells, column
|
||||
/// intervals and wide ranges.
|
||||
fn entries(areas: &[Area]) -> Entries {
|
||||
fn entries(areas: &[Area]) -> (Vec<Key>, 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() {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
use super::*;
|
||||
|
||||
/// 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
|
||||
@@ -45,10 +43,6 @@ pub(super) struct PassHost<'a> {
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
pub(super) suspensions: usize,
|
||||
/// Spilled values looked at by range reads (tests check that a read outside a large spill
|
||||
/// looks at none of it).
|
||||
#[cfg(test)]
|
||||
pub(super) spill_visits: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
@@ -68,10 +62,6 @@ pub(super) struct PassHost<'a> {
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
|
||||
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
|
||||
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
|
||||
python: HashMap<Key, Value>,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -95,8 +85,6 @@ impl<'a> PassHost<'a> {
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
#[cfg(test)]
|
||||
spill_visits: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
@@ -106,17 +94,8 @@ impl<'a> PassHost<'a> {
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
|
||||
/// are pending until laid out, so every read path calculates them before reading them.
|
||||
pub(super) fn with_python(mut self, python: HashMap<Key, Value>) -> 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,
|
||||
@@ -130,10 +109,7 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
let own = sh.spill_ranges.get(&anchor);
|
||||
let spilled_over = |c: CellRef| {
|
||||
!own.is_some_and(|o| o.contains(c))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| {
|
||||
// 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))
|
||||
})
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
|
||||
};
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
@@ -157,42 +133,6 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
/// The area an array anchored at `anchor` covers.
|
||||
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
|
||||
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)));
|
||||
RangeRef::new(anchor, end)
|
||||
}
|
||||
|
||||
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
|
||||
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
|
||||
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
|
||||
/// Returns how many values were looked at.
|
||||
fn stored_spill_in<'s>(sheet: &'s gridcraft_model::Sheet, range: RangeRef, f: &mut dyn FnMut(CellRef, &'s Value) -> bool) -> usize {
|
||||
let mut seen = 0usize;
|
||||
if sheet.spill.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if (range.height() as usize) < sheet.spill.len() {
|
||||
for row in range.start.row..=range.end.row {
|
||||
for (c, v) in sheet.spill.range(CellRef::new(row, range.start.col)..=CellRef::new(row, range.end.col)) {
|
||||
seen += 1;
|
||||
if !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (c, v) in sheet.spill.range(range.start..=range.end) {
|
||||
seen += 1;
|
||||
if range.contains(*c) && !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
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
|
||||
@@ -246,9 +186,6 @@ impl PassHost<'_> {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if let Some(result) = self.python.get(&k).cloned() {
|
||||
return self.python_result(k, result);
|
||||
}
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
@@ -366,36 +303,6 @@ impl PassHost<'_> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A Python cell's kept result as its value: an array spills from the cell unless it is
|
||||
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
|
||||
/// crafted file holds one), which shows `#SPILL!`.
|
||||
fn python_result(&mut self, k: Key, result: Value) -> Value {
|
||||
self.pending.remove(&k);
|
||||
let v = match result {
|
||||
Value::Array(a) => {
|
||||
let sane = a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
|
||||
if sane {
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
}
|
||||
v => v,
|
||||
};
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A formula's previous value for iterative calculation: from the last iteration, else the
|
||||
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
|
||||
fn previous(&self, k: Key) -> Value {
|
||||
@@ -536,29 +443,26 @@ impl Host for PassHost<'_> {
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
let mut out = vec![Value::Empty; h * w];
|
||||
let Some(sh) = self.wb.sheet(sheet) else { return out };
|
||||
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
|
||||
// through `compute`, constants are copied straight from the store.
|
||||
// Sparse walk: only stored cells; formula 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() {
|
||||
if cell.formula.is_some() {
|
||||
formulas.push((i, c));
|
||||
} else if let Some(slot) = out.get_mut(i) {
|
||||
*slot = cell.value.clone();
|
||||
}
|
||||
}
|
||||
let _seen = stored_spill_in(sh, range, &mut |c, v| {
|
||||
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 (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();
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
for (i, c) in formulas {
|
||||
let v = self.cell_value(sheet, c);
|
||||
@@ -567,31 +471,26 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
if !self.spills.is_empty() {
|
||||
// Only the part of each array inside the range is walked: a large array beside a small
|
||||
// read costs nothing.
|
||||
let mut new: Vec<(usize, Value)> = Vec::new();
|
||||
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
|
||||
let area = array_area(a.1, arr);
|
||||
if !area.intersects(&range) {
|
||||
continue;
|
||||
}
|
||||
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
|
||||
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
|
||||
for row in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
let c = CellRef::new(row, col);
|
||||
if c == a.1 {
|
||||
continue;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
|
||||
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += new.len();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
@@ -615,31 +514,21 @@ impl Host for PassHost<'_> {
|
||||
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
let wb = self.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return false };
|
||||
// Formula 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.
|
||||
// Formula cells first, in row-major order like `range_values`: reading one calculates it
|
||||
// and the pass remembers the value, so the walk below gets the same value again without
|
||||
// this method holding any.
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
if cell.formula.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`.
|
||||
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
|
||||
let mut stored = false;
|
||||
let _seen = stored_spill_in(sh, range, &mut |_, _| {
|
||||
stored = true;
|
||||
false
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
if arrays || stored {
|
||||
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) };
|
||||
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -614,25 +614,6 @@ 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();
|
||||
@@ -1251,25 +1232,6 @@ fn array_broadcast_result_is_capped() {
|
||||
assert_eq!(t.num("C3"), 5.0);
|
||||
}
|
||||
|
||||
/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the
|
||||
/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in
|
||||
/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at
|
||||
/// 16384 columns, so the widest product it can build is 16384 squared).
|
||||
#[test]
|
||||
fn map_caps_its_result_size() {
|
||||
let mut t = T::new();
|
||||
for (at, formula) in
|
||||
[("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")]
|
||||
{
|
||||
t.set(at, formula);
|
||||
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
|
||||
}
|
||||
// Within the cap the broadcast shape is unchanged.
|
||||
t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
|
||||
t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
|
||||
assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_function() {
|
||||
let mut t = T::new();
|
||||
@@ -2312,63 +2274,3 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
use gridcraft_model::python::{PyCode, PyOutput};
|
||||
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
|
||||
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
|
||||
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
|
||||
(0, c(at))
|
||||
}
|
||||
|
||||
fn column(values: &[f64]) -> Value {
|
||||
let data = values.iter().map(|v| Value::Number(*v)).collect();
|
||||
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
|
||||
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
|
||||
// it in the pass; each must lay it out first rather than read its old value, 1.
|
||||
let build = |t: &mut T| {
|
||||
t.set("B2", "10");
|
||||
t.set("B3", "100");
|
||||
t.set("A1", "=B1");
|
||||
t.set("A2", "=MAX(B1:B3)");
|
||||
t.set("A3", "=SUM(B1:B3)");
|
||||
t.set("A4", "=COUNTA(B1:B3)");
|
||||
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
|
||||
};
|
||||
let check = |t: &T, how: &str| {
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
|
||||
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
|
||||
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
|
||||
assert_eq!(t.num("A4"), 3.0, "{how}");
|
||||
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
|
||||
};
|
||||
|
||||
// A full recalculation.
|
||||
let mut t = T::new();
|
||||
build(&mut t);
|
||||
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
check(&t, "full");
|
||||
|
||||
// An incremental one: B1 becomes a Python cell after its readers.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "1");
|
||||
build(&mut t);
|
||||
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
|
||||
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.cells_changed(&mut t.wb, &[k]);
|
||||
check(&t, "incremental");
|
||||
|
||||
// Unblocked, the readers see the new array.
|
||||
t.set("B2", "");
|
||||
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 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, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, 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};
|
||||
@@ -14,7 +15,6 @@ mod frame;
|
||||
mod kinds;
|
||||
mod legend;
|
||||
mod lines;
|
||||
mod table;
|
||||
mod trend;
|
||||
|
||||
use cartesian::{cartesian, scatter};
|
||||
@@ -70,7 +70,8 @@ impl Ctx<'_> {
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +135,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;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
|
||||
use super::legend::{cat_label, plot_kind};
|
||||
use super::lines::{decimate_area, draw_markers, draw_runs};
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
|
||||
|
||||
@@ -21,9 +18,6 @@ enum Fam {
|
||||
Area100,
|
||||
}
|
||||
|
||||
/// Stack accumulator: (axis, family) key with positive and negative running sums.
|
||||
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
|
||||
|
||||
fn family(k: ChartKind) -> Fam {
|
||||
use ChartKind::*;
|
||||
match k {
|
||||
@@ -51,7 +45,16 @@ fn is_line(f: Fam) -> bool {
|
||||
matches!(f, Fam::Line | Fam::LineStack)
|
||||
}
|
||||
|
||||
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
|
||||
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<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = 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<f64>)> = Vec::new();
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
@@ -78,7 +81,7 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
};
|
||||
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
||||
let mut stacks: Vec<Stack> = Vec::new();
|
||||
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
||||
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
@@ -123,10 +126,6 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
|
||||
spans.push(row);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
|
||||
// Axis scales.
|
||||
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
|
||||
for axis in [false, true] {
|
||||
@@ -152,13 +151,35 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
|
||||
break;
|
||||
}
|
||||
}
|
||||
axes
|
||||
}
|
||||
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<String> = (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<Vec<String>> =
|
||||
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 };
|
||||
|
||||
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, 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()];
|
||||
let mut slot_of = vec![0usize; data.series.len()];
|
||||
for (i, f) in fams.iter().enumerate() {
|
||||
if !is_col(*f) {
|
||||
continue;
|
||||
@@ -176,51 +197,7 @@ fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, f32) {
|
||||
*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<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = 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<String> = (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<Vec<String>> =
|
||||
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
|
||||
let (area, table) = super::table::reserve(ctx, chart, data, area, ncat_slots);
|
||||
let category_axis = super::table::category_axis(&table, &labels, &outer);
|
||||
let plot = frame(ctx, area, category_axis, &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 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.
|
||||
@@ -268,7 +245,10 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
|
||||
}
|
||||
}
|
||||
let poly = decimate_area(top, base, MAX_LINE_PTS * 2);
|
||||
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 });
|
||||
}
|
||||
@@ -295,20 +275,20 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,7 +304,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
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));
|
||||
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,7 +312,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
}
|
||||
for (x, y, t) in area_labels.drain(..) {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
// Trendlines on top: x is the category number 1..n.
|
||||
@@ -354,7 +334,6 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
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);
|
||||
super::table::draw(ctx, chart, data, plot, table.as_ref());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scatter & bubble
|
||||
@@ -433,7 +412,7 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
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));
|
||||
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Bx {
|
||||
@@ -44,7 +44,7 @@ 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));
|
||||
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()) {
|
||||
@@ -74,14 +74,12 @@ 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<String>]),
|
||||
/// A data table supplies the labels below a vertical chart. Its stub must clear the plot.
|
||||
Table(&'a [String], f32),
|
||||
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).
|
||||
pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
|
||||
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
|
||||
let mut starts: Vec<usize> = (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()
|
||||
@@ -103,13 +101,9 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let ls = LABEL_SIZE;
|
||||
let lh = ls * 1.4;
|
||||
let ylabels = y.labels();
|
||||
let stub = match &cat {
|
||||
CatAxis::Table(_, width) => *width,
|
||||
_ => 0.0,
|
||||
};
|
||||
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).max(stub);
|
||||
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),
|
||||
@@ -118,7 +112,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
};
|
||||
let levels = match &cat {
|
||||
CatAxis::Labels(_, o) => o.len() as f32,
|
||||
CatAxis::Numeric(..) | CatAxis::Table(..) => 0.0,
|
||||
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() {
|
||||
@@ -129,19 +123,18 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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));
|
||||
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::Table(..) => {}
|
||||
CatAxis::Labels(l, outer) => {
|
||||
let n = l.len().max(1);
|
||||
let cw = plot.w / n as f32;
|
||||
@@ -149,7 +142,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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.
|
||||
@@ -159,7 +152,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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);
|
||||
@@ -176,7 +169,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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;
|
||||
@@ -188,7 +181,6 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
} else {
|
||||
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
|
||||
CatAxis::Labels(l, o) => (l.to_vec(), o),
|
||||
CatAxis::Table(l, _) => (l.to_vec(), &[]),
|
||||
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);
|
||||
@@ -197,8 +189,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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 left = (lw + levels_w).max(stub);
|
||||
let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - right, h: area.h - lh - 6.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;
|
||||
}
|
||||
@@ -211,7 +202,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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 };
|
||||
@@ -222,7 +213,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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.
|
||||
@@ -233,7 +224,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
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));
|
||||
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);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- histogram
|
||||
|
||||
@@ -50,7 +47,7 @@ pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
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));
|
||||
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +110,7 @@ pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
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));
|
||||
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,10 +150,10 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
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));
|
||||
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));
|
||||
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +194,7 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
a += sweep;
|
||||
}
|
||||
for (x, y, t, c) in labels {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,11 +281,11 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
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));
|
||||
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));
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,7 +325,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
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));
|
||||
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);
|
||||
@@ -352,7 +349,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
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));
|
||||
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();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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;
|
||||
use super::*;
|
||||
|
||||
/// How a legend entry's swatch is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -85,7 +81,7 @@ fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
if sw == Swatch::Box { s } else { s * 2.0 }
|
||||
}
|
||||
|
||||
pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
|
||||
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),
|
||||
@@ -136,7 +132,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
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));
|
||||
ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
|
||||
}
|
||||
x += iw + spacing;
|
||||
}
|
||||
@@ -156,7 +152,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
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));
|
||||
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 } }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::frame::Bx;
|
||||
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
|
||||
use std::collections::HashSet;
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
@@ -54,86 +53,6 @@ pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Decimates the top and baseline of an area chart together at the same horizontal indices so
|
||||
/// `area_mesh`'s paired vertical-strip topology (`pts[i]` ↔ `pts[2n - 1 - i]`) is preserved.
|
||||
pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
||||
let n = top.len().min(base.len());
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if n <= max {
|
||||
let mut poly = top;
|
||||
poly.truncate(n);
|
||||
poly.extend(base.into_iter().take(n).rev());
|
||||
return poly;
|
||||
}
|
||||
let mut cols: Vec<[usize; 6]> = Vec::new();
|
||||
let mut keep = Vec::with_capacity(max * 2);
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let col = top[i][0].floor();
|
||||
let (start, mut t_mn, mut t_mx, mut b_mn, mut b_mx) = (i, i, i, i, i);
|
||||
let mut k = i + 1;
|
||||
while k < n && top[k][0].floor() == col {
|
||||
if top[k][1] < top[t_mn][1] {
|
||||
t_mn = k;
|
||||
}
|
||||
if top[k][1] > top[t_mx][1] {
|
||||
t_mx = k;
|
||||
}
|
||||
if base[k][1] < base[b_mn][1] {
|
||||
b_mn = k;
|
||||
}
|
||||
if base[k][1] > base[b_mx][1] {
|
||||
b_mx = k;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
let entry = [start, t_mn, t_mx, b_mn, b_mx, k - 1];
|
||||
cols.push(entry);
|
||||
let mut idx = entry;
|
||||
idx.sort_unstable();
|
||||
let mut last = usize::MAX;
|
||||
for ix in idx {
|
||||
if ix != last {
|
||||
keep.push(ix);
|
||||
last = ix;
|
||||
}
|
||||
}
|
||||
i = k;
|
||||
}
|
||||
if keep.len() > max * 4 {
|
||||
let max_groups = (max * 4 / 6).max(1);
|
||||
let group_size = cols.len().div_ceil(max_groups);
|
||||
keep.clear();
|
||||
for ch in cols.chunks(group_size) {
|
||||
let start = ch[0][0];
|
||||
let end = ch.last().map_or(start, |c| c[5]);
|
||||
let t_mn = ch.iter().map(|c| c[1]).min_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
|
||||
let t_mx = ch.iter().map(|c| c[2]).max_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
|
||||
let b_mn = ch.iter().map(|c| c[3]).min_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
|
||||
let b_mx = ch.iter().map(|c| c[4]).max_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
|
||||
let mut idx = [start, t_mn, t_mx, b_mn, b_mx, end];
|
||||
idx.sort_unstable();
|
||||
let mut last = usize::MAX;
|
||||
for ix in idx {
|
||||
if ix != last {
|
||||
keep.push(ix);
|
||||
last = ix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut poly = Vec::with_capacity(keep.len() * 2);
|
||||
for &ix in &keep {
|
||||
poly.push(top[ix]);
|
||||
}
|
||||
for &ix in keep.iter().rev() {
|
||||
poly.push(base[ix]);
|
||||
}
|
||||
poly
|
||||
}
|
||||
|
||||
/// 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<Bx>) {
|
||||
@@ -235,57 +154,4 @@ mod smooth_tests {
|
||||
assert!(out.len() > 5);
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_area_preserves_paired_vertical_strips() {
|
||||
let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect();
|
||||
let base: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, 100.0]).collect();
|
||||
let poly = decimate_area(top, base, 64);
|
||||
assert_eq!(poly.len() % 2, 0);
|
||||
let half = poly.len() / 2;
|
||||
for i in 0..half {
|
||||
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
|
||||
assert_eq!(poly[poly.len() - 1 - i][1], 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_area_preserves_extrema_across_3000_columns() {
|
||||
// 3,000 occupied pixel columns with 6 points per column (18,000 points total),
|
||||
// where each column has distinct top and baseline extrema so the initial per-column
|
||||
// index list exceeds `MAX_LINE_PTS * 2 * 4` (6,400).
|
||||
let max = MAX_LINE_PTS * 2;
|
||||
let mut top = Vec::with_capacity(18_000);
|
||||
let mut base = Vec::with_capacity(18_000);
|
||||
for col in 0..3_000 {
|
||||
let y_top = [50.0, 20.0, 80.0, 50.0, 50.0, 50.0];
|
||||
let y_base = [150.0, 150.0, 150.0, 120.0, 180.0, 150.0];
|
||||
for s in 0..6 {
|
||||
let x = col as f32 + s as f32 * 0.1;
|
||||
top.push([x, y_top[s]]);
|
||||
base.push([x, y_base[s]]);
|
||||
}
|
||||
}
|
||||
// Plant sharp spikes on both top and baseline in interior columns.
|
||||
top[777 * 6 + 1][1] = 1.0;
|
||||
top[1999 * 6 + 2][1] = 99.0;
|
||||
base[1234 * 6 + 3][1] = 101.0;
|
||||
base[2501 * 6 + 4][1] = 199.0;
|
||||
|
||||
let poly = decimate_area(top, base, max);
|
||||
assert_eq!(poly.len() % 2, 0);
|
||||
let half = poly.len() / 2;
|
||||
assert!(half <= max * 4, "retained {half} paired vertices, limit is {}", max * 4);
|
||||
for i in 0..half {
|
||||
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
|
||||
}
|
||||
let top_kept = &poly[..half];
|
||||
let base_kept = &poly[half..];
|
||||
assert_eq!(top_kept.first().map(|p| p[0]), Some(0.0));
|
||||
assert_eq!(top_kept.last().map(|p| p[0]), Some(2999.5));
|
||||
assert!(top_kept.iter().any(|p| p[1] == 1.0), "top min spike preserved");
|
||||
assert!(top_kept.iter().any(|p| p[1] == 99.0), "top max spike preserved");
|
||||
assert!(base_kept.iter().any(|p| p[1] == 101.0), "base min spike preserved");
|
||||
assert!(base_kept.iter().any(|p| p[1] == 199.0), "base max spike preserved");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! The chart data table uses the same primitives as the plot, including in print/PDF output.
|
||||
|
||||
use super::frame::{Bx, CatAxis, level_groups};
|
||||
use super::legend::{Swatch, cat_label, draw_swatch, plot_kind};
|
||||
use super::{AXIS, Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, TEXT, VAlign};
|
||||
|
||||
pub(super) struct Layout {
|
||||
top: f32,
|
||||
row_height: f32,
|
||||
headers: usize,
|
||||
stub: f32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
pub(super) fn category_axis<'a>(layout: &Option<Layout>, labels: &'a [String], outer: &'a [Vec<String>]) -> CatAxis<'a> {
|
||||
match layout {
|
||||
Some(t) => CatAxis::Table(labels, t.stub),
|
||||
None => CatAxis::Labels(labels, outer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve at most half the available height. A table that cannot fit is explicitly labelled
|
||||
/// instead of dropping rows or allocating a series × point-sized matrix.
|
||||
pub(super) fn reserve(ctx: &mut Ctx, chart: &Chart, data: &ChartData, mut area: Bx, columns: usize) -> (Bx, Option<Layout>) {
|
||||
if chart.data_table.is_none() || !chart.supports_data_table() {
|
||||
return (area, None);
|
||||
}
|
||||
let headers = data.category_levels.len() + 1;
|
||||
let rows = headers + data.series.len();
|
||||
let row_height = LABEL_SIZE * 1.4 + 4.0;
|
||||
let height = row_height * rows as f32;
|
||||
let key_width = if chart.data_table.as_ref().is_some_and(|t| t.show_keys) { 22.0 } else { 0.0 };
|
||||
let names = data.series.iter().take(32).map(|s| ctx.tw(&s.name, LABEL_SIZE)).fold(0.0f32, f32::max);
|
||||
let stub = (names + key_width + 10.0).clamp(24.0, (area.w * 0.3).max(24.0));
|
||||
if columns == 0 || columns > 128 || data.series.len() > 32 || height > area.h * 0.5 || (area.w * 0.6) / (columns as f32) < 18.0 {
|
||||
let message = ctx.fit("Data table needs more space", LABEL_SIZE, area.w);
|
||||
ctx.text(area.cx(), area.bottom(), &message, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
area.h -= row_height;
|
||||
return (area, None);
|
||||
}
|
||||
area.h -= height;
|
||||
(area, Some(Layout { top: area.bottom(), row_height, headers, stub, columns }))
|
||||
}
|
||||
|
||||
pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, layout: Option<&Layout>) {
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(settings) = &chart.data_table else { return };
|
||||
let x0 = plot.x - layout.stub;
|
||||
let top = layout.top;
|
||||
let rows = layout.headers + data.series.len();
|
||||
let bottom = top + layout.row_height * rows as f32;
|
||||
let cw = plot.w / layout.columns as f32;
|
||||
// Outermost category level first, then the individual categories.
|
||||
for (i, level) in data.category_levels.iter().rev().enumerate() {
|
||||
for (first, end) in level_groups(level, layout.columns) {
|
||||
let text = level.get(first).map(String::as_str).unwrap_or("");
|
||||
let text = ctx.fit(text, LABEL_SIZE, (end - first) as f32 * cw - 4.0);
|
||||
let x = plot.x + (first + end) as f32 * cw / 2.0;
|
||||
ctx.text(x, top + (i as f32 + 0.5) * layout.row_height, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
let header_y = top + (layout.headers as f32 - 0.5) * layout.row_height;
|
||||
let n = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
for j in 0..layout.columns {
|
||||
let text = if j < n { cat_label(data, j) } else { String::new() };
|
||||
let text = ctx.fit(&text, LABEL_SIZE, cw - 4.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, header_y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
for (i, series) in data.series.iter().enumerate() {
|
||||
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
|
||||
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
|
||||
if settings.show_keys {
|
||||
let swatch = match plot_kind(chart, series) {
|
||||
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
_ => Swatch::Box,
|
||||
};
|
||||
draw_swatch(ctx, swatch, x0 + 4.0, y, 7.0, series.color);
|
||||
}
|
||||
let name = ctx.fit(&series.name, LABEL_SIZE, plot.x - name_x - 4.0);
|
||||
ctx.text(name_x, y, &name, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
let fmt = Fmt::new(&series.number_format);
|
||||
for j in 0..layout.columns {
|
||||
let value = series.values.get(j).copied().flatten().map(|v| fmt.format(v, None)).unwrap_or_default();
|
||||
let text = ctx.fit(&value, LABEL_SIZE, cw - 4.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
if settings.horizontal_border {
|
||||
for row in 1..rows {
|
||||
ctx.hline(x0, plot.right(), top + row as f32 * layout.row_height, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
if settings.vertical_border {
|
||||
ctx.vline(plot.x, top, bottom, AXIS, 0.75);
|
||||
// The outer headers span groups; category dividers begin at the innermost header.
|
||||
for j in 1..layout.columns {
|
||||
ctx.vline(plot.x + j as f32 * cw, top + (layout.headers - 1) as f32 * layout.row_height, bottom, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
if settings.outline {
|
||||
ctx.hline(x0, plot.right(), top, AXIS, 0.75);
|
||||
ctx.hline(x0, plot.right(), bottom, AXIS, 0.75);
|
||||
ctx.vline(x0, top, bottom, AXIS, 0.75);
|
||||
ctx.vline(plot.right(), top, bottom, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- trendlines
|
||||
|
||||
@@ -39,11 +36,10 @@ 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<ClippedSegment> {
|
||||
#[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)] {
|
||||
@@ -165,6 +161,6 @@ pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
|
||||
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));
|
||||
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +179,9 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
|
||||
match gridcraft_core::split_sheet(t) {
|
||||
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
(None, _) => wb.name(t, sheet).is_some(),
|
||||
match t.split_once('!') {
|
||||
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
None => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod data_table;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
ChartKind::ColumnClustered,
|
||||
ChartKind::ColumnStacked,
|
||||
@@ -45,7 +43,6 @@ fn chart(kind: ChartKind) -> Chart {
|
||||
series: vec![],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: true,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 0,
|
||||
x_title: Some("Quarter".into()),
|
||||
@@ -1258,75 +1255,3 @@ fn box_whisker_draws_each_series_quartile_method() {
|
||||
// The boxes span 3.5 and 2.5 data units on the same value axis.
|
||||
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() {
|
||||
// #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series
|
||||
// was named by the literal text.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "Bob's".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'Bob''s'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'Bob''s'!Title".into()),
|
||||
categories: None,
|
||||
values: "'Bob''s'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
|
||||
// `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at
|
||||
// the last one, not the first.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "a!b".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'a!b'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'a!b'!Title".into()),
|
||||
categories: None,
|
||||
values: "'a!b'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
use super::*;
|
||||
use gridcraft_model::ChartDataTable;
|
||||
|
||||
#[test]
|
||||
fn tables_draw_source_values_for_every_supported_chart_kind() {
|
||||
for kind in ALL_KINDS {
|
||||
let mut c = chart(kind);
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let supported = c.supports_data_table();
|
||||
let d = sample(kind);
|
||||
let prims = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 900.0, 500.0);
|
||||
let labels = texts(&prims);
|
||||
// Source amounts, including on 100% stacked charts, not shares or running totals.
|
||||
if supported {
|
||||
for value in ["$12,000", "-$4,000", "$38,000", "$61,870"] {
|
||||
assert!(labels.contains(&value), "{kind:?}: missing {value}");
|
||||
}
|
||||
} else {
|
||||
c.data_table = None;
|
||||
assert_eq!(prims, render(&c, &d, 900.0, 500.0, &ApproxMeasure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_columns_align_with_plot_and_keep_missing_values_blank() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.title = Some(String::new());
|
||||
c.x_title = None;
|
||||
c.y_title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let color = [1, 2, 3, 255];
|
||||
let mut s = series("Rate", &[0.25, 0.0, 0.75], color, c.kind);
|
||||
s.values[1] = None;
|
||||
s.number_format = "0.0%".into();
|
||||
let d = ChartData { categories: ["Jan", "Feb", "Mar"].map(String::from).to_vec(), series: vec![s], ..Default::default() };
|
||||
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
let at = |text: &str| -> (f32, f32) {
|
||||
p.iter()
|
||||
.find_map(|p| match p {
|
||||
Prim::Text { text: t, x, y, .. } if t == text => Some((*x, *y)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let (x1, y1) = at("25.0%");
|
||||
let (x3, y3) = at("75.0%");
|
||||
let header_y = at("Jan").1;
|
||||
assert!(y1 > header_y && (y1 - y3).abs() < 0.01);
|
||||
assert_eq!(at("Jan").0, x1);
|
||||
assert_eq!(at("Mar").0, x3);
|
||||
assert_eq!(texts(&p).iter().filter(|t| **t == "Jan").count(), 1, "the table replaces category-axis labels");
|
||||
let centers: Vec<f32> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { x, y, w, fill: Some(f), .. } if *f == color && *y < header_y => Some(x + w / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(centers.len(), 2);
|
||||
assert!((centers[0] - x1).abs() < 0.01 && (centers[1] - x3).abs() < 0.01);
|
||||
let cells: Vec<&str> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Text { y, text, .. } if (*y - y1).abs() < 0.01 => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cells, ["Rate", "25.0%", "75.0%"], "blank cells are not zeroes");
|
||||
assert!(
|
||||
p.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)),
|
||||
"legend key independent of hidden chart legend"
|
||||
);
|
||||
c.data_table = Some(ChartDataTable { show_keys: false, ..Default::default() });
|
||||
let off = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
assert!(!off.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_borders_are_independent_and_multilevel_headers_are_kept() {
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
let mut d = sample(c.kind);
|
||||
d.category_levels = vec![vec!["2025".into(), "".into(), "".into(), "2026".into(), "".into(), "".into()]];
|
||||
for (h, v, outline, want) in [(false, false, false, 0), (true, false, false, 4), (false, true, false, 6), (false, false, true, 4)] {
|
||||
c.data_table = Some(ChartDataTable { horizontal_border: h, vertical_border: v, outline, show_keys: false });
|
||||
let p = render(&c, &d, 900.0, 600.0, &ApproxMeasure);
|
||||
let year_y = p
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
Prim::Text { text, y, .. } if text == "2025" => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(texts(&p).contains(&"2026"));
|
||||
let lines = p.iter().filter(|p| matches!(p, Prim::Line { pts, .. } if pts.iter().all(|p| p[1] > year_y - 9.0))).count();
|
||||
assert_eq!(lines, want, "{h} {v} {outline}");
|
||||
assert_sane(&p, 900.0, 600.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_and_tiny_tables_are_bounded_and_explicit() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let mut d = sample(c.kind);
|
||||
d.categories = (0..20_000).map(|i| i.to_string()).collect();
|
||||
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
assert!(texts(&p).contains(&"Data table needs more space"));
|
||||
assert_sane(&p, 600.0, 400.0);
|
||||
d.categories.clear();
|
||||
for (w, h) in [(1.0, 1.0), (10.0, 10.0), (40.0, 35.0), (200.0, 100.0)] {
|
||||
assert_sane(&render(&c, &d, w, h, &ApproxMeasure), w, h);
|
||||
}
|
||||
}
|
||||
+22
-49
@@ -147,10 +147,10 @@ impl RangeRef {
|
||||
RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.end.row.saturating_sub(self.start.row).saturating_add(1)
|
||||
self.end.row - self.start.row + 1
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
self.end.col.saturating_sub(self.start.col).saturating_add(1)
|
||||
self.end.col - self.start.col + 1
|
||||
}
|
||||
pub fn count(&self) -> u64 {
|
||||
self.height() as u64 * self.width() as u64
|
||||
@@ -188,12 +188,6 @@ impl RangeRef {
|
||||
end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)),
|
||||
}
|
||||
}
|
||||
/// The top-left cell of a list of areas: the lowest row and the lowest column across all of
|
||||
/// them, whatever order they are listed in. Excel writes the relative formulas of a
|
||||
/// multi-area validation or conditional format for this cell. `None` for no areas.
|
||||
pub fn top_left(ranges: &[RangeRef]) -> Option<CellRef> {
|
||||
ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start)
|
||||
}
|
||||
/// Row-major iterator over the cells. Callers must bound huge ranges themselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = CellRef> + '_ {
|
||||
let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col);
|
||||
@@ -301,33 +295,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_range_dimensions_saturate_instead_of_wrapping() {
|
||||
let r = RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) };
|
||||
assert_eq!((r.height(), r.width()), (u32::MAX, u32::MAX));
|
||||
assert_eq!(r.count(), u64::from(u32::MAX) * u64::from(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
|
||||
fn range_parse_rejects_signed_rows() {
|
||||
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").ok_or_else(|| std::io::Error::other("valid row range"))?;
|
||||
let r = RangeRef::parse("$1:$3").unwrap();
|
||||
assert_eq!((r.start.row, r.end.row), (0, 2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_left_spans_every_area_in_any_order() {
|
||||
let areas: Vec<RangeRef> = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect();
|
||||
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26"));
|
||||
// Lowest row and lowest column, even when they come from different areas.
|
||||
let areas: Vec<RangeRef> = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect();
|
||||
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2"));
|
||||
assert_eq!(RangeRef::top_left(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,20 +389,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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")?;
|
||||
fn ranges() {
|
||||
let r = RangeRef::parse("C3:A1").unwrap();
|
||||
assert_eq!(r.a1(), "A1:C3");
|
||||
assert_eq!(r.count(), 9);
|
||||
assert_eq!(parse("A:C")?.a1(), "A:C");
|
||||
assert_eq!(parse("3:5")?.a1(), "3:5");
|
||||
assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C");
|
||||
assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5");
|
||||
assert!(RangeRef::parse("0:5").is_none());
|
||||
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");
|
||||
let a = RangeRef::parse("A1:C3").unwrap();
|
||||
let b = RangeRef::parse("B2:D4").unwrap();
|
||||
assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3");
|
||||
assert_eq!(a.union(&b).a1(), "A1:D4");
|
||||
assert_eq!(a.iter().count(), 9);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,19 +458,18 @@ mod category_level_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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");
|
||||
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");
|
||||
// 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");
|
||||
Ok(())
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -47,11 +47,13 @@ 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.
|
||||
/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
|
||||
/// 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
|
||||
pub fn unix_now_ms() -> Option<f64> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Some(chrono::Utc::now().timestamp_millis() as f64)
|
||||
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some(d.as_secs_f64() * 1000.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -321,7 +323,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
|
||||
fn fictitious_1900_02_29() {
|
||||
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
|
||||
// through it.
|
||||
let s = DateSystem::D1900;
|
||||
@@ -343,7 +345,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).ok_or_else(|| std::io::Error::other("serial should convert"))?;
|
||||
let d = datetime_from_serial(s, serial as f64).unwrap();
|
||||
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);
|
||||
@@ -356,7 +358,6 @@ 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]
|
||||
@@ -370,18 +371,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() -> Result<(), std::io::Error> {
|
||||
fn roundtrip() {
|
||||
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).ok_or_else(|| std::io::Error::other("serial should convert"))?;
|
||||
let d = datetime_from_serial(s, serial).unwrap();
|
||||
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).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
|
||||
let d = datetime_from_serial(s, 60.0).unwrap();
|
||||
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
|
||||
let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
|
||||
let d = datetime_from_serial(s, 46302.75).unwrap();
|
||||
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).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
|
||||
let d = datetime_from_serial(s, 1.0).unwrap();
|
||||
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,18 +21,10 @@ pub enum CellError {
|
||||
Calc,
|
||||
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
|
||||
Circ,
|
||||
/// A Python cell's code raised an error, or Python stopped.
|
||||
Python,
|
||||
/// A Python cell is waiting to run.
|
||||
Busy,
|
||||
/// A Python cell ran longer than the time limit.
|
||||
Timeout,
|
||||
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl CellError {
|
||||
pub const ALL: [CellError; 14] = [
|
||||
pub const ALL: [CellError; 10] = [
|
||||
CellError::Null,
|
||||
CellError::Div0,
|
||||
CellError::Value,
|
||||
@@ -43,10 +35,6 @@ impl CellError {
|
||||
CellError::GettingData,
|
||||
CellError::Spill,
|
||||
CellError::Calc,
|
||||
CellError::Python,
|
||||
CellError::Busy,
|
||||
CellError::Timeout,
|
||||
CellError::Blocked,
|
||||
];
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -61,16 +49,9 @@ impl CellError {
|
||||
CellError::Spill => "#SPILL!",
|
||||
CellError::Calc => "#CALC!",
|
||||
CellError::Circ => "#CIRC!",
|
||||
CellError::Python => "#PYTHON!",
|
||||
CellError::Busy => "#BUSY!",
|
||||
CellError::Timeout => "#TIMEOUT!",
|
||||
CellError::Blocked => "#BLOCKED!",
|
||||
}
|
||||
}
|
||||
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
|
||||
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
|
||||
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
|
||||
/// and may change if Excel's numbers become known.
|
||||
/// `ERROR.TYPE` number.
|
||||
pub fn code(&self) -> u32 {
|
||||
match self {
|
||||
CellError::Null => 1,
|
||||
@@ -84,10 +65,6 @@ impl CellError {
|
||||
CellError::Spill => 9,
|
||||
CellError::Calc => 14,
|
||||
CellError::Circ => 15,
|
||||
CellError::Blocked => 11,
|
||||
CellError::Python => 19,
|
||||
CellError::Busy => 20,
|
||||
CellError::Timeout => 21,
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CellError> {
|
||||
@@ -477,12 +454,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"))?;
|
||||
fn arrays() {
|
||||
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,11 @@ 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 }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -269,7 +269,6 @@ 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"),
|
||||
|
||||
+78
-96
@@ -136,95 +136,6 @@ 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()
|
||||
@@ -244,14 +155,58 @@ impl CfCache {
|
||||
let v = sh.value(c);
|
||||
let no_stats = RuleStats::default();
|
||||
for (idx, cf) in rules {
|
||||
let first = RangeRef::top_left(&cf.ranges).unwrap_or(c);
|
||||
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
|
||||
let st: &RuleStats = match stats_need(&cf.rule) {
|
||||
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
|
||||
None => &no_stats,
|
||||
};
|
||||
let text = v.display();
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
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 };
|
||||
@@ -274,8 +229,36 @@ impl CfCache {
|
||||
None => None,
|
||||
},
|
||||
CfRule::TimePeriod { period, style } => match v.as_f64() {
|
||||
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
|
||||
_ => None,
|
||||
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,
|
||||
},
|
||||
CfRule::ColorScale { stops } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
@@ -346,8 +329,7 @@ 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
|
||||
|
||||
@@ -17,13 +17,11 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
|
||||
const MAX_HTML_CELLS: usize = 1_000_000;
|
||||
|
||||
/// One table cell: its visible text, the number the source recorded for it, if any, and whether
|
||||
/// it is a header cell (`<th>`).
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct HtmlCell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
pub header: bool,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
@@ -90,7 +88,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<V
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan });
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
@@ -378,18 +376,18 @@ mod tests {
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel, 2, 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
|
||||
assert_eq!(rows[1][0].text, "2.50 & up");
|
||||
assert_eq!(rows[1][2].text, "x");
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]);
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -436,7 +434,7 @@ mod tests {
|
||||
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
|
||||
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
|
||||
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
|
||||
+20
-93
@@ -5,7 +5,6 @@ pub mod analysis;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod extra;
|
||||
pub mod file;
|
||||
pub mod forecast;
|
||||
@@ -13,10 +12,8 @@ pub mod format;
|
||||
pub mod formulas;
|
||||
pub mod insert;
|
||||
pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod python;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
@@ -42,11 +39,7 @@ pub struct CommandSpec {
|
||||
pub label: &'static str,
|
||||
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
|
||||
pub menu: &'static [&'static str],
|
||||
/// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere.
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut
|
||||
/// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`].
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -60,32 +53,12 @@ pub struct CommandInfo {
|
||||
pub label: &'static str,
|
||||
pub menu: Vec<&'static str>,
|
||||
pub shortcut: Option<&'static str>,
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
/// Format the portable shortcut notation for the runtime operating system.
|
||||
pub fn shortcut_for_platform(&self, mac: bool) -> Option<String> {
|
||||
let keys = if mac {
|
||||
self.shortcut_mac.or(self.shortcut)
|
||||
} else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut {
|
||||
// An identical Mac override marks a Mac-only display shortcut.
|
||||
// The portable field remains unchanged for legacy CLI consumers.
|
||||
None
|
||||
} else {
|
||||
self.shortcut
|
||||
}?;
|
||||
if mac {
|
||||
return Some(keys.to_owned());
|
||||
}
|
||||
Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::<Vec<_>>().join("+"))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
@@ -94,7 +67,6 @@ impl CommandSpec {
|
||||
label: self.label,
|
||||
menu: self.menu.to_vec(),
|
||||
shortcut: self.shortcut,
|
||||
shortcut_mac: self.shortcut_mac,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
@@ -145,13 +117,13 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
};
|
||||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -165,19 +137,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(format::specs());
|
||||
v.extend(sheet::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(data::specs());
|
||||
v.extend(formulas::specs());
|
||||
v.extend(review::specs());
|
||||
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());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(python::specs());
|
||||
v.extend(slicer::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
@@ -209,7 +178,6 @@ pub fn register_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -227,7 +195,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -238,6 +205,7 @@ 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(),
|
||||
@@ -370,9 +338,6 @@ 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<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
@@ -384,51 +349,26 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn wall_clock() -> std::time::Duration {
|
||||
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
// `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);
|
||||
// `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()) };
|
||||
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,
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = 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", since(t0), changed.len());
|
||||
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
|
||||
}
|
||||
if structural {
|
||||
d.calc.recalc_all(&mut wb);
|
||||
@@ -436,7 +376,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + recalc {:?}", since(t0));
|
||||
eprintln!("commit: + recalc {:?}", t0.elapsed());
|
||||
}
|
||||
fit_rows.sort_unstable();
|
||||
fit_rows.dedup();
|
||||
@@ -444,26 +384,19 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
auto_row_height(&mut wb, si, row);
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + rows {:?}", since(t0));
|
||||
eprintln!("commit: + rows {:?}", t0.elapsed());
|
||||
}
|
||||
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.";
|
||||
|
||||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
|
||||
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
|
||||
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
|
||||
/// leave the cell editable, so the effective lock (row and column formats included) is compared
|
||||
/// as well.
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
@@ -474,8 +407,7 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
|
||||
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
|
||||
if !same_content(sh.cell(c), after.cell(c))
|
||||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||||
{
|
||||
@@ -528,7 +460,7 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let filled = after.cell(c).is_some_and(gridcraft_model::Cell::has_content);
|
||||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||
continue;
|
||||
}
|
||||
@@ -544,18 +476,13 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
|
||||
/// is not something the person edits).
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content());
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => match (&a.py, &b.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,10 @@ use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
|
||||
use gridcraft_model::{
|
||||
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
|
||||
ValidationKind, Workbook,
|
||||
};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
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 super::*;
|
||||
use crate::outline;
|
||||
use crate::selection::current_region;
|
||||
|
||||
@@ -99,30 +93,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+K"),
|
||||
..cmd!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
)
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+J"),
|
||||
..cmd!(
|
||||
"data.ungroup",
|
||||
"Ungroup",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Left"),
|
||||
"{rows? | cols? | by?: rows|cols, sheet?}",
|
||||
has_doc,
|
||||
|s, p| { group(s, p, false) }
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
),
|
||||
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
|
||||
group(s, p, false)
|
||||
}),
|
||||
cmd!(
|
||||
"data.hideDetail",
|
||||
"Hide Detail",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
@@ -184,8 +181,7 @@ pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
|
||||
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 };
|
||||
// An imported range can name rows past the grid; none of those exist to hide.
|
||||
let r = RangeRef::new(af.range.start, CellRef::new(af.range.end.row.min(gridcraft_core::MAX_ROWS - 1), af.range.end.col));
|
||||
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<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- outline
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SortKey {
|
||||
@@ -195,7 +192,8 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
// 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<Anchor> = sh.object_anchors().copied().collect();
|
||||
let objs: Vec<Anchor> =
|
||||
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| {
|
||||
@@ -241,22 +239,33 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected in
|
||||
// `object_anchors` order, which `object_anchors_mut` repeats.
|
||||
// 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 new_anchors: Vec<Anchor> = objs
|
||||
.into_iter()
|
||||
.zip(descs)
|
||||
.map(|(a, desc)| {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) }
|
||||
})
|
||||
.collect();
|
||||
for (a, n) in shm.object_anchors_mut().zip(new_anchors) {
|
||||
*a = n;
|
||||
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));
|
||||
@@ -272,9 +281,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
/// 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, and
|
||||
/// keeps its size when the sort turns its lines around (its bottom line now sorts before its top
|
||||
/// line), since the lines then enclose nothing to stretch it between.
|
||||
/// 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;
|
||||
@@ -292,10 +299,7 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let x2 = sh.col_left(nbc) + tdx as f64;
|
||||
let y2 = sh.row_top(nbr) + tdy as f64;
|
||||
// An extent that is not positive means the sort put the bottom-right line before the
|
||||
// top-left one on that axis: the object keeps its old size there.
|
||||
let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) };
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode }
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -480,45 +480,6 @@ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
|
||||
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_list_source_is_read_from_the_cell_being_checked() {
|
||||
// As in Excel, `=$B1:$B2` on A3:A4,A1:A2 is written for A1 (the top-left cell of all the
|
||||
// areas) and lists B4:B5 at A4. It used to list B1:B2 in every cell.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B1:B5", "values": [["a"], ["b"], ["c"], ["d"], ["e"]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A3:A4,A1:A2", "type": "list", "formula1": "=$B1:$B2"})).unwrap();
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let dv = wb.active().unwrap().validations[0].clone();
|
||||
for (cell, items) in [("A1", ["a", "b"]), ("A2", ["b", "c"]), ("A3", ["c", "d"]), ("A4", ["d", "e"])] {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
assert_eq!(list_items(&wb, 0, at, &dv), items, "{cell}");
|
||||
for v in ["a", "b", "c", "d", "e"] {
|
||||
assert_eq!(check_validation(&wb, 0, at, v).is_none(), items.contains(&v), "{cell} with {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_source_that_takes_in_the_cell_does_not_list_the_value_being_typed() {
|
||||
// `=$A1:$A2` on A2:A3 is A1:A2 at A2 and A2:A3 at A3. The list is the one before the entry, as
|
||||
// in Excel; a new value used to land in its own source and pass.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["x"], ["y"]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A2:A3", "type": "list", "formula1": "=$A1:$A2"})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
for (cell, accepted, refused) in [("A2", &["x", "y"][..], &["new"][..]), ("A3", &["y"], &["x", "new"])] {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
for v in accepted {
|
||||
assert!(check_validation(wb, 0, at, v).is_none(), "{cell} refuses {v}");
|
||||
}
|
||||
for v in refused {
|
||||
assert!(check_validation(wb, 0, at, v).is_some(), "{cell} accepts {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
/// Most cells (rows times compared columns) one Remove Duplicates call reads.
|
||||
const MAX_COMPARED_CELLS: u64 = 50_000_000;
|
||||
|
||||
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
@@ -27,10 +21,6 @@ pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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"));
|
||||
}
|
||||
// Rows are compared one by one, so a sparse sheet's enclosing rectangle is bounded like a list.
|
||||
if r.count() > MAX_COMPARED_CELLS {
|
||||
return Err(bad("data.removeDuplicates", "the range is too large"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
@@ -153,16 +143,7 @@ pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
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 cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- validation
|
||||
|
||||
@@ -38,18 +35,11 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
/// When the rule's top-left cell goes, its relative formulas are rewritten for the new top-left
|
||||
/// cell, so the cells left keep checking the same cells.
|
||||
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
let before = RangeRef::top_left(&dv.ranges);
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
}
|
||||
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) {
|
||||
dv.f1 = move_rule_formula(&dv.f1, from, to);
|
||||
dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to));
|
||||
}
|
||||
}
|
||||
validations.retain(|d| !d.ranges.is_empty());
|
||||
}
|
||||
@@ -152,10 +142,8 @@ fn list_source(f: &str) -> String {
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range) for the cell `at`. A relative
|
||||
/// source is written for the rule's top-left cell and read from `at`, as in Excel (`=$B1:$B3` on
|
||||
/// A1:A9 lists B5:B7 at A5).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) -> Vec<String> {
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
@@ -165,8 +153,8 @@ pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) ->
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let first = RangeRef::top_left(&dv.ranges).unwrap_or(at);
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)) {
|
||||
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![],
|
||||
@@ -184,10 +172,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
|
||||
return None;
|
||||
}
|
||||
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
|
||||
// The formulas are written for the top-left cell of all the areas and see the value being entered,
|
||||
// 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 = RangeRef::top_left(&dv.ranges).unwrap_or(at);
|
||||
let original = wb;
|
||||
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
|
||||
let entered;
|
||||
let wb = if sh.value(at) == v {
|
||||
wb
|
||||
@@ -203,9 +190,7 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
|
||||
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 {
|
||||
// The list is the one the dropdown showed before the entry, as in Excel: a source that takes
|
||||
// in the cell itself (`=$A1:$A2` on A2:A3 is A2:A3 at A3) doesn't list the value being typed.
|
||||
ValidationKind::List => list_items(original, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
|
||||
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;
|
||||
@@ -289,14 +274,11 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet();
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let mut bad_cells = Vec::new();
|
||||
// One scan allowance for the whole command: duplicate or overlapping ranges don't multiply it.
|
||||
let mut budget = 100_000usize;
|
||||
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(budget) {
|
||||
budget -= 1;
|
||||
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());
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -80,7 +80,6 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
|
||||
let id = d.wb.next_object_id();
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind: ShapeKind::Ink,
|
||||
anchor: Anchor {
|
||||
|
||||
+29
-100
@@ -4,10 +4,7 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, StyleId};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
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 super::*;
|
||||
use crate::selection::{current_region, jump};
|
||||
use crate::{Clipboard, Session};
|
||||
|
||||
@@ -20,10 +17,10 @@ mod paste_special;
|
||||
mod selection;
|
||||
|
||||
use cell::{cell_set, range_fill, range_set_values};
|
||||
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
|
||||
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, range_move};
|
||||
use clipboard::{clear_clipboard, copy, cut, paste};
|
||||
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
|
||||
#[cfg(test)]
|
||||
use find::replace_ci;
|
||||
@@ -35,8 +32,8 @@ 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_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
|
||||
pub(crate) use paste_special::rewrite_all_formulas;
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -46,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Values",
|
||||
[],
|
||||
None,
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
|
||||
has_doc,
|
||||
range_set_values
|
||||
),
|
||||
@@ -62,15 +59,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
|
||||
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
|
||||
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
|
||||
cmd!(
|
||||
noundo "selection.nextCorner",
|
||||
"Next Corner of Selection",
|
||||
[],
|
||||
Some("Ctrl+."),
|
||||
"{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
|
||||
has_doc,
|
||||
selection_next_corner
|
||||
),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
|
||||
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
|
||||
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
|
||||
@@ -91,26 +79,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"range.move",
|
||||
"Drag and Drop",
|
||||
[],
|
||||
None,
|
||||
"{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
|
||||
has_doc,
|
||||
range_move
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Cmd+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Ctrl+Cmd+V"),
|
||||
..cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Alt+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
)
|
||||
},
|
||||
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
|
||||
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
|
||||
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
|
||||
@@ -144,13 +120,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fill_series
|
||||
),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
|
||||
cmd!(
|
||||
"edit.replace",
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -204,8 +180,6 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
// Step by step: a rename and a new sheet undone together match up only one step apart.
|
||||
super::extra::keep_view_modes(d, &cur_wb);
|
||||
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
@@ -226,7 +200,6 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
super::extra::keep_view_modes(d, &cur_wb);
|
||||
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
@@ -237,13 +210,6 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- 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<u32> {
|
||||
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<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
@@ -254,44 +220,22 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
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| {
|
||||
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))
|
||||
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
|
||||
// doesn't see them go: a locked cell's are refused here, stored cell or not.
|
||||
if matches!(what, "all" | "comments" | "hyperlinks") {
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
|
||||
for &whole in &ranges {
|
||||
let skip = filtered_rows(sh, whole);
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
|
||||
if d.wb.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
for &whole in &ranges {
|
||||
// Cells are only stored inside the used range. Notes and links are kept apart from
|
||||
// them, so they are cleared over the whole range: one from a file can sit on a cell
|
||||
// with nothing stored in it.
|
||||
let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
|
||||
(Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
|
||||
_ => Vec::new(),
|
||||
for r in &ranges {
|
||||
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
Some(u) => match r.intersection(&u) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
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();
|
||||
// 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)));
|
||||
@@ -300,7 +244,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
cell.py = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
@@ -311,18 +254,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
|
||||
// A removed note or link changes its cell even with nothing stored there.
|
||||
let metadata: Vec<CellRef> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| {
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
notes.chain(links).copied().filter(|c| cleared(*c)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
@@ -340,17 +271,15 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
if matches!(what, "all" | "comments") {
|
||||
sh.comments.retain(|c, _| !cleared(*c));
|
||||
sh.comments.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
if matches!(what, "all" | "hyperlinks") {
|
||||
sh.hyperlinks.retain(|c, _| !cleared(*c));
|
||||
sh.hyperlinks.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
if let Some(r) = used
|
||||
&& matches!(what, "all" | "formats")
|
||||
{
|
||||
if matches!(what, "all" | "formats") {
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
|
||||
let keep = !r.contains_range(m);
|
||||
unmerged |= !keep;
|
||||
keep
|
||||
});
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- 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(…).";
|
||||
|
||||
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
|
||||
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
|
||||
pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
input.starts_with('=')
|
||||
|| (input.len() > 1
|
||||
&& (input.starts_with('+') || input.starts_with('-'))
|
||||
&& gridcraft_core::parse::parse_number_text(input).is_none()
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
|
||||
}
|
||||
|
||||
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
|
||||
/// format). Inherits the destination's cell, row or column style, including its number
|
||||
/// format, before parsing. Errors when the formula can't be parsed.
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
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 };
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
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 = is_formula_input(input);
|
||||
if is_formula && gridcraft_model::python::is_py_formula(input) {
|
||||
return Err(EngineError::Other(PY_FORMULA.into()));
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
|
||||
}
|
||||
let is_formula = input.starts_with('=')
|
||||
|| (input.len() > 1
|
||||
&& (input.starts_with('+') || input.starts_with('-'))
|
||||
&& gridcraft_core::parse::parse_number_text(input).is_none()
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
@@ -86,7 +71,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
if input.contains('\n') && !wb.styles.get(style).align.wrap {
|
||||
style = wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style, py: None }))
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
@@ -154,7 +139,7 @@ pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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()) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
|
||||
let style = old.style;
|
||||
sh.set_cell(c, Cell { style, ..Cell::default() });
|
||||
}
|
||||
@@ -195,22 +180,6 @@ pub(crate) fn json_to_input(v: &Json) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many converted strings `range.setValues` reports back, and how much of each input and
|
||||
/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
|
||||
/// in the result.
|
||||
const MAX_REPORTED_CONVERSIONS: usize = 20;
|
||||
const MAX_REPORTED_CHARS: usize = 32;
|
||||
|
||||
/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
|
||||
/// cut. Counting characters keeps a multi-byte input whole.
|
||||
fn clipped(text: &str) -> String {
|
||||
let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
|
||||
if out.len() < text.len() {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let start = target_range(s, p)?.start;
|
||||
@@ -228,10 +197,6 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("range.setValues", "too many rows"));
|
||||
}
|
||||
let mut count = 0;
|
||||
// The strings typed input read as something other than the text given, for the result: a
|
||||
// caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
|
||||
let mut converted: Vec<(CellRef, String)> = Vec::new();
|
||||
let mut converted_count = 0;
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
let cols: Vec<&Json> = match row.as_array() {
|
||||
@@ -246,47 +211,23 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell {
|
||||
value: Value::Bool(*b),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
// Text as given: no date or number reading, and a leading `'` is part of the
|
||||
// text rather than the marker that makes it text. A formula is still a
|
||||
// formula, which is what the ticket asks for and what `=` means in a cell.
|
||||
Json::String(text) if !parse && !text.starts_with('=') => {
|
||||
if text.is_empty() {
|
||||
// An empty string clears the cell, as `null` and the default path do:
|
||||
// an empty text cell is not blank, and a script clearing cells with
|
||||
// `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
|
||||
input_to_cell("", sheet, at, &mut cx.wb)?
|
||||
} else {
|
||||
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
// Line breaks still turn on Wrap Text, as typed input does.
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
|
||||
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 })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
// A string that became a formula was written as one (`=`), which is deliberate;
|
||||
// only a number (dates and times included), boolean or error is a reading of the
|
||||
// string and worth reporting.
|
||||
if let Some(c) = &cell
|
||||
&& matches!(v, Json::String(_))
|
||||
&& c.formula.is_none()
|
||||
&& matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
|
||||
{
|
||||
converted_count += 1;
|
||||
if converted.len() < MAX_REPORTED_CONVERSIONS {
|
||||
converted.push((at, input.clone()));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.set_cell(at, c),
|
||||
@@ -301,17 +242,7 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = s.doc()?;
|
||||
let reported: Vec<Json> =
|
||||
d.wb.sheet(sheet)
|
||||
.map(|sh| {
|
||||
converted
|
||||
.iter()
|
||||
.map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
|
||||
Ok(json!({"cells": count}))
|
||||
}
|
||||
|
||||
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
|
||||
target_sheet,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
|
||||
@@ -93,7 +90,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
@@ -164,40 +161,6 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
|
||||
}
|
||||
|
||||
/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on
|
||||
/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a
|
||||
/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A
|
||||
/// refused or failed move leaves the clipboard as it was.
|
||||
pub(super) fn range_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?;
|
||||
let kept = s.clipboard.take();
|
||||
let moved = cut(s, p).and_then(|_| {
|
||||
let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default();
|
||||
let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end));
|
||||
if bool_param(p, "overwrite") != Some(true)
|
||||
&& let Some(dest) = dest
|
||||
&& let Some(sh) = s.doc()?.wb.sheet(sheet)
|
||||
&& (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content())
|
||||
|| sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty))
|
||||
{
|
||||
return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into()));
|
||||
}
|
||||
// On the range's sheet, which `paste` would leave to the active one. The selection
|
||||
// belongs to the active sheet: it follows the moved cells only when they are on it.
|
||||
let selection = s.doc()?.selection.clone();
|
||||
let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?;
|
||||
if sheet != s.doc()?.wb.active_sheet() {
|
||||
s.doc_mut()?.selection = selection;
|
||||
}
|
||||
Ok(pasted)
|
||||
});
|
||||
if moved.is_err() {
|
||||
s.clipboard = kept;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
|
||||
@@ -261,55 +224,16 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
let mut row = Vec::new();
|
||||
let mut field = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
|
||||
// quote but turned out not to be quoted.
|
||||
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
|
||||
// Text is replayed at most as much as the input is long; past that, quotes are text, so
|
||||
// hostile input full of fields that only look quoted reads in linear time.
|
||||
let mut replay_budget = text.len();
|
||||
let mut quoted = false;
|
||||
let mut at_start = true;
|
||||
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
|
||||
let mut raw = String::new();
|
||||
loop {
|
||||
let c = match replay.pop_front().or_else(|| chars.next()) {
|
||||
Some(c) => c,
|
||||
// A quote that never closes: the field is its text as written.
|
||||
None if quoted => {
|
||||
quoted = false;
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
replay.extend(raw.chars().skip(1));
|
||||
raw.clear();
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
while let Some(c) = chars.next() {
|
||||
if quoted {
|
||||
raw.push(c);
|
||||
if c == '"' {
|
||||
let next = replay.front().copied().or_else(|| chars.peek().copied());
|
||||
if next == Some('"') {
|
||||
if chars.peek() == Some(&'"') {
|
||||
field.push('"');
|
||||
raw.push('"');
|
||||
if replay.pop_front().is_none() {
|
||||
chars.next();
|
||||
}
|
||||
chars.next();
|
||||
} else {
|
||||
quoted = false;
|
||||
// A field is quoted only when its closing quote ends it, as Excel reads it:
|
||||
// `"hi" there` is that text, quotes included, and a tab or line break inside
|
||||
// it still ends the cell, as if the leading quote weren't there.
|
||||
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
|
||||
for ch in rest.into_iter().rev() {
|
||||
replay.push_front(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
@@ -317,10 +241,9 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if at_start && replay_budget > 0 => {
|
||||
'"' if at_start => {
|
||||
quoted = true;
|
||||
at_start = false;
|
||||
raw = String::from('"');
|
||||
}
|
||||
'\t' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- fill
|
||||
|
||||
@@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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 })?;
|
||||
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- find & replace
|
||||
|
||||
@@ -62,8 +62,6 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
// `selection`: only the selected cells of the active sheet, as Excel searches a selected range.
|
||||
let in_selection = str_param(p, "within") == Some("selection");
|
||||
s.last_find = Some(p.clone());
|
||||
let d = s.doc()?;
|
||||
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
|
||||
@@ -73,7 +71,6 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut cells: Vec<(CellRef, String)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|(c, _)| !in_selection || d.selection.contains(*c))
|
||||
.filter_map(|(c, cell)| {
|
||||
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
|
||||
crate::display::cell_text(&d.wb, sh, c)
|
||||
@@ -103,12 +100,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
|
||||
let dd = s.doc_mut()?;
|
||||
dd.wb_switch_sheet(si);
|
||||
if in_selection {
|
||||
// The range stays selected and the active cell moves through it, as in Excel.
|
||||
dd.selection.active = c;
|
||||
} else {
|
||||
dd.selection = crate::Selection::at(c);
|
||||
}
|
||||
dd.selection = crate::Selection::at(c);
|
||||
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
|
||||
}
|
||||
|
||||
@@ -122,21 +114,15 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let whole = bool_param(p, "wholeCell").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?;
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = 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)> =
|
||||
// A hidden formula's source is not searched: a count or error would reveal its text.
|
||||
cx.wb
|
||||
.sheet(si)
|
||||
.map(|sh| sh.cells.iter().filter(|(c, _)| !crate::display::formula_hidden(&cx.wb, sh, *c)).map(|(c, _)| (c, sh.input_text(c))).collect())
|
||||
.unwrap_or_default();
|
||||
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) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
|
||||
if !all && c != active {
|
||||
continue;
|
||||
}
|
||||
if !matches(&text, &what, case, whole) {
|
||||
@@ -149,30 +135,6 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
// A Python cell's text is its code: the replacement edits the code and the cell
|
||||
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
|
||||
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
|
||||
if let Some(old) = old_py {
|
||||
let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) };
|
||||
if py.code == old.code {
|
||||
continue;
|
||||
}
|
||||
if !py.fits() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Its old spill is left to the recalculation, which removes it and
|
||||
// recalculates what read it.
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) },
|
||||
);
|
||||
cx.touch(si, c);
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
@@ -187,9 +149,6 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if skipped > 0 {
|
||||
return Ok(json!({"replaced": n, "skipped": skipped}));
|
||||
}
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::selection::{select_region, selection_set};
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
|
||||
use super::*;
|
||||
|
||||
/// How many references the Go To dialog keeps for going back to.
|
||||
pub(super) const GO_TO_RECENT: usize = 10;
|
||||
@@ -174,8 +174,8 @@ pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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()),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
|
||||
"comments" | "notes" => sheet.comments.contains_key(&c),
|
||||
"errors" => v.is_error(),
|
||||
"numbers" => v.is_number(),
|
||||
|
||||
@@ -1,76 +1,5 @@
|
||||
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<Item = (u32, u32, u32, u32)> + 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<char> {
|
||||
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<RangeRef> = 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(())
|
||||
}
|
||||
use super::*;
|
||||
|
||||
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
|
||||
@@ -145,181 +74,209 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
moved_formulas = taken;
|
||||
}
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
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<std::sync::Arc<Formula>> {
|
||||
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| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(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))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
st
|
||||
src_sheet.cell(sc).cloned()
|
||||
};
|
||||
}
|
||||
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);
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
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);
|
||||
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<std::sync::Arc<Formula>> {
|
||||
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) }))
|
||||
};
|
||||
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_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() };
|
||||
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() };
|
||||
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.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()
|
||||
{
|
||||
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)?;
|
||||
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);
|
||||
}
|
||||
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 = RangeRef::top_left(&dv.ranges).unwrap_or(sc);
|
||||
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 matches!(what, What::All | What::Formats) && !transpose {
|
||||
let merges: Vec<RangeRef> = 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();
|
||||
@@ -328,7 +285,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} 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)?;
|
||||
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);
|
||||
@@ -352,7 +309,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// 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) -> Result<()> {
|
||||
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).
|
||||
@@ -391,109 +348,4 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
n.formula = gridcraft_formula::print(&new);
|
||||
}
|
||||
}
|
||||
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
||||
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
/// name and bubble sizes, and the source block it was made from). Every edit that moves
|
||||
/// references calls it: inserting and deleting lines and cells and cut-moves (through
|
||||
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
|
||||
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
|
||||
/// block that no longer exists is dropped.
|
||||
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
let fix = |text: &str| -> String {
|
||||
match gridcraft_formula::parse(text) {
|
||||
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. A cell's cached
|
||||
/// result is kept while its references still name the same data (a rename, a move); a reference
|
||||
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
|
||||
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
|
||||
/// recalculation removes its spill. A rewrite that would
|
||||
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
|
||||
/// the whole edit, which then changes nothing: see [`check_python_fits`].
|
||||
pub(crate) fn rewrite_python_refs(
|
||||
wb: &mut gridcraft_model::Workbook,
|
||||
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
|
||||
) -> Result<()> {
|
||||
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, bool)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
let mut lost = false;
|
||||
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));
|
||||
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
|
||||
(after != before).then_some(after)
|
||||
});
|
||||
if let Some(code) = changed {
|
||||
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
|
||||
new_code.push((c, code, lost));
|
||||
}
|
||||
}
|
||||
if new_code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, code, lost) in new_code {
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Some(py) = cell.py.as_mut()
|
||||
{
|
||||
let py = std::sync::Arc::make_mut(py);
|
||||
py.code = code;
|
||||
if lost {
|
||||
py.output = None;
|
||||
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
|
||||
/// for any other form (a name, an error).
|
||||
fn area_shape(reference: &str) -> Option<(u32, u32)> {
|
||||
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
|
||||
Some((r.height(), r.width()))
|
||||
}
|
||||
|
||||
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
|
||||
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
|
||||
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
|
||||
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
|
||||
if py.fits() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(format!(
|
||||
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
|
||||
gridcraft_formula::quote_sheet(sheet),
|
||||
at.a1(),
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
|
||||
target_sheet,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
@@ -137,23 +134,6 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"active": d.selection.active.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
// A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one
|
||||
// the sheet's protection doesn't let you select, and a merged cell that starts outside the
|
||||
// selected area are skipped.
|
||||
let area = d.selection.current();
|
||||
let stop = |c: CellRef| {
|
||||
Some(sheet.merge_at(c).map_or(c, |m| m.start))
|
||||
.filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c))
|
||||
};
|
||||
let mut next = d.selection.clone();
|
||||
next.next_corner_by(stop);
|
||||
d.selection = next;
|
||||
Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
|
||||
@@ -192,74 +192,6 @@ fn set_values_can_store_strings_as_the_text_they_are() {
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
||||
}
|
||||
|
||||
/// The result names the strings typed input read as something else, so a script notices without
|
||||
/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
|
||||
#[test]
|
||||
fn set_values_reports_the_strings_typed_input_read() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
|
||||
let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
||||
assert_eq!(r["cells"], 6, "{r}");
|
||||
assert_eq!(r["convertedCount"], 4, "{r}");
|
||||
assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
|
||||
assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
|
||||
// The report is a diagnostic, not a second copy of the data: a long input is cut.
|
||||
let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
|
||||
assert_eq!(long["convertedCount"], 1, "{long}");
|
||||
let input = long["converted"][0]["input"].as_str().unwrap_or_default();
|
||||
assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
|
||||
assert!(input.ends_with('…'), "{input}");
|
||||
// Nothing is read with `parse: false`, so nothing is reported.
|
||||
let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
|
||||
assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
|
||||
assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
|
||||
}
|
||||
|
||||
/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
|
||||
/// script clearing cells with "" must not leave cells `COUNTA` counts.
|
||||
#[test]
|
||||
fn set_values_parse_false_clears_an_empty_string() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["type"], "empty");
|
||||
assert_eq!(get(&mut s, "B1")["type"], "empty");
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
|
||||
assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
|
||||
}
|
||||
|
||||
/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
|
||||
/// default's reading of a date survives as a date (#2255).
|
||||
#[test]
|
||||
fn set_values_text_survives_an_xlsx_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
|
||||
assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
|
||||
assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
|
||||
assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
|
||||
// The default read the date, and the file keeps it as one.
|
||||
let at = CellRef::new(2, 0);
|
||||
assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
|
||||
assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
@@ -307,9 +239,6 @@ fn tsv() {
|
||||
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
|
||||
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
|
||||
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
|
||||
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
|
||||
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
|
||||
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,74 +285,6 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_data() {
|
||||
// #3136: dragging column B's border onto column A replaced A without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D9"})).unwrap();
|
||||
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone();
|
||||
let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
// Nothing moved, and the copy made before is still on the clipboard.
|
||||
assert_eq!(text(&mut s, "A1"), json!("Apple"));
|
||||
assert_eq!(text(&mut s, "B1"), json!("Kiwi"));
|
||||
assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9"));
|
||||
// Confirmed, column B replaces column A.
|
||||
s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap();
|
||||
assert_eq!(text(&mut s, "A1"), json!("Kiwi"));
|
||||
assert_eq!(text(&mut s, "A3"), json!("Lime"));
|
||||
assert_eq!(text(&mut s, "B1"), json!(""));
|
||||
// Onto empty cells, or overlapping only the moved cells themselves, nothing is asked.
|
||||
s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "A4"), json!("Lime"));
|
||||
s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "C2"), json!("Kiwi"));
|
||||
assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_spilled_values() {
|
||||
// Cells showing a dynamic array's spilled values hold no cell of their own, but they are data.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum"));
|
||||
s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
|
||||
// The move was checked on the range's sheet but pasted on the active one, replacing its data
|
||||
// without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap();
|
||||
// Sheet1 stays active with its own selection; the moved cells are on Two.
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "C5");
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let two = wb.sheet_index("Two").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
@@ -442,141 +303,3 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_of_selection_goes_clockwise_and_anchors_there() {
|
||||
// #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows
|
||||
// then move the corner opposite the new active cell.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!(r["selection"], "B2:D4");
|
||||
seen.push(r["active"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(seen, ["D2", "D4", "B4", "B2"]);
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4");
|
||||
// One row goes end to end; a single cell stays.
|
||||
s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() {
|
||||
// With locked cells unselectable, Ctrl+. goes on to the next corner it may select.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() {
|
||||
// A corner inside a merged cell is that merged cell, whose active cell is its top-left one.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
let seen: Vec<String> = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(seen, ["C2", "D3", "B3", "B2"]);
|
||||
// A selection that is one merged cell has no other corner.
|
||||
s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
|
||||
// A corner in a hidden column is passed over, as keyboard moves pass over hidden cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3");
|
||||
// Column C selected with B1:C1 merged: its top corner's merged cell starts in column B,
|
||||
// outside the selection, so the active cell doesn't go there.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
s.execute("selection.column", json!({})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
|
||||
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
|
||||
// tab or line break, and pastes the result back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
|
||||
let rows = parse_tsv(&text);
|
||||
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
|
||||
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
|
||||
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
|
||||
// never closes), the text is read as written, and the tabs and line breaks inside it still
|
||||
// separate cells, as they would have without the leading quote.
|
||||
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
|
||||
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
|
||||
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
|
||||
// input's length), and every one is still its own cell.
|
||||
let hostile = "\"x\t".repeat(50_000);
|
||||
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
|
||||
}
|
||||
|
||||
/// `within: selection` searches only the selected cells, as Excel does with a range selected
|
||||
/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range
|
||||
/// selected, and Replace changes only them.
|
||||
#[test]
|
||||
fn find_and_replace_within_the_selection() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]}))
|
||||
.unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap();
|
||||
let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect();
|
||||
assert_eq!(cells, ["B1", "B2", "B4"], "only column B");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range");
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected");
|
||||
assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range");
|
||||
assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let at = |a: &str| sh.value(CellRef::parse(a).unwrap());
|
||||
assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear")));
|
||||
// The sheet scope is unchanged.
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap();
|
||||
assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet");
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
|
||||
//! `gridcraft-math` and drawn from the cached layout.
|
||||
//!
|
||||
//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
|
||||
//! frame for the requested font size; editing keeps the current scale and resizes the frame to
|
||||
//! the new content.
|
||||
|
||||
use gridcraft_math::{MathError, MathList};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.equation",
|
||||
"Equation",
|
||||
["Insert", "Symbols"],
|
||||
None,
|
||||
"{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
|
||||
has_doc,
|
||||
insert_equation
|
||||
),
|
||||
cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
|
||||
]
|
||||
}
|
||||
|
||||
/// Default equation font size, in points.
|
||||
pub const DEFAULT_SIZE_PT: f64 = 11.0;
|
||||
/// Space between an equation's frame and its content, in sheet pixels.
|
||||
pub const PADDING: f32 = 4.0;
|
||||
/// Sheet pixels per point.
|
||||
const PX_PER_PT: f64 = 96.0 / 72.0;
|
||||
|
||||
/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
|
||||
pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
|
||||
if s.is_finite() { s.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The frame size for `list` at `scale` pixels per em.
|
||||
fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
|
||||
let w = list.width.max(0.5) * scale + 2.0 * PADDING;
|
||||
let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
|
||||
(w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
|
||||
}
|
||||
|
||||
/// `latex` laid out, or a readable error with the position of the problem.
|
||||
fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
|
||||
gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
|
||||
}
|
||||
|
||||
/// "Error at character 5: Undefined control sequence: \foo".
|
||||
pub fn describe(latex: &str, e: &MathError) -> String {
|
||||
match e.char_pos(latex) {
|
||||
Some(p) => format!("equation error at character {p}: {}", e.message),
|
||||
None => format!("equation error: {}", e.message),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "insert.equation";
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty"));
|
||||
}
|
||||
let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
|
||||
if !(1.0..=409.0).contains(&size) {
|
||||
return Err(bad(CMD, "`size` must be between 1 and 409 points"));
|
||||
}
|
||||
let color = match p.get("color") {
|
||||
None | Some(Json::Null) => Color::Auto,
|
||||
Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
|
||||
};
|
||||
let list = lay_out(CMD, latex)?;
|
||||
let at = match cell_param(s, p, "cell")? {
|
||||
Some(c) => Some(c),
|
||||
None => cell_param(s, p, "at")?,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let at = at.unwrap_or(d.selection.active);
|
||||
let id = d.wb.next_object_id();
|
||||
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
|
||||
let mut shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Equation,
|
||||
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
|
||||
fill: Color::Auto,
|
||||
line: color,
|
||||
text: latex.to_string(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
shape.math.refresh(latex);
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "equation.edit";
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
// The same source is a no-op, unless it has no layout yet (a file with more equations than
|
||||
// are laid out at once): then OK lays it out.
|
||||
if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
|
||||
return ok();
|
||||
}
|
||||
let list = lay_out(CMD, latex)?;
|
||||
// Keep the size the equation is drawn at now; without a layout, use the default size.
|
||||
let scale = match shape.math.get(&shape.text) {
|
||||
Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
|
||||
_ => 0.0,
|
||||
};
|
||||
let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
|
||||
let (width, height) = frame_for(&list, scale);
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
shape.text = latex.to_string();
|
||||
shape.math = EquationCache::default();
|
||||
shape.math.refresh(latex);
|
||||
shape.anchor.width = width;
|
||||
shape.anchor.height = height;
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PDF
|
||||
|
||||
/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
|
||||
/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
|
||||
/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
|
||||
/// and does no work on the rest.
|
||||
pub fn source_excerpt(text: &str) -> &str {
|
||||
let cap = gridcraft_math::MAX_SOURCE_BYTES;
|
||||
if text.len() <= cap {
|
||||
return text;
|
||||
}
|
||||
let mut end = cap;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
|
||||
/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
|
||||
fn pdf_font(c: char, face: gridcraft_math::Face) -> Option<gridcraft_pdf::Font> {
|
||||
use gridcraft_math::Face;
|
||||
use gridcraft_pdf::Font;
|
||||
let text = match face {
|
||||
Face::Italic => Font::TimesItalic,
|
||||
Face::Bold => Font::TimesBold,
|
||||
Face::BoldItalic => Font::TimesBoldItalic,
|
||||
Face::Sans => Font::Helvetica,
|
||||
Face::SansBold => Font::HelveticaBold,
|
||||
Face::SansItalic => Font::HelveticaOblique,
|
||||
Face::Mono => Font::Courier,
|
||||
Face::Roman | Face::Large(_) => Font::TimesRoman,
|
||||
};
|
||||
// Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
|
||||
// operators (minus, equals, plus) match the maths better than the text faces'.
|
||||
if c.is_ascii_alphanumeric() {
|
||||
return Some(text);
|
||||
}
|
||||
if Font::Symbol.can_encode(c) {
|
||||
return Some(Font::Symbol);
|
||||
}
|
||||
text.can_encode(c).then_some(text)
|
||||
}
|
||||
|
||||
/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
|
||||
/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
|
||||
pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) {
|
||||
use gridcraft_math::{Face, Item, PathCmd};
|
||||
let (x, y, w, h) = rect;
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
|
||||
if !(s.is_finite() && s > 0.01) {
|
||||
return;
|
||||
}
|
||||
let s = s.min(4000.0);
|
||||
let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
|
||||
let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
|
||||
for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) {
|
||||
match item {
|
||||
Item::Glyph(g) => {
|
||||
let color = col(g.color);
|
||||
let (gx, base) = (ox + g.x * s, oy + g.y * s);
|
||||
// The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
|
||||
// at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
|
||||
if g.ch == '\u{221A}' {
|
||||
let size = (g.height + g.depth) * s / 1.064;
|
||||
if size.is_finite() && size >= 0.5 {
|
||||
let top = base - g.height * s;
|
||||
pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let large = matches!(g.face, Face::Large(_));
|
||||
let size = if large { (g.height + g.depth) * s } else { g.size * s };
|
||||
if !(size.is_finite() && size >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
|
||||
match pick {
|
||||
Some((c, font)) => {
|
||||
let text = c.to_string();
|
||||
if large {
|
||||
// Centred on the glyph's box, like the screen.
|
||||
let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
|
||||
let tw = gridcraft_pdf::text_width(font, size, &text);
|
||||
pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
|
||||
} else {
|
||||
pg.text(gx, base, size, font, color, &text);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// [?]: the glyph's box with a question mark.
|
||||
let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
|
||||
pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
|
||||
let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
|
||||
let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?");
|
||||
pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?");
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::Rule(r) => {
|
||||
if r.width > 0.0 && r.height > 0.0 {
|
||||
pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
|
||||
}
|
||||
}
|
||||
Item::Path(p) => {
|
||||
let (px, py) = (ox + p.x * s, oy + p.y * s);
|
||||
let mut subs: Vec<Vec<[f32; 2]>> = vec![];
|
||||
let mut cur: Vec<[f32; 2]> = vec![];
|
||||
let at = |a: f32, b: f32| [px + a * s, py + b * s];
|
||||
for c in &p.commands {
|
||||
match *c {
|
||||
PathCmd::MoveTo(a, b) => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![at(a, b)];
|
||||
}
|
||||
PathCmd::LineTo(a, b) => cur.push(at(a, b)),
|
||||
PathCmd::QuadTo(x1, y1, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, p1) = (at(x1, y1), at(a, b));
|
||||
for k in 1..=8 {
|
||||
let t = k as f32 / 8.0;
|
||||
let u = 1.0 - t;
|
||||
cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
|
||||
for k in 1..=10 {
|
||||
let t = k as f32 / 10.0;
|
||||
let u = 1.0 - t;
|
||||
let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::Close => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
subs.push(cur);
|
||||
}
|
||||
let color = col(p.color);
|
||||
for sub in subs.iter().filter(|v| v.len() <= 4000) {
|
||||
if p.fill && sub.len() >= 3 {
|
||||
// The PDF fill rule handles concave outlines itself.
|
||||
pg.polygon(sub, Some(color), None);
|
||||
} else {
|
||||
pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,10 @@
|
||||
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline,
|
||||
TrendlineKind, Workbook,
|
||||
};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
mod cells;
|
||||
mod chart;
|
||||
@@ -28,7 +22,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}
|
||||
pub(crate) use scripts::has_scripts;
|
||||
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
|
||||
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
|
||||
pub(crate) use window::{keep_view_modes, keep_watch_list, window_param};
|
||||
pub(crate) use window::{keep_watch_list, window_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
@@ -97,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Bring Forward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
|
||||
"{kind: chart|image|shape, id, toFront?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, true)
|
||||
),
|
||||
@@ -106,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Send Backward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
|
||||
"{kind: chart|image|shape, id, toBack?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, false)
|
||||
),
|
||||
@@ -116,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
"{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
@@ -310,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
new_cell_style
|
||||
),
|
||||
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
|
||||
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used),
|
||||
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
|
||||
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
|
||||
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
|
||||
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Whether `c` has checkbox formatting.
|
||||
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
@@ -51,7 +48,6 @@ pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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 {
|
||||
@@ -123,10 +119,25 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
|
||||
/// ([`crate::Prefs::recent_functions`]).
|
||||
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(json!(s.prefs.recent_functions))
|
||||
let mut seen: Vec<String> = 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<Json> {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
@@ -43,7 +40,6 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
"dataLabels" => c.data_labels = on,
|
||||
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
|
||||
"gridlines" => c.gridlines = on,
|
||||
"axisTitles" => {
|
||||
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
use gridcraft_model::ObjKind;
|
||||
|
||||
use super::chart::format_series;
|
||||
use super::{
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param,
|
||||
u32_param,
|
||||
};
|
||||
|
||||
/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that
|
||||
/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for
|
||||
/// the `object.*` commands, where the kind is required.
|
||||
fn kind_param(p: &Json, cmd: &str) -> Result<ObjKind> {
|
||||
let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation");
|
||||
match p.get("kind") {
|
||||
None => Ok(ObjKind::Shape),
|
||||
Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err),
|
||||
Some(_) => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an
|
||||
/// equation, as `Sheet::object_anchor` has it for the `object.*` commands.
|
||||
fn shape_is(kind: ObjKind, sh: &Shape) -> bool {
|
||||
kind != ObjKind::Equation || sh.kind == ShapeKind::Equation
|
||||
}
|
||||
use super::*;
|
||||
|
||||
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if str_param(p, "kind") == Some("shape") {
|
||||
@@ -54,7 +31,7 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<J
|
||||
|
||||
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
|
||||
let kind = kind_param(p, "arrange")?;
|
||||
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<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
@@ -72,17 +49,23 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
// An id that is not an object of this kind is an error, so a stale id, or a shape's id
|
||||
// given as an equation, never reorders something else or succeeds silently.
|
||||
let found = match kind {
|
||||
ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)),
|
||||
ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)),
|
||||
ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)),
|
||||
ObjKind::Shape | ObjKind::Equation => {
|
||||
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
|
||||
match kind.as_str() {
|
||||
"chart" => {
|
||||
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.charts, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
};
|
||||
found.ok_or_else(|| bad("arrange", "no such object"))?;
|
||||
"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)
|
||||
})
|
||||
}
|
||||
@@ -92,8 +75,7 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut v = Vec::new();
|
||||
for c in &sh.shapes {
|
||||
let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" };
|
||||
v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
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)}));
|
||||
@@ -101,51 +83,33 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
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)}));
|
||||
}
|
||||
// Slicers paint above the other objects.
|
||||
for c in &sh.slicers {
|
||||
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
|
||||
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations.
|
||||
fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
|
||||
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
|
||||
match kind {
|
||||
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
"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<Json> {
|
||||
let kind = kind_param(p, "arrange.align")?;
|
||||
// `ids` may be absent (nothing to align) but, present, must be an array whose every entry is
|
||||
// an object id naming an object of the kind: a stale or mismatched id, or a value of another
|
||||
// shape, is an error, as for `arrange` and the `object.*` commands, not something silently
|
||||
// left out.
|
||||
let bad_ids = || bad("arrange.align", "ids must be an array of object ids");
|
||||
let ids: Vec<u32> = match p.get("ids") {
|
||||
None => Vec::new(),
|
||||
Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad_ids()),
|
||||
};
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let ids: Vec<u32> =
|
||||
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)
|
||||
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 let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) {
|
||||
return Err(bad("arrange.align", format!("no such object: {missing}")));
|
||||
}
|
||||
if geo.len() < 2 {
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
@@ -154,7 +118,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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) {
|
||||
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() {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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<Json> = 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 cmds: Vec<Json> =
|
||||
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()}));
|
||||
@@ -109,16 +104,12 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
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(_)))) {
|
||||
@@ -129,43 +120,17 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
|
||||
}
|
||||
let result = ran?;
|
||||
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(id, 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.
|
||||
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
|
||||
/// Redo work there again.
|
||||
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
// 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)
|
||||
Ok(json!({"stopped": s.abort_groups(uid)}))
|
||||
}
|
||||
|
||||
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// 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.
|
||||
@@ -38,35 +35,6 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
|
||||
wb.names.extend(keep);
|
||||
}
|
||||
|
||||
/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't
|
||||
/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's
|
||||
/// current view when they swap in another snapshot of the workbook, and a view kept this way
|
||||
/// doesn't make a saved workbook dirty.
|
||||
pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) {
|
||||
// Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which
|
||||
// keeps the sheet count and position: the sheet is the one at the same index, under a name the
|
||||
// restored snapshot doesn't have.
|
||||
let renamed = |i: usize| {
|
||||
(now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name))
|
||||
};
|
||||
let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view);
|
||||
let changed: Vec<(usize, SheetViewMode)> =
|
||||
d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dirty = d.is_dirty();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
for (i, v) in changed {
|
||||
if let Some(sh) = wb.sheet_mut(i) {
|
||||
sh.view = v;
|
||||
}
|
||||
}
|
||||
if !dirty {
|
||||
d.saved = d.wb.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut views = stash_map(&d.wb, VIEWS_NAME);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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?, 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.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", 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,11 +191,7 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
|
||||
wb.set_active_sheet(target_sheet(s, p)?);
|
||||
}
|
||||
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
|
||||
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)?
|
||||
};
|
||||
let bytes = crate::io::save_bytes(&wb, &target)?;
|
||||
crate::io::write_file(&target, &bytes)?;
|
||||
Ok(json!({"path": target, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -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::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
|
||||
use super::*;
|
||||
use crate::selection::current_region;
|
||||
|
||||
const CMD: &str = "data.forecastSheet";
|
||||
@@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
|
||||
let mut sheet = Sheet::new(sheet_name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
};
|
||||
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
|
||||
let mut cell = Cell::formula(Formula::new(&text));
|
||||
@@ -424,7 +424,6 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -90,14 +87,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
format_cells
|
||||
),
|
||||
// Alignment
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+L"),
|
||||
..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left))
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+E"),
|
||||
..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center))
|
||||
},
|
||||
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
|
||||
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
|
||||
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
|
||||
@@ -656,7 +647,8 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.clear_content();
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -673,7 +665,8 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.clear_content();
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -690,10 +683,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let blocked = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
// A Python cell's result is blocked and unblocked like a formula's.
|
||||
.filter(|(_, x)| {
|
||||
(x.formula.is_some() || x.py.is_some()) && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill)
|
||||
});
|
||||
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||
}
|
||||
cx.changed.extend(touched);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::{
|
||||
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Built-in cell styles (our own palette).
|
||||
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::{
|
||||
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- sizes
|
||||
|
||||
@@ -140,44 +138,46 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let measure = s.text_width.clone();
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let mut widths = Vec::new();
|
||||
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
|
||||
let mut distinct = 0usize;
|
||||
// Selected columns, then one pass over the populated cells (the store is row-oriented, so a
|
||||
// scan per column would revisit every row for each selected column, empty or not).
|
||||
let cols: std::collections::BTreeSet<u32> = lines.iter().flat_map(|&(a, b)| a..=b.min(a + 16383)).collect();
|
||||
let mut maxes: std::collections::BTreeMap<u32, f32> = Default::default();
|
||||
if let (Some(u), Some(&first), Some(&last)) = (sh.used_range(), cols.first(), cols.last()) {
|
||||
let span = RangeRef::new(CellRef::new(u.start.row, first), CellRef::new(u.end.row, last));
|
||||
for (cell, _) in sh.cells.iter_range(span) {
|
||||
if !cols.contains(&cell.col) || sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let id = sh.style_id(cell);
|
||||
let st = cx.wb.styles.get(id);
|
||||
let text = crate::display::cell_text(&cx.wb, sh, cell);
|
||||
for line in text.lines() {
|
||||
let w = match &measure {
|
||||
Some(m) => {
|
||||
let known = measured.entry(id).or_default();
|
||||
match known.get(line) {
|
||||
Some(w) => *w,
|
||||
None => {
|
||||
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
|
||||
known.insert(line.to_owned(), w);
|
||||
distinct += 1;
|
||||
w
|
||||
let used = sh.used_range();
|
||||
for (a, b) in lines {
|
||||
for c in a..=b.min(a + 16383) {
|
||||
let Some(u) = used else { continue };
|
||||
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
|
||||
let mut max: f32 = 0.0;
|
||||
for (cell, _) in sh.cells.iter_range(col) {
|
||||
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let id = sh.style_id(cell);
|
||||
let st = cx.wb.styles.get(id);
|
||||
let text = crate::display::cell_text(&cx.wb, sh, cell);
|
||||
for line in text.lines() {
|
||||
let w = match &measure {
|
||||
Some(m) => {
|
||||
let known = measured.entry(id).or_default();
|
||||
match known.get(line) {
|
||||
Some(w) => *w,
|
||||
None => {
|
||||
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
|
||||
known.insert(line.to_owned(), w);
|
||||
distinct += 1;
|
||||
w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
let w = w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell);
|
||||
let max = maxes.entry(cell.col).or_insert(0.0);
|
||||
*max = max.max(w);
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
widths.push((c, max.clamp(8.0, 1785.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let widths: Vec<(u32, f32)> = maxes.into_iter().filter(|&(_, m)| m > 0.0).map(|(c, m)| (c, m.clamp(8.0, 1785.0))).collect();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (c, w) in widths {
|
||||
let e = sh.cols.entry(c).or_default();
|
||||
|
||||
@@ -4,10 +4,7 @@ use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
|
||||
target_range, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -69,15 +66,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(
|
||||
query "formulas.traceError",
|
||||
"Trace Error",
|
||||
["Formulas", "Formula Auditing"],
|
||||
None,
|
||||
"{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.",
|
||||
has_doc,
|
||||
trace_error
|
||||
),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
|
||||
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
|
||||
@@ -635,7 +623,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(c);
|
||||
// The second column holds the refers-to text as a text value, with no formula.
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None });
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
@@ -662,79 +650,6 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// Trace Error goes back at most this many links; a longer chain stops there.
|
||||
const TRACE_ERROR_LINKS: usize = 10_000;
|
||||
|
||||
/// Trace Error: from the cell, the first reference in its formula that shows the same error
|
||||
/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the
|
||||
/// cell the error starts in: one none of whose references shows it, or one without references.
|
||||
/// Arrows link the cells on the way, and lead from the source's own references into it.
|
||||
fn trace_error(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use std::collections::HashSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone());
|
||||
let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()});
|
||||
let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else {
|
||||
return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
};
|
||||
// A value spilled by a dynamic array comes from its anchor's formula.
|
||||
let precedents = |(si, c): (usize, CellRef)| {
|
||||
let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c));
|
||||
d.calc.graph.precedents_of((si, anchor.unwrap_or(c)))
|
||||
};
|
||||
// The walk keeps to cells it hasn't been to, so a circular reference ends it.
|
||||
let mut chain = vec![(sheet, at)];
|
||||
let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]);
|
||||
let mut truncated = false;
|
||||
let mut last = (sheet, at);
|
||||
while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) {
|
||||
if !seen.insert(next) {
|
||||
break;
|
||||
}
|
||||
if chain.len() > TRACE_ERROR_LINKS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
last = next;
|
||||
}
|
||||
let mut arrows = Vec::new();
|
||||
// A chain cut short hasn't reached its source.
|
||||
if !truncated {
|
||||
let mut drawn = HashSet::new();
|
||||
for a in precedents(last) {
|
||||
if drawn.insert(a) {
|
||||
arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// From the source outwards, as the error travels.
|
||||
for pair in chain.windows(2).rev() {
|
||||
if let [to, from] = pair {
|
||||
arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"}));
|
||||
}
|
||||
}
|
||||
let source = if truncated { Json::Null } else { cell(last) };
|
||||
Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated}))
|
||||
}
|
||||
|
||||
/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled
|
||||
/// into the area by a dynamic array.
|
||||
fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> {
|
||||
let sh = wb.sheet(a.sheet)?;
|
||||
let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some();
|
||||
let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at);
|
||||
let spilled = sh
|
||||
.spill
|
||||
.range(a.range.start..)
|
||||
.take_while(|(at, _)| **at <= a.range.end)
|
||||
.find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds))
|
||||
.map(|(at, _)| *at);
|
||||
stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c))
|
||||
}
|
||||
|
||||
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
|
||||
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
|
||||
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
|
||||
|
||||
+20
-1676
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
|
||||
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
@@ -260,7 +257,6 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
|
||||
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- conditional formatting
|
||||
|
||||
@@ -333,7 +330,6 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
|
||||
split_sheet, str_param, target_ranges, target_sheet,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- links & comments
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
|
||||
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
|
||||
@@ -153,7 +150,6 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line,
|
||||
text: str_param(p, "text").unwrap_or("").into(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
|
||||
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
@@ -60,7 +57,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e)?;
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", has_doc, cell_get),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
@@ -137,13 +137,6 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
"comment": sh.comments.get(&c),
|
||||
"hyperlink": sh.hyperlinks.get(&c),
|
||||
"python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({
|
||||
"code": py.code,
|
||||
"output": if py.object { "object" } else { "value" },
|
||||
"typeName": py.output.as_ref().map(|o| o.type_name.as_str()),
|
||||
"preview": py.output.as_ref().map(|o| o.preview.as_str()),
|
||||
"stale": py.stale,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user