mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:21:45 +00:00
Merge upstream/main into fix/chart-work-budgets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c35878948a
83 files changed
+3417
-648
No files matched your search
@@ -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
|
||||
@@ -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
+22
@@ -995,6 +995,26 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2186,6 +2206,8 @@ dependencies = [
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"gridcraft-engine",
|
||||
|
||||
@@ -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-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) |
|
||||
| 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 |
|
||||
@@ -177,6 +178,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) |
|
||||
@@ -196,6 +198,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
|
||||
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
@@ -259,6 +262,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) |
|
||||
| 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 |
|
||||
@@ -289,6 +293,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 |
|
||||
|
||||
@@ -16,6 +16,10 @@ 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.
|
||||
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -26,4 +30,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"] }
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `window.gridcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is GridCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||
//! applying a command the page was told had failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
state: Arc<AtomicU8>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
ControlRequest::abandon(&self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use gridcraft_ui_egui::{ControlRequest, SheetApp};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{POLL_MS, Pending};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "gridcraft";
|
||||
|
||||
/// Give the app its control queue and put `window.gridcraft` on the page. An existing
|
||||
/// `window.gridcraft` keeps the functions it already has.
|
||||
pub fn install(app: &mut SheetApp, ctx: egui::Context) {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
app.control_rx = Some(requests);
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||
if queue.send(control).is_err() {
|
||||
pending.answered = true;
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = pending.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ControlRequest`'s `state` values (`gridcraft_ui_egui::control`): the app has started it, or its
|
||||
/// sender gave up.
|
||||
const TAKEN: u8 = 1;
|
||||
const ABANDONED: u8 = 2;
|
||||
|
||||
fn pending() -> (Pending, ControlRequest) {
|
||||
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||
let (waiting, control) = pending();
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answered_call_is_not_released() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert!(control.take());
|
||||
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert_eq!(waiting.poll(), None);
|
||||
drop(control);
|
||||
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`,
|
||||
//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods
|
||||
//! (`api`).
|
||||
//! `?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(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[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}));
|
||||
@@ -96,6 +99,7 @@ pub fn start() {
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,7 +33,8 @@ sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
sync_github_name = true
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MacJedi42]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,8 @@ impl Calc {
|
||||
for (si, sheet) in wb.sheets.iter().enumerate() {
|
||||
for (c, cell) in sheet.cells.iter() {
|
||||
if let Some(f) = &cell.formula
|
||||
&& let Some(e) = f.expr()
|
||||
&& let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e))
|
||||
{
|
||||
let (areas, dynamic) = precedents(wb, si, &e);
|
||||
self.graph.insert((si, c), Node { areas, dynamic });
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
@@ -209,14 +208,14 @@ impl Calc {
|
||||
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
let precedent =
|
||||
wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e)));
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
match precedent {
|
||||
Some((areas, dynamic)) => {
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
|
||||
@@ -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
|
||||
@@ -2313,6 +2379,45 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the
|
||||
/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that
|
||||
/// carry their tree and for formulas that only carry their text (as after loading a file).
|
||||
#[test]
|
||||
fn graph_built_from_text_only_formulas_matches_cached() {
|
||||
fn run(cached: bool) -> Vec<Value> {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "2");
|
||||
for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] {
|
||||
// E1 does not parse, so it has no tree either way and only covers the `None` branch.
|
||||
let mut formula = Formula::new(f);
|
||||
if !cached {
|
||||
formula.expr = None;
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula));
|
||||
}
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
let mut out: Vec<Value> = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect();
|
||||
// Editing the input must reach the dependents through the rebuilt graph.
|
||||
t.set("A1", "5");
|
||||
out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a)));
|
||||
// A text-only formula that is rewritten through `cells_changed` joins the graph too.
|
||||
let mut formula = Formula::new("=A1+100");
|
||||
if !cached {
|
||||
formula.expr = None;
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula));
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]);
|
||||
t.set("A1", "7");
|
||||
out.push(t.get("F1"));
|
||||
out
|
||||
}
|
||||
let cached = run(true);
|
||||
assert_eq!(cached, run(false));
|
||||
assert_eq!(cached[4], Value::Number(50.0));
|
||||
assert_eq!(cached[7], Value::Number(107.0));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
|
||||
@@ -25,9 +25,11 @@ impl Scale {
|
||||
(0..=n)
|
||||
.map(|i| {
|
||||
let v = self.min + i as f64 * self.step;
|
||||
// Snap away accumulated float error.
|
||||
// Snap away accumulated float error, but only onto a multiple of the step that is
|
||||
// really that close: a `min` off the step grid must keep its own ticks.
|
||||
let snapped = (v / self.step).round() * self.step;
|
||||
if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped }
|
||||
let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v };
|
||||
if v.abs() < self.step * 1e-9 { 0.0 } else { v }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale {
|
||||
Scale { min, max, step }
|
||||
}
|
||||
|
||||
/// Exact fixed scale (100% stacked axes).
|
||||
pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale {
|
||||
Scale { min, max, step }
|
||||
}
|
||||
|
||||
/// A number format prepared once and reused for many labels.
|
||||
pub(crate) struct Fmt {
|
||||
general: bool,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::axis::{Fmt, Scale, clean, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, points_per_series, series_color};
|
||||
use crate::trend::{auto_name, fit_trendline, moving_average};
|
||||
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::lines::{decimate_area, draw_markers, draw_runs};
|
||||
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
|
||||
use super::{
|
||||
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, MIN_BAR_PX, Prim, Rgba, Scale,
|
||||
SeriesData, TEXT, VAlign, WHITE, clean, fixed_scale, nice_scale,
|
||||
SeriesData, TEXT, VAlign, WHITE, clean, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
|
||||
@@ -249,7 +249,7 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
|
||||
}));
|
||||
let (lo, hi) = range.unwrap_or((0.0, 0.0));
|
||||
let (scale, fmt) = if all100 {
|
||||
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
|
||||
(Scale { min: if lo < -1e-9 { -1.0 } else { 0.0 }, max: if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, step: 0.2 }, Fmt::new("0%"))
|
||||
} else {
|
||||
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
|
||||
(nice_scale(lo, hi, force_zero), Fmt::new(code))
|
||||
@@ -291,7 +291,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
if ncat == 0 {
|
||||
return;
|
||||
}
|
||||
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let kinds: Vec<ChartKind> = data.series.iter().enumerate().map(|(i, s)| if horizontal { chart.kind } else { plot_kind(chart, s, i) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||
|
||||
@@ -15,9 +15,10 @@ pub enum Swatch {
|
||||
Marker,
|
||||
}
|
||||
|
||||
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
|
||||
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
|
||||
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
|
||||
/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule
|
||||
/// as `series_plot_kind`, applied to the already resolved series kind.
|
||||
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind {
|
||||
crate::resolve::plot_kind(chart.kind, Some(s.kind), i)
|
||||
}
|
||||
|
||||
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
|
||||
@@ -60,8 +61,9 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
let series = data.series.iter().take(MAX_SERIES);
|
||||
let mut out: Vec<(String, Rgba, Swatch)> = series
|
||||
.clone()
|
||||
.map(|s| {
|
||||
let sw = match plot_kind(chart, s) {
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let sw = match plot_kind(chart, s, i) {
|
||||
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
|
||||
@@ -71,7 +73,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
})
|
||||
.collect();
|
||||
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
|
||||
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
|
||||
for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) {
|
||||
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
|
||||
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, lay
|
||||
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
|
||||
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
|
||||
if settings.show_keys {
|
||||
let swatch = match plot_kind(chart, series) {
|
||||
let swatch = match plot_kind(chart, series, i) {
|
||||
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
_ => Swatch::Box,
|
||||
|
||||
@@ -1419,6 +1419,38 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_and_resolving_agree_on_the_combo_plot_kind() {
|
||||
// Resolved series carry `effective_kind`; drawing re-derives the kind from it. Both paths
|
||||
// must give the same kind for every chart kind, series kind (or none) and series index.
|
||||
let series_kinds = std::iter::once(None).chain(ALL_KINDS.iter().map(|k| Some(*k)));
|
||||
for ck in ALL_KINDS {
|
||||
for sk in series_kinds.clone() {
|
||||
for i in 0..3 {
|
||||
let mut c = chart(ck);
|
||||
c.series = (0..3).map(|j| Series { kind: if j == i { sk } else { None }, ..Default::default() }).collect();
|
||||
let from_chart = series_plot_kind(&c, i);
|
||||
let resolved = crate::resolve::effective_kind(ck, sk, i);
|
||||
assert_eq!(crate::resolve::plot_kind(ck, Some(resolved), i), from_chart, "{ck:?} {sk:?} {i}");
|
||||
if ck != ChartKind::Combo {
|
||||
assert_eq!(from_chart, ck);
|
||||
} else {
|
||||
assert_ne!(from_chart, ChartKind::Combo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_of_a_scale_off_the_step_grid_start_at_its_min() {
|
||||
let s = Scale { min: 3.0, max: 7.0, step: 4.0 };
|
||||
assert_eq!(s.ticks(), vec![3.0, 7.0]);
|
||||
// Aligned scales still snap float error away.
|
||||
let s = Scale { min: 0.0, max: 1.0, step: 0.1 };
|
||||
assert!((s.ticks()[3] - 0.3).abs() < 1e-12 && s.ticks()[0] == 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
|
||||
let d = ChartData {
|
||||
|
||||
@@ -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};
|
||||
+115
-1
@@ -406,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();
|
||||
@@ -416,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) {
|
||||
@@ -454,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -66,7 +66,14 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Extension moves the far corner, not the active cell.
|
||||
let from = if extend { sel.moving_end() } else { sel.active };
|
||||
// Skip hidden rows/columns and step over merged areas.
|
||||
let mut to = if jmp {
|
||||
let mut to = if jmp && extend && (dr == 0) != (dc == 0) {
|
||||
// Excel: Ctrl+Shift+arrow searches the active cell's column (or row) from the moving
|
||||
// edge, so a header row selected with Ctrl+Shift+Right extends down to the end of the
|
||||
// active column's data.
|
||||
let probe = if dr != 0 { CellRef::new(from.row, sel.active.col) } else { CellRef::new(sel.active.row, from.col) };
|
||||
let edge = jump(sheet, probe, dr.signum(), dc.signum());
|
||||
if dr != 0 { CellRef::new(edge.row, from.col) } else { CellRef::new(from.row, edge.col) }
|
||||
} else if jmp {
|
||||
jump(sheet, from, dr.signum(), dc.signum())
|
||||
} else {
|
||||
let mut t = from.offset_clamped(dr, dc);
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -179,10 +179,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
|
||||
let mut range = None;
|
||||
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
|
||||
let mut top = at.row - 1;
|
||||
while top > 0
|
||||
&& (numeric(CellRef::new(top - 1, at.col))
|
||||
|| sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col)))
|
||||
{
|
||||
while top > 0 && numeric(CellRef::new(top - 1, at.col)) {
|
||||
top -= 1;
|
||||
}
|
||||
if numeric(CellRef::new(at.row - 1, at.col)) {
|
||||
@@ -954,14 +951,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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+418
-1
@@ -2067,6 +2067,19 @@ fn formula_at(s: &Session, a: &str) -> Option<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_of_a_single_cell_stops_at_a_blank_cell() {
|
||||
// #3291: a blank row between two blocks of numbers ended the guess one block too late, so the
|
||||
// second total summed the first block (and its total) as well.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "B4", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B7", "input": "=SUM(B4:B6)"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B9", "values": [[10], [20]]})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B11"})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B11"), Value::Number(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_over_a_row_totals_it_to_the_right() {
|
||||
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
|
||||
@@ -3130,7 +3143,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());
|
||||
@@ -4170,6 +4183,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.
|
||||
@@ -4791,6 +4823,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();
|
||||
@@ -4840,6 +5046,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]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Ctrl/Cmd+Shift+arrow searches the active cell's column (or row) from the moving edge (#3484).
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn table() -> Session {
|
||||
// Headers in A2:K2, a key column in A3:A13, nothing else filled.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let header: Vec<i64> = (1..=11).collect();
|
||||
s.execute("range.setValues", json!({"range": "A2:K2", "values": [header]})).unwrap();
|
||||
let keys: Vec<Vec<i64>> = (1..=11).map(|i| vec![i]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A3:A13", "values": keys})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ctrl_shift(s: &mut Session, dr: i64, dc: i64) -> String {
|
||||
s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true, "jump": true})).unwrap();
|
||||
s.doc().unwrap().selection.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_down_after_ctrl_shift_right_follows_the_active_column() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K2");
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K13", "column A's data ends on row 13; column K's ends on row 2");
|
||||
// From the end of column A's data the next press runs to the sheet's last row.
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_right_after_ctrl_shift_down_follows_the_active_row() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:A13");
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K13", "row 2's data ends in column K; row 13's ends in column A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_down_after_ctrl_shift_left_follows_the_active_column() {
|
||||
// The active cell is on the right: column K, empty below the header, decides, not column A.
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "K2"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, 0, -1), "A2:K2");
|
||||
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_right_after_ctrl_shift_up_follows_the_active_row() {
|
||||
// The active cell is at the bottom: row 13, empty right of the key, decides, not row 2.
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"cell": "A13"})).unwrap();
|
||||
assert_eq!(ctrl_shift(&mut s, -1, 0), "A2:A13");
|
||||
assert_eq!(ctrl_shift(&mut s, 0, 1), "2:13", "row 13's data ends in column A, so the rows extend to the last column");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
mod csv_export_budget;
|
||||
mod ctrl_shift_arrow_active_line;
|
||||
mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
|
||||
@@ -33,19 +33,40 @@ pub(super) fn var_p(x: &[f64]) -> R<f64> {
|
||||
Ok(devsq(x) / x.len() as f64)
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||
pub(super) fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
/// Sorts `v` if it is already monotone in `total_cmp` order (a descending slice is reversed) and
|
||||
/// says whether it did. At most two passes, each stopping at the first out-of-order pair, so
|
||||
/// unordered data costs next to nothing. Needed because the std sort recognises ascending and
|
||||
/// descending runs in O(n) with a small constant; selection is O(n) too but measured 1.2-1.8x
|
||||
/// slower on such input.
|
||||
fn sorted_if_monotone(v: &mut [f64]) -> bool {
|
||||
if v.is_sorted_by(|a, b| a.total_cmp(b).is_le()) {
|
||||
return true;
|
||||
}
|
||||
if v.is_sorted_by(|a, b| a.total_cmp(b).is_ge()) {
|
||||
v.reverse();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn median(v: Vec<f64>) -> R<f64> {
|
||||
if v.is_empty() {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
let n = s.len();
|
||||
let hi = s.get(n / 2).copied().unwrap_or(0.0);
|
||||
if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
|
||||
let mut v = v;
|
||||
let n = v.len();
|
||||
if sorted_if_monotone(&mut v) {
|
||||
let hi = v.get(n / 2).copied().unwrap_or(0.0);
|
||||
return if n % 2 == 1 { Ok(hi) } else { Ok(v.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) };
|
||||
}
|
||||
let (lower, hi, _) = v.select_nth_unstable_by(n / 2, f64::total_cmp);
|
||||
let hi = *hi;
|
||||
if n % 2 == 1 { Ok(hi) } else { Ok(lower.iter().copied().max_by(f64::total_cmp).unwrap_or(0.0).midpoint(hi)) }
|
||||
}
|
||||
|
||||
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
|
||||
@@ -126,34 +147,54 @@ pub(super) fn max_of(v: &[f64]) -> f64 {
|
||||
}
|
||||
|
||||
/// k-th largest (k is rounded up like Excel).
|
||||
fn kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
|
||||
pub(super) fn kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
|
||||
let k = k.ceil();
|
||||
if k.is_nan() || k < 1.0 || k > v.len() as f64 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
let mut v = v;
|
||||
let k = k as usize;
|
||||
let idx = if largest { s.len() - k } else { k - 1 };
|
||||
s.get(idx).copied().ok_or(CellError::Num)
|
||||
let idx = if largest { v.len() - k } else { k - 1 };
|
||||
// Selection instead of a full sort (monotone input is read directly); same total order, so the
|
||||
// same bit pattern as `sorted(v)[idx]`.
|
||||
if sorted_if_monotone(&mut v) {
|
||||
return v.get(idx).copied().ok_or(CellError::Num);
|
||||
}
|
||||
Ok(*v.select_nth_unstable_by(idx, f64::total_cmp).1)
|
||||
}
|
||||
|
||||
fn interp(s: &[f64], pos: f64) -> R<f64> {
|
||||
/// Value at fractional rank `pos` of the sorted data, linearly interpolated, found by selection.
|
||||
fn interp(mut v: Vec<f64>, pos: f64) -> R<f64> {
|
||||
let i = pos.floor();
|
||||
let frac = pos - i;
|
||||
let i = i as usize;
|
||||
let lo = *s.get(i).ok_or(CellError::Num)?;
|
||||
match s.get(i + 1) {
|
||||
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
|
||||
_ => Ok(lo),
|
||||
if i >= v.len() {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
if sorted_if_monotone(&mut v) {
|
||||
let lo = v.get(i).copied().ok_or(CellError::Num)?;
|
||||
return match v.get(i + 1) {
|
||||
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
|
||||
_ => Ok(lo),
|
||||
};
|
||||
}
|
||||
let (_, lo, upper) = v.select_nth_unstable_by(i, f64::total_cmp);
|
||||
let lo = *lo;
|
||||
// The next order statistic is the minimum of the upper partition.
|
||||
if frac > 0.0
|
||||
&& let Some(hi) = upper.iter().copied().min_by(f64::total_cmp)
|
||||
{
|
||||
return Ok(lo + frac * (hi - lo));
|
||||
}
|
||||
Ok(lo)
|
||||
}
|
||||
|
||||
pub(super) fn percentile_inc(v: Vec<f64>, k: f64) -> R<f64> {
|
||||
if v.is_empty() || !(0.0..=1.0).contains(&k) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
interp(&s, k * (s.len() - 1) as f64)
|
||||
let pos = k * (v.len() - 1) as f64;
|
||||
interp(v, pos)
|
||||
}
|
||||
|
||||
pub(super) fn percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
|
||||
@@ -165,8 +206,7 @@ pub(super) fn percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
|
||||
if pos < 1.0 || pos > n {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
interp(&s, pos - 1.0)
|
||||
interp(v, pos - 1.0)
|
||||
}
|
||||
|
||||
pub(super) fn quartile_inc(v: Vec<f64>, q: f64) -> R<f64> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::descriptive::{kth, median, percentile_exc, percentile_inc, sorted};
|
||||
use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size};
|
||||
use crate::util::R;
|
||||
use crate::util::testutil::*;
|
||||
use gridcraft_core::{CellError, Value};
|
||||
|
||||
@@ -200,6 +202,126 @@ fn min_max_order() {
|
||||
close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5);
|
||||
}
|
||||
|
||||
// Reference implementations: the previous full-sort versions, as the oracle for the selection ones.
|
||||
fn old_interp(s: &[f64], pos: f64) -> R<f64> {
|
||||
let i = pos.floor();
|
||||
let frac = pos - i;
|
||||
let i = i as usize;
|
||||
let lo = *s.get(i).ok_or(CellError::Num)?;
|
||||
match s.get(i + 1) {
|
||||
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
|
||||
_ => Ok(lo),
|
||||
}
|
||||
}
|
||||
|
||||
fn old_median(v: Vec<f64>) -> R<f64> {
|
||||
if v.is_empty() {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
let n = s.len();
|
||||
let hi = s.get(n / 2).copied().unwrap_or(0.0);
|
||||
if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
|
||||
}
|
||||
|
||||
fn old_kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
|
||||
let k = k.ceil();
|
||||
if k.is_nan() || k < 1.0 || k > v.len() as f64 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
let k = k as usize;
|
||||
let idx = if largest { s.len() - k } else { k - 1 };
|
||||
s.get(idx).copied().ok_or(CellError::Num)
|
||||
}
|
||||
|
||||
fn old_percentile_inc(v: Vec<f64>, k: f64) -> R<f64> {
|
||||
if v.is_empty() || !(0.0..=1.0).contains(&k) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
old_interp(&s, k * (s.len() - 1) as f64)
|
||||
}
|
||||
|
||||
fn old_percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
|
||||
let n = v.len() as f64;
|
||||
if v.is_empty() || !(k > 0.0 && k < 1.0) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let pos = k * (n + 1.0);
|
||||
if pos < 1.0 || pos > n {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let s = sorted(v);
|
||||
old_interp(&s, pos - 1.0)
|
||||
}
|
||||
|
||||
/// Same Ok bit pattern or same error.
|
||||
fn same(a: R<f64>, b: R<f64>) -> bool {
|
||||
match (a, b) {
|
||||
(Ok(x), Ok(y)) => x.to_bits() == y.to_bits(),
|
||||
(Err(x), Err(y)) => x == y,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_matches_full_sort_bit_for_bit() {
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let specials = [0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -f64::NAN, f64::MAX, f64::MIN_POSITIVE, 1.0, -1.0];
|
||||
for len in (0..=40).chain([63, 64, 65, 100, 257, 1000, 4096]) {
|
||||
for round in 0..24 {
|
||||
let mut v: Vec<f64> = (0..len)
|
||||
.map(|_| match (round % 4, next() % 8) {
|
||||
// Few distinct values: many ties.
|
||||
(0, r) => (r % 3) as f64,
|
||||
// Specials, including both zeros and NaNs.
|
||||
(1, r) => specials[(next() as usize).wrapping_add(r as usize) % specials.len()],
|
||||
// Wide random range.
|
||||
(2, _) => (next() as i64 as f64) / 1e3,
|
||||
// Integers with some specials mixed in.
|
||||
(_, 0) => specials[next() as usize % specials.len()],
|
||||
_ => (next() % 50) as f64,
|
||||
})
|
||||
.collect();
|
||||
// Rounds 12..24 repeat the data as already ascending, already descending and nearly
|
||||
// ascending (ends swapped): the monotone shortcut and its fallback to selection.
|
||||
match round / 4 {
|
||||
3 => v.sort_by(f64::total_cmp),
|
||||
4 => {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.reverse();
|
||||
}
|
||||
5 => {
|
||||
v.sort_by(f64::total_cmp);
|
||||
if let Some(last) = v.len().checked_sub(1) {
|
||||
v.swap(0, last);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
assert!(same(median(v.clone()), old_median(v.clone())), "median len={len} round={round}");
|
||||
let ks = [-1.0, 0.0, 0.2, 1.0, 1.5, 2.0, len as f64 / 2.0, len as f64 - 0.5, len as f64, len as f64 + 1.0, f64::NAN];
|
||||
for &k in &ks {
|
||||
for largest in [true, false] {
|
||||
assert!(same(kth(v.clone(), k, largest), old_kth(v.clone(), k, largest)), "kth len={len} k={k}");
|
||||
}
|
||||
}
|
||||
let ps = [-0.1, 0.0, 0.01, 0.1, 0.25, 0.3, 0.5, 0.75, 0.9, 0.99, 1.0, 1.1, f64::NAN, (next() % 1001) as f64 / 1000.0];
|
||||
for &p in &ps {
|
||||
assert!(same(percentile_inc(v.clone(), p), old_percentile_inc(v.clone(), p)), "pinc len={len} p={p}");
|
||||
assert!(same(percentile_exc(v.clone(), p), old_percentile_exc(v.clone(), p)), "pexc len={len} p={p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentiles() {
|
||||
close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9);
|
||||
|
||||
@@ -47,6 +47,16 @@ impl Formula {
|
||||
pub fn expr_arc(&self) -> Option<Arc<Expr>> {
|
||||
self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new))
|
||||
}
|
||||
/// Runs `f` on the parsed expression, borrowing the cached tree or, after deserialization,
|
||||
/// a freshly parsed one that is dropped afterwards. Unlike [`expr`](Self::expr) it never copies
|
||||
/// the tree, and unlike [`expr_arc`](Self::expr_arc) it needs no reference count or `Arc`
|
||||
/// allocation. `None` when the text does not parse.
|
||||
pub fn with_expr<R>(&self, f: impl FnOnce(&Expr) -> R) -> Option<R> {
|
||||
match &self.expr {
|
||||
Some(e) => Some(f(e)),
|
||||
None => gridcraft_formula::parse(&self.text).ok().map(|e| f(&e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A static picture stored as cell content, independent of floating drawing anchors.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -302,6 +302,7 @@ impl SheetApp {
|
||||
// The requests belong to the window the command left active.
|
||||
self.follow_active_window();
|
||||
self.prune_selected_object();
|
||||
self.prune_views();
|
||||
for req in self.session.take_ui_requests() {
|
||||
match req {
|
||||
UiRequest::Dialog(name, params) => self.open_dialog(&name, params),
|
||||
@@ -327,7 +328,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)) {
|
||||
|
||||
@@ -7,8 +7,8 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
/// The active window and sheet: each window scrolls on its own.
|
||||
fn scroll_key(&self) -> Option<(u64, usize)> {
|
||||
Some((self.session.active_window()?, self.session.active()?.wb.active_sheet()))
|
||||
fn scroll_key(&self) -> Option<(u64, u64)> {
|
||||
Some((self.session.active_window()?, self.session.active()?.wb.active()?.uid.get()))
|
||||
}
|
||||
|
||||
pub fn view(&self) -> SheetView {
|
||||
@@ -41,8 +41,10 @@ impl SheetApp {
|
||||
let window = self.session.active_window();
|
||||
let Some(d) = self.session.active_mut() else { return };
|
||||
let mut tops = Vec::new();
|
||||
for (&(w, si), v) in &self.views {
|
||||
let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else {
|
||||
for (si, sh) in d.wb.sheets.iter().enumerate() {
|
||||
let Some(v) =
|
||||
window.and_then(|w| self.views.get(&(w, sh.uid.get()))).filter(|_| sh.view == gridcraft_engine::model::SheetViewMode::Normal)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
||||
@@ -53,4 +55,10 @@ impl SheetApp {
|
||||
}
|
||||
d.store_view(&tops);
|
||||
}
|
||||
|
||||
/// Drops the scroll positions of windows that have closed: window ids are never reused.
|
||||
pub(super) fn prune_views(&mut self) {
|
||||
let session = &self.session;
|
||||
self.views.retain(|&(w, _), _| session.window(w).is_some());
|
||||
}
|
||||
}
|
||||
@@ -157,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()
|
||||
@@ -266,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",
|
||||
@@ -1119,6 +1131,7 @@ impl Dialog {
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
ref_edit::record_home(app, &mut d);
|
||||
Some(d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -218,8 +218,11 @@ pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
|
||||
if columns.is_empty() {
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id);
|
||||
} else {
|
||||
// The closed box shows the same "B: Revenue" label as the list, not the bare letter (#3446).
|
||||
let shown =
|
||||
columns.iter().find(|(letter, _)| letter.eq_ignore_ascii_case(&col)).map_or_else(|| col.clone(), |(_, label)| label.clone());
|
||||
craft_ui::ComboBox::from_id_salt(("sort_col", i))
|
||||
.selected_text(col.clone())
|
||||
.selected_text(shown)
|
||||
.show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -350,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 });
|
||||
}
|
||||
@@ -370,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 });
|
||||
}
|
||||
@@ -401,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;
|
||||
}
|
||||
@@ -486,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;
|
||||
@@ -498,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);
|
||||
@@ -532,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
|
||||
|
||||
@@ -496,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"),
|
||||
("-", ""),
|
||||
@@ -513,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"),
|
||||
("-", ""),
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
@@ -476,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…" => "Διαγραφή κελιών…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Format\nPane", "書式ウィンドウ"),
|
||||
("Format", "書式"),
|
||||
("Insert", "挿入"),
|
||||
("Header Menu|Insert", "挿入"),
|
||||
("Insert Cells…", "セルの挿入…"),
|
||||
("Delete", "削除"),
|
||||
("Delete Cells…", "セルの削除…"),
|
||||
|
||||
@@ -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…" => "셀 삭제…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Painel de\nFormatação",
|
||||
"Format" => "Formatar",
|
||||
"Insert" => "Inserir",
|
||||
"Header Menu|Insert" => "Inserir",
|
||||
"Insert Cells…" => "Inserir Células…",
|
||||
"Delete" => "Excluir",
|
||||
"Delete Cells…" => "Excluir Células…",
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Panou\nformatare",
|
||||
"Format" => "Format",
|
||||
"Insert" => "Inserare",
|
||||
"Header Menu|Insert" => "Inserare",
|
||||
"Insert Cells…" => "Inserare celule…",
|
||||
"Delete" => "Ștergere",
|
||||
"Delete Cells…" => "Ștergere celule…",
|
||||
|
||||
@@ -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…" => "Удалить ячейки…",
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Okno za\noblikovanje",
|
||||
"Format" => "Oblikovanje",
|
||||
"Insert" => "Umetni",
|
||||
"Header Menu|Insert" => "Umetni",
|
||||
"Insert Cells…" => "Umetni ćelije…",
|
||||
"Delete" => "Obriši",
|
||||
"Delete Cells…" => "Obriši ćelije…",
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Biçim\nBölmesi",
|
||||
"Format" => "Biçim",
|
||||
"Insert" => "Ekle",
|
||||
"Header Menu|Insert" => "Ekle",
|
||||
"Insert Cells…" => "Hücre Ekle…",
|
||||
"Delete" => "Sil",
|
||||
"Delete Cells…" => "Hücreleri Sil…",
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Область\nформатування",
|
||||
"Format" => "Формат",
|
||||
"Insert" => "Вставити",
|
||||
"Header Menu|Insert" => "Вставити",
|
||||
"Insert Cells…" => "Вставити комірки…",
|
||||
"Delete" => "Видалити",
|
||||
"Delete Cells…" => "Видалити комірки…",
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Format\nPane" => "Ngăn\nđịnh dạng",
|
||||
"Format" => "Định dạng",
|
||||
"Insert" => "Chèn",
|
||||
"Header Menu|Insert" => "Chèn",
|
||||
"Insert Cells…" => "Chèn ô…",
|
||||
"Delete" => "Xóa",
|
||||
"Delete Cells…" => "Xóa ô…",
|
||||
|
||||
@@ -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…" => "删除单元格…",
|
||||
|
||||
@@ -222,8 +222,9 @@ pub struct SheetApp {
|
||||
pub ui: UiState,
|
||||
/// The window chrome to draw (see [`Chrome`]).
|
||||
pub chrome: Chrome,
|
||||
/// Scroll position per (window id, sheet index).
|
||||
pub views: HashMap<(u64, usize), SheetView>,
|
||||
/// Scroll position per (window id, sheet uid): each sheet keeps its own when sheets are
|
||||
/// inserted, deleted or moved.
|
||||
pub views: HashMap<(u64, u64), SheetView>,
|
||||
pub editor: Option<editor::EditState>,
|
||||
pub text_box_editor: Option<text_box::EditState>,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
|
||||
@@ -1637,3 +1637,249 @@ fn solver_load_model_after_a_cross_sheet_pick_reads_the_dialogs_sheet() {
|
||||
assert!(field(&h, "variables").contains("B2"), "{:?}", field(&h, "variables"));
|
||||
assert_eq!(selection(&h).0, 0, "back on the dialog's sheet");
|
||||
}
|
||||
|
||||
/// Shows the sheet at `index` from a script, as a sheet tab click or the control channel does.
|
||||
fn show_sheet(h: &mut H, index: usize) {
|
||||
h.state_mut().session.execute("sheet.activate", json!({"sheet": index})).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_shown_before_the_first_pick_is_not_the_dialogs_sheet() {
|
||||
// Goal Seek opened on Sheet1 (B5 selected); Rates is shown before any box picks.
|
||||
let mut h = harness(two_sheets());
|
||||
open_goal_seek(&mut h);
|
||||
show_sheet(&mut h, 1);
|
||||
click_button(&mut h, "Collapse dialog", 1);
|
||||
drag(&mut h, "A2", "A3");
|
||||
assert_eq!(field(&h, "changing"), "Rates!$A$2:$A$3", "Rates is not the dialog's sheet: the pick carries its name");
|
||||
// Back on the dialog's own sheet, a pick needs no sheet name, and Cancel puts back the
|
||||
// selection the dialog was opened with, not the one picked there.
|
||||
show_sheet(&mut h, 0);
|
||||
drag(&mut h, "C3", "C3");
|
||||
assert_eq!(field(&h, "changing"), "$C$3");
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(selection(&h), (0, "B5".into()), "back on the sheet the dialog was opened on, with its selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_and_data_analysis_keep_the_sheet_they_were_opened_on() {
|
||||
for (name, params, button, key_name) in [("solver", json!({}), 0, "objective"), ("analysisToolPak", json!({"tool": "descriptive"}), 0, "range")] {
|
||||
let mut h = harness(two_sheets());
|
||||
h.state_mut().open_dialog(name, params);
|
||||
h.run_steps(4);
|
||||
show_sheet(&mut h, 1);
|
||||
click_button(&mut h, "Collapse dialog", button);
|
||||
drag(&mut h, "B2", "B2");
|
||||
assert_eq!(field(&h, key_name), "Rates!$B$2", "{name}: a pick on a sheet shown after opening carries its name");
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().dialog.is_none(), "{name}");
|
||||
assert_eq!(selection(&h), (0, "B5".into()), "{name}: Cancel goes back to the sheet it was opened on");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_without_a_pick_runs_on_the_sheet_the_dialog_was_opened_on() {
|
||||
// Data Analysis opened on Sheet1, Rates shown, then OK with typed, unqualified references:
|
||||
// they mean Sheet1's cells, and the report lands on Sheet1.
|
||||
let mut h = harness(two_sheets());
|
||||
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
|
||||
h.run_steps(4);
|
||||
show_sheet(&mut h, 1);
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
d.values.insert("range".into(), json!("B1:B6"));
|
||||
d.values.insert("outputTo".into(), json!("range"));
|
||||
d.values.insert("output".into(), json!("$H$1"));
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "{:?}", h.state().message);
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
assert!(!wb.sheets[0].value(CellRef::parse("H1").unwrap()).is_empty(), "the report is on the dialog's own sheet");
|
||||
assert!(wb.sheets[1].value(CellRef::parse("H1").unwrap()).is_empty(), "not on the sheet shown when OK was pressed");
|
||||
assert_eq!(selection(&h).0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_chosen_on_another_sheet_takes_that_sheets_selection_with_its_name() {
|
||||
let mut h = harness(two_sheets());
|
||||
h.state_mut().open_dialog("analysisToolPak", json!({}));
|
||||
h.run_steps(4);
|
||||
show_sheet(&mut h, 1);
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A1:A6"})).unwrap();
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(field(&h, "range"), "Rates!A1:A6", "the input range keeps the sheet it was selected on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opened_sheet_renamed_or_deleted_before_the_first_pick() {
|
||||
// Renamed: still the dialog's sheet, found by identity.
|
||||
let mut h = harness(two_sheets());
|
||||
open_goal_seek(&mut h);
|
||||
h.state_mut().session.execute("sheet.rename", json!({"sheet": 0, "name": "Model"})).unwrap();
|
||||
show_sheet(&mut h, 1);
|
||||
click_button(&mut h, "Collapse dialog", 1);
|
||||
drag(&mut h, "A2", "A2");
|
||||
assert_eq!(field(&h, "changing"), "Rates!$A$2");
|
||||
show_sheet(&mut h, 0);
|
||||
drag(&mut h, "C3", "C3");
|
||||
assert_eq!(field(&h, "changing"), "$C$3", "the renamed sheet is the dialog's own");
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert_eq!(selection(&h), (0, "B5".into()));
|
||||
|
||||
// Deleted: the sheet shown afterwards does not become the dialog's, so picks carry its name
|
||||
// and OK is refused rather than reading the dialog's references against it.
|
||||
let mut h = harness(two_sheets());
|
||||
open_goal_seek(&mut h);
|
||||
h.state_mut().session.execute("home.deleteSheet", json!({"sheet": 0})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(sheet_names(&h), ["Rates"]);
|
||||
click_button(&mut h, "Collapse dialog", 1);
|
||||
drag(&mut h, "A2", "A2");
|
||||
assert_eq!(field(&h, "changing"), "Rates!$A$2");
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some(), "the dialog stays open");
|
||||
let message = h.state().message.as_ref().map(|m| m.1.clone()).unwrap_or_default();
|
||||
assert!(message.contains("was deleted"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_opened_by_the_control_channel_keeps_the_sheet_it_was_opened_on() {
|
||||
let mut h = harness(two_sheets());
|
||||
let r = control(&mut h, "ui.dialog", json!({"name": "goalSeek"}));
|
||||
assert_eq!(r["result"]["dialog"], json!("goalSeek"), "{r}");
|
||||
let r = control(&mut h, "engine.execute", json!({"command": "sheet.activate", "params": {"sheet": 1}}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
click_button(&mut h, "Collapse dialog", 1);
|
||||
drag(&mut h, "A2", "A2");
|
||||
assert_eq!(field(&h, "changing"), "Rates!$A$2");
|
||||
let r = control(&mut h, "ui.dialog.cancel", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(selection(&h), (0, "B5".into()), "a scripted cancel goes back to the sheet the dialog was opened on");
|
||||
}
|
||||
|
||||
/// A single click on `cell`, however soon after the last one: the harness clicks faster than a
|
||||
/// double-click, which would start editing the cell instead.
|
||||
fn single_click(h: &mut H, cell: &str) {
|
||||
h.state_mut().grid.last_click = None;
|
||||
let pos = cell_pos(h, cell);
|
||||
click_at(h, pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_cell_already_selected_picks_it() {
|
||||
let mut h = harness(workbook());
|
||||
open_goal_seek(&mut h);
|
||||
// Not picking: a click on the selected cell changes no box.
|
||||
single_click(&mut h, "B5");
|
||||
assert_eq!((field(&h, "set"), field(&h, "changing")), ("B5".into(), String::new()));
|
||||
// Clicking into a box keeps its text: the earlier click is not taken for a pick.
|
||||
let boxes = dialog_boxes(&h);
|
||||
click_at(&mut h, boxes[2].center());
|
||||
assert_eq!(field(&h, "changing"), "", "focusing the box alone writes nothing");
|
||||
// Picking: B5 is still the selection, and a click on it writes it.
|
||||
assert_eq!(selection(&h), (0, "B5".into()));
|
||||
single_click(&mut h, "B5");
|
||||
assert_eq!(field(&h, "changing"), "$B$5", "a click on the cell already selected is a pick");
|
||||
// A drag still picks a range, and after typing over it, a click on the cell already selected
|
||||
// writes that cell again.
|
||||
drag(&mut h, "C3", "D4");
|
||||
assert_eq!(field(&h, "changing"), "$C$3:$D$4");
|
||||
single_click(&mut h, "C3");
|
||||
assert_eq!(field(&h, "changing"), "$C$3");
|
||||
key(&mut h, Key::A, Modifiers::COMMAND);
|
||||
type_text(&mut h, "X1");
|
||||
assert_eq!(field(&h, "changing"), "X1");
|
||||
single_click(&mut h, "C3");
|
||||
assert_eq!(field(&h, "changing"), "$C$3", "the click replaces the typed text although the selection is unchanged");
|
||||
assert_eq!(field(&h, "set"), "B5", "only the box being picked into changes");
|
||||
assert!(h.state().editor.is_none(), "no cell is being edited");
|
||||
}
|
||||
|
||||
/// A click on `cell` at a fraction of its width (a link's text sits in its first 80%), with
|
||||
/// `modifiers` held.
|
||||
fn click_cell_with(h: &mut H, cell: &str, modifiers: Modifiers) {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let r = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(cell).unwrap());
|
||||
let pos = egui::pos2(r.left() + r.width() * 0.3, r.center().y);
|
||||
h.state_mut().grid.last_click = None;
|
||||
h.input_mut().events.extend([
|
||||
Event::ModifiersChanged(modifiers),
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers },
|
||||
]);
|
||||
h.run_steps(3);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_picking_a_click_on_a_link_or_checkbox_picks_its_cell_only() {
|
||||
let mut s = workbook();
|
||||
for (cell, target) in [("C3", "Sheet1!B5"), ("C4", "Sheet1!F9"), ("C5", "https://example.org/a")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": "link"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap();
|
||||
}
|
||||
s.execute("insert.checkbox", json!({"range": "C6"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
open_goal_seek(&mut h);
|
||||
let boxes = dialog_boxes(&h);
|
||||
click_at(&mut h, boxes[2].center());
|
||||
// Ctrl+click on a link back to the cell already selected: the cell clicked is the pick.
|
||||
click_cell_with(&mut h, "C3", Modifiers::COMMAND);
|
||||
assert_eq!(field(&h, "changing"), "$C$3", "the link is not followed back to B5");
|
||||
// A link to another cell on the same sheet, with and without Ctrl: no Go To, the cell picks.
|
||||
click_cell_with(&mut h, "C4", Modifiers::COMMAND);
|
||||
assert_eq!(field(&h, "changing"), "$C$4");
|
||||
click_cell_with(&mut h, "C4", Modifiers::NONE);
|
||||
assert_eq!(field(&h, "changing"), "$C$4");
|
||||
assert_eq!(selection(&h), (0, "C4".into()), "the link to F9 was not followed");
|
||||
// A web link asks nothing, so the dialog being filled stays.
|
||||
click_cell_with(&mut h, "C5", Modifiers::NONE);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("goalSeek"));
|
||||
assert_eq!(field(&h, "changing"), "$C$5");
|
||||
// A checkbox cell is picked, not toggled.
|
||||
let checked = |h: &H| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("C6").unwrap());
|
||||
let before = checked(&h);
|
||||
click_cell_with(&mut h, "C6", Modifiers::NONE);
|
||||
assert_eq!(field(&h, "changing"), "$C$6");
|
||||
assert_eq!(checked(&h), before, "the checkbox was not toggled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_quick_click_on_a_cell_picks_it_instead_of_editing() {
|
||||
let mut h = harness(workbook());
|
||||
open_goal_seek(&mut h);
|
||||
let boxes = dialog_boxes(&h);
|
||||
click_at(&mut h, boxes[2].center());
|
||||
// The last click on the grid was on B5 a moment ago (before the box was clicked into), so
|
||||
// this click would be a double-click.
|
||||
let now = gridcraft_engine::core::date::unix_now_ms().unwrap();
|
||||
h.state_mut().grid.last_click = Some((CellRef::parse("B5").unwrap(), now));
|
||||
let pos = cell_pos(&h, "B5");
|
||||
click_at(&mut h, pos);
|
||||
assert!(h.state().editor.is_none(), "the click does not start editing the cell");
|
||||
assert_eq!(field(&h, "changing"), "$B$5", "the click picks the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_selected_cell_picks_into_a_collapsed_box_on_another_sheet() {
|
||||
let mut h = harness(two_sheets());
|
||||
open_goal_seek(&mut h);
|
||||
click_button(&mut h, "Collapse dialog", 1);
|
||||
next_sheet(&mut h);
|
||||
assert_eq!(selection(&h), (1, "A1".into()));
|
||||
assert_eq!(field(&h, "changing"), "", "showing another sheet is not a pick");
|
||||
single_click(&mut h, "A1");
|
||||
assert_eq!(field(&h, "changing"), "Rates!$A$1", "a click on that sheet's selected cell is");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! The saved scroll position (`sheetView topLeftCell`) is restored on open and saved back (#378).
|
||||
//! Each window keeps each sheet's own scroll position while sheets are inserted, deleted or
|
||||
//! moved (#3485).
|
||||
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::Workbook;
|
||||
@@ -58,3 +60,84 @@ fn saved_scroll_position_is_restored_and_saved_back() {
|
||||
assert!(!d.is_dirty(), "scrolling and saving view state does not dirty the workbook");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pivottable_sheet_opens_at_the_top_and_its_source_sheet_keeps_its_place() {
|
||||
// A new PivotTable sheet goes in front of its source sheet. Scroll positions were kept by
|
||||
// sheet index, so the new sheet took over the source sheet's position and opened at row 51.
|
||||
let mut h = harness(None);
|
||||
h.state_mut().run("range.setValues", json!({"range": "C1:D3", "values": [["Region", "Sales"], ["North", 10], ["South", 20]]})).unwrap();
|
||||
{
|
||||
let app = h.state_mut();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
|
||||
app.view_mut().unwrap().scroll.y = sh.row_top(50) as f32;
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(first_scrolled_row(&h), 50, "setup: Sheet1 is scrolled to row 51");
|
||||
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$C$1:$D$3", "rows": ["Region"]})).unwrap();
|
||||
h.run_steps(4);
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
assert!(!wb.active().unwrap().pivots.is_empty(), "setup: the PivotTable's new sheet is active");
|
||||
assert_eq!(first_scrolled_row(&h), 0, "the new sheet opens at row 1");
|
||||
h.state_mut().run("sheet.next", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().name, "Sheet1");
|
||||
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 is still scrolled to row 51");
|
||||
}
|
||||
|
||||
/// Scrolls the active sheet so `row` (0-based) is the first one in view.
|
||||
fn scroll_to_row(h: &mut Harness, row: u32) {
|
||||
let app = h.state_mut();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
|
||||
app.view_mut().unwrap().scroll.y = sh.row_top(row) as f32;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn activate(h: &mut Harness, sheet: &str) {
|
||||
h.state_mut().run("sheet.activate", json!({"sheet": sheet})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().name, sheet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_scroll_position_when_sheets_are_inserted_deleted_or_moved() {
|
||||
let mut h = harness(None);
|
||||
scroll_to_row(&mut h, 50);
|
||||
h.state_mut().run("home.insertSheet", json!({"name": "Two", "before": 0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(first_scrolled_row(&h), 0, "the inserted sheet opens at row 1");
|
||||
scroll_to_row(&mut h, 10);
|
||||
activate(&mut h, "Sheet1");
|
||||
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 moved one place right and is still at row 51");
|
||||
h.state_mut().run("home.deleteSheet", json!({"sheet": "Two"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 moved back and is still at row 51");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
activate(&mut h, "Two");
|
||||
assert_eq!(first_scrolled_row(&h), 10, "the sheet undo brings back is where it was left");
|
||||
h.state_mut().run("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
|
||||
h.run_steps(4);
|
||||
activate(&mut h, "Two");
|
||||
assert_eq!(first_scrolled_row(&h), 10, "a moved sheet keeps its place");
|
||||
activate(&mut h, "Sheet1");
|
||||
assert_eq!(first_scrolled_row(&h), 50, "and so does the sheet it moved past");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_window_scrolls_on_its_own_and_a_closed_windows_positions_are_dropped() {
|
||||
let mut h = harness(None);
|
||||
scroll_to_row(&mut h, 50);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
assert_ne!(first, second);
|
||||
scroll_to_row(&mut h, 10);
|
||||
assert!(h.state().views.keys().any(|(w, _)| *w == second), "setup: the second window has its own position");
|
||||
h.state_mut().run("window.close", json!({"window": second})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active_window(), Some(first));
|
||||
assert_eq!(first_scrolled_row(&h), 50, "the first window kept its own position");
|
||||
assert!(h.state().views.keys().all(|(w, _)| *w == first), "nothing is kept for the closed window: {:?}", h.state().views.keys());
|
||||
}
|
||||
@@ -34,6 +34,18 @@ fn sort_dialog_seeds_the_active_column() {
|
||||
assert_eq!(col(&h), "B", "Sort by starts on the column the cursor is in, not A");
|
||||
}
|
||||
|
||||
/// #3446: the closed "Sort by" box shows the column's name next to its letter,
|
||||
/// as the open list does ("B: Revenue", not just "B").
|
||||
#[test]
|
||||
fn closed_column_box_shows_the_column_name() {
|
||||
use egui::accesskit::Role;
|
||||
let mut h = harness(sales());
|
||||
h.state_mut().open_dialog("sort", json!({}));
|
||||
h.run_steps(2);
|
||||
let shown: Vec<String> = h.get_all_by_role(Role::ComboBox).filter_map(|n| n.value()).collect();
|
||||
assert!(shown.iter().any(|v| v == "B: Revenue"), "the Sort by box shows the column name: {shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_level_picks_a_column_no_level_uses_yet() {
|
||||
let mut h = harness(sales());
|
||||
|
||||
@@ -35,6 +35,9 @@ pub struct Ctx<'a> {
|
||||
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
|
||||
/// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`.
|
||||
pub cf_ext_ids: Vec<(String, usize)>,
|
||||
/// Bytes charged so far for shared formulas expanded into dependent cells (see
|
||||
/// [`crate::sheet_read::MAX_SHARED_FORMULA_BYTES`]).
|
||||
pub shared_formula_bytes: u64,
|
||||
/// Whether GridCraft calculates a function, when the caller says: cell formulas wrapped by
|
||||
/// Google Sheets' export are then read as the formula inside (see [`crate::read_xlsx_with`]).
|
||||
pub known_function: Option<&'a dyn Fn(&str) -> bool>,
|
||||
@@ -255,6 +258,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
|
||||
num_fmts: Default::default(),
|
||||
cell_pictures: vec![],
|
||||
cf_ext_ids: vec![],
|
||||
shared_formula_bytes: 0,
|
||||
known_function,
|
||||
python: Default::default(),
|
||||
};
|
||||
|
||||
@@ -31,6 +31,16 @@ struct Shared {
|
||||
anchor: CellRef,
|
||||
expr: Option<Expr>,
|
||||
text: String,
|
||||
/// Approximate bytes one dependent cell costs (its printed text plus its expression tree);
|
||||
/// what exceeds [`SHARED_FORMULA_FREE_COST`] is charged against [`MAX_SHARED_FORMULA_BYTES`].
|
||||
cost: u64,
|
||||
}
|
||||
|
||||
/// What one sheet's hyperlinks have copied so far, across all its `<hyperlinks>` blocks.
|
||||
#[derive(Default)]
|
||||
struct LinkBudget {
|
||||
cells: u64,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -120,10 +130,23 @@ fn settle_python_spills_past_budget(st: &RowState, sheet: &mut Sheet) {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HYPERLINK_CELLS: u64 = 10_000;
|
||||
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
|
||||
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
|
||||
const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
/// Most cells one sheet's hyperlinks may cover (each cell gets its own copy of the link).
|
||||
const MAX_HYPERLINK_CELLS: u64 = 10_000;
|
||||
/// Longest hyperlink target kept, in bytes (Excel's own limit is about 2,079 characters).
|
||||
const MAX_HYPERLINK_TARGET: usize = 8_192;
|
||||
/// Longest hyperlink ScreenTip kept, in characters (Excel's limit).
|
||||
const MAX_TOOLTIP_CHARS: usize = 255;
|
||||
/// What a dependent of a shared formula may cost for free: an ordinary formula cell's worth (by
|
||||
/// [`shared_cost`], a formula of about 40 operators and operands), so a sheet of many ordinary
|
||||
/// shared formulas is never cut short however many cells share them.
|
||||
const SHARED_FORMULA_FREE_COST: u64 = 4096;
|
||||
/// Budget, per workbook, for what shared formulas cost beyond [`SHARED_FORMULA_FREE_COST`] per
|
||||
/// dependent cell: a long master copied into many cells is where the expansion multiplies the
|
||||
/// file's bytes, and this bounds it to well within a web build's memory.
|
||||
pub const MAX_SHARED_FORMULA_BYTES: u64 = 256 << 20;
|
||||
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
|
||||
/// Most dynamic-array and legacy-array ranges of one sheet.
|
||||
const MAX_DYNAMIC_RANGES: usize = 65_536;
|
||||
@@ -182,6 +205,8 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
|
||||
drop(bytes);
|
||||
|
||||
let mut fit_to_page = false;
|
||||
let mut link_budget = LinkBudget::default();
|
||||
let mut cf_kept_cells = 0u64;
|
||||
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
|
||||
cx.cf_ext_ids.clear();
|
||||
for e in &root.children {
|
||||
@@ -208,7 +233,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
|
||||
}
|
||||
}
|
||||
}
|
||||
"conditionalFormatting" => read_cf(cx, e, &mut sheet),
|
||||
"conditionalFormatting" => read_cf(cx, e, &mut sheet, &mut cf_kept_cells),
|
||||
"dataValidations" => {
|
||||
for v in e.kids("dataValidation") {
|
||||
if let Some(v) = read_validation(v) {
|
||||
@@ -216,7 +241,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
|
||||
}
|
||||
}
|
||||
}
|
||||
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet),
|
||||
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet, &mut link_budget),
|
||||
"autoFilter" => sheet.autofilter = read_autofilter(cx, e),
|
||||
"printOptions" => {
|
||||
sheet.print.gridlines = e.flag("gridLines", false);
|
||||
@@ -641,13 +666,30 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
|
||||
let si = f.attr_u32("si")?;
|
||||
if !text.is_empty() {
|
||||
let expr = gridcraft_formula::parse(text).ok();
|
||||
st.shared.insert(si, Shared { anchor: pos, expr, text: text.to_string() });
|
||||
let cost = shared_cost(text, expr.as_ref());
|
||||
st.shared.insert(si, Shared { anchor: pos, expr, text: text.to_string(), cost });
|
||||
Some(Formula::new(text))
|
||||
} else {
|
||||
let Some(sh) = st.shared.get(&si) else {
|
||||
cx.warn("a shared formula refers to a missing master cell; kept the cached value");
|
||||
return None;
|
||||
};
|
||||
// The parser's own test: bytes first (cheap), then characters, so a multibyte
|
||||
// master that failed to parse for another reason gets no false length warning.
|
||||
if sh.expr.is_none()
|
||||
&& sh.text.len() > gridcraft_formula::MAX_FORMULA_LEN
|
||||
&& sh.text.chars().count() > gridcraft_formula::MAX_FORMULA_LEN
|
||||
{
|
||||
cx.warn(format!(
|
||||
"a shared formula is longer than {} characters; kept the cached values of the cells that share it",
|
||||
gridcraft_formula::MAX_FORMULA_LEN
|
||||
));
|
||||
return None;
|
||||
}
|
||||
if !charge(&mut cx.shared_formula_bytes, extra_shared_cost(sh.cost), MAX_SHARED_FORMULA_BYTES) {
|
||||
cx.warn("shared formulas expand to too much data; kept the cached values of the remaining cells that share them");
|
||||
return None;
|
||||
}
|
||||
Some(match &sh.expr {
|
||||
Some(e) => {
|
||||
let dr = pos.row as i64 - sh.anchor.row as i64;
|
||||
@@ -720,6 +762,41 @@ pub fn parse_iso_datetime(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
Some(serial)
|
||||
}
|
||||
|
||||
/// What one dependent of a shared formula costs: its printed text plus its expression tree.
|
||||
fn shared_cost(text: &str, expr: Option<&Expr>) -> u64 {
|
||||
let mut nodes = 0u64;
|
||||
if let Some(e) = expr {
|
||||
e.walk(&mut |_| nodes = nodes.saturating_add(1));
|
||||
}
|
||||
let node = std::mem::size_of::<Expr>() as u64;
|
||||
(text.len() as u64).saturating_mul(2).saturating_add(nodes.saturating_mul(node)).saturating_add(64)
|
||||
}
|
||||
|
||||
/// What one dependent of a shared formula costing `cost` is charged: its cost beyond an ordinary
|
||||
/// formula cell's.
|
||||
fn extra_shared_cost(cost: u64) -> u64 {
|
||||
cost.saturating_sub(SHARED_FORMULA_FREE_COST)
|
||||
}
|
||||
|
||||
/// Adds `cost` to `used` when that stays within `cap`; `false` (and `used` unchanged) otherwise.
|
||||
fn charge(used: &mut u64, cost: u64, cap: u64) -> bool {
|
||||
match used.checked_add(cost) {
|
||||
Some(total) if total <= cap => {
|
||||
*used = total;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `max` characters of `s`.
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
match s.char_indices().nth(max) {
|
||||
Some((at, _)) => s.get(..at).unwrap_or(s).to_string(),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_sqref(s: Option<&str>) -> Vec<RangeRef> {
|
||||
s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect()
|
||||
}
|
||||
@@ -780,6 +857,40 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_formula_budget_stops_expansion() {
|
||||
let expr = gridcraft_formula::parse("SUM(1,2,3)").ok();
|
||||
let cost = shared_cost("SUM(1,2,3)", expr.as_ref());
|
||||
assert!(cost > 64 + 4 * std::mem::size_of::<Expr>() as u64);
|
||||
let cap = cost * 3;
|
||||
let mut used = 0;
|
||||
assert!((0..3).all(|_| charge(&mut used, cost, cap)));
|
||||
assert!(!charge(&mut used, cost, cap));
|
||||
assert_eq!(used, cap);
|
||||
assert!(!charge(&mut used, u64::MAX, u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_shared_formulas_are_free_and_long_ones_pay_their_excess() {
|
||||
let text = r#"IF(AND(A1>0,B1<>""),VLOOKUP(C1,Sheet2!$A$1:$D$500,4,FALSE)*SUM(D1:D10)/COUNT(D1:D10),IFERROR(E1/F1,0))"#;
|
||||
let expr = gridcraft_formula::parse(text).ok();
|
||||
assert!(expr.is_some());
|
||||
assert_eq!(extra_shared_cost(shared_cost(text, expr.as_ref())), 0, "an ordinary formula costs nothing extra");
|
||||
let long = vec![format!("SUM({}1)", "1,".repeat(199)); 10].join("+");
|
||||
let expr = gridcraft_formula::parse(&long).ok();
|
||||
assert!(expr.is_some());
|
||||
let cost = shared_cost(&long, expr.as_ref());
|
||||
assert_eq!(extra_shared_cost(cost), cost - SHARED_FORMULA_FREE_COST);
|
||||
// A few thousand copies of a long master no longer fit the budget.
|
||||
assert!(extra_shared_cost(cost).saturating_mul(10_000) > MAX_SHARED_FORMULA_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_keeps_whole_characters() {
|
||||
assert_eq!(truncate_chars("ab\u{e9}d", 3), "ab\u{e9}");
|
||||
assert_eq!(truncate_chars("ab", 3), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(parse_iso_datetime("1900-01-01", DateSystem::D1900), Some(1.0));
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
use super::{
|
||||
AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS, MAX_ROWS,
|
||||
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
|
||||
AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, LinkBudget, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS,
|
||||
MAX_HYPERLINK_TARGET, MAX_ROWS, MAX_TOOLTIP_CHARS, NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr,
|
||||
from_file, truncate_chars, xml,
|
||||
};
|
||||
|
||||
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
|
||||
let mut cells = 0u64;
|
||||
let mut bytes = 0u64;
|
||||
/// `budget` counts the cells and bytes this sheet's hyperlinks have copied so far, across
|
||||
/// `<hyperlinks>` blocks.
|
||||
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet, budget: &mut LinkBudget) {
|
||||
for h in e.kids("hyperlink") {
|
||||
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
|
||||
let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
|
||||
let rel_target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.as_str()).unwrap_or("");
|
||||
let location = h.attr("location").filter(|l| !l.is_empty());
|
||||
// Each covered cell gets its own copy of the link: refuse oversized targets before copying.
|
||||
if rel_target.len().saturating_add(location.map_or(0, str::len)) > MAX_HYPERLINK_TARGET {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlinks longer than {MAX_HYPERLINK_TARGET} characters were dropped"));
|
||||
continue;
|
||||
}
|
||||
let mut target = rel_target.to_string();
|
||||
// A relationship is a file or web page; a bare `location` is a place in this workbook.
|
||||
let external = !target.is_empty();
|
||||
if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) {
|
||||
if let Some(loc) = location {
|
||||
if target.is_empty() {
|
||||
target = loc.trim_start_matches('#').to_string();
|
||||
} else {
|
||||
@@ -21,19 +29,20 @@ pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel]
|
||||
if target.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
|
||||
let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
|
||||
let tooltip = h.attr("tooltip").map(|t| truncate_chars(t, MAX_TOOLTIP_CHARS));
|
||||
let link_bytes = (target.len() + tooltip.as_ref().map_or(0, String::len)) as u64;
|
||||
let link = Hyperlink { target, tooltip, external };
|
||||
for c in range.iter() {
|
||||
if cells >= MAX_HYPERLINK_CELLS {
|
||||
if budget.cells >= MAX_HYPERLINK_CELLS {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
|
||||
return;
|
||||
}
|
||||
if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
|
||||
if budget.bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
|
||||
return;
|
||||
}
|
||||
cells += 1;
|
||||
bytes += link_bytes;
|
||||
budget.cells += 1;
|
||||
budget.bytes += link_bytes;
|
||||
sheet.hyperlinks.insert(c, link.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,15 @@ fn cfvos(e: &El) -> Vec<CfValueKind> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
||||
/// One `conditionalFormatting` block. Every rule keeps its own copy of the block's ranges, so
|
||||
/// `kept_cells` counts the range cells the sheet's retained rules hold so far, across blocks; a
|
||||
/// rule that would take it past [`MAX_COND_CELLS`] is dropped. Unsupported rules cost nothing.
|
||||
pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet, kept_cells: &mut u64) {
|
||||
let ranges = parse_sqref(e.attr("sqref"));
|
||||
if ranges.is_empty() {
|
||||
return;
|
||||
}
|
||||
let block_cells = ranges.iter().map(RangeRef::count).fold(0u64, u64::saturating_add);
|
||||
for r in e.kids("cfRule") {
|
||||
let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default());
|
||||
let formulas: Vec<String> = r.kids("formula").map(|f| from_file(f.text.trim())).collect();
|
||||
@@ -111,15 +115,15 @@ pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(id) = ext_id {
|
||||
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
|
||||
}
|
||||
let rules = sheet.cond_formats.len();
|
||||
let area = ranges.iter().map(RangeRef::count).sum::<u64>().saturating_mul(1 + rules as u64);
|
||||
if area > MAX_COND_CELLS {
|
||||
let total = kept_cells.saturating_add(block_cells);
|
||||
if total > MAX_COND_CELLS {
|
||||
cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped"));
|
||||
break;
|
||||
}
|
||||
*kept_cells = total;
|
||||
if let Some(id) = ext_id {
|
||||
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
|
||||
}
|
||||
sheet.cond_formats.push(CondFormat {
|
||||
ranges: ranges.clone(),
|
||||
rule,
|
||||
|
||||
+38
-11
@@ -1,7 +1,7 @@
|
||||
//! `styles.xml`: reading cell formats and differential formats into model styles, and writing
|
||||
//! the workbook's style table.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::style::{DxfStated, Protection};
|
||||
@@ -14,6 +14,9 @@ use crate::xml::{El, esc_attr, num};
|
||||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
/// Longest font name kept, in characters (Excel's own limit is 31).
|
||||
pub const MAX_FONT_NAME_CHARS: usize = 255;
|
||||
|
||||
/// Styles read from a file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StylesIn {
|
||||
@@ -63,7 +66,11 @@ const MAX_FONT_FAMILY: u8 = 14;
|
||||
pub fn read_font(e: &El, palette: &[u32]) -> Font {
|
||||
let mut f = Font::default();
|
||||
if let Some(n) = e.child("name").or_else(|| e.child("rFont")).and_then(|n| n.attr("val")) {
|
||||
f.name = n.to_string();
|
||||
// Every cell format that uses the font copies its name: keep it short.
|
||||
f.name = match n.char_indices().nth(MAX_FONT_NAME_CHARS) {
|
||||
Some((at, _)) => n.get(..at).unwrap_or_default().to_string(),
|
||||
None => n.to_string(),
|
||||
};
|
||||
}
|
||||
if let Some(sz) = e.child("sz").and_then(|s| s.attr_f64("val")) {
|
||||
f.size = sz.clamp(1.0, 409.0) as f32;
|
||||
@@ -277,14 +284,16 @@ fn read_protection(p: &El) -> Protection {
|
||||
Protection { locked: p.flag("locked", true), hidden: p.flag("hidden", false) }
|
||||
}
|
||||
|
||||
fn num_fmt_code(id: u32, custom: &HashMap<u32, String>) -> NumFmt {
|
||||
/// A number format by id. Custom formats are built once and shared (`NumFmt` is an `Arc`), so
|
||||
/// many cell formats naming one long code don't each copy it.
|
||||
fn num_fmt_code(id: u32, custom: &HashMap<u32, NumFmt>) -> NumFmt {
|
||||
if let Some(c) = custom.get(&id) {
|
||||
return NumFmt::new(c);
|
||||
return c.clone();
|
||||
}
|
||||
NumFmt::new(builtin_format(id).unwrap_or("General"))
|
||||
}
|
||||
|
||||
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, String>, checkboxes: &[bool]) -> Style {
|
||||
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, NumFmt>, checkboxes: &[bool]) -> Style {
|
||||
let mut s = Style::default();
|
||||
if let Some(f) = xf.attr_u32("fontId").and_then(|i| fonts.get(i as usize)) {
|
||||
s.font = f.clone();
|
||||
@@ -336,6 +345,20 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
|
||||
}
|
||||
}
|
||||
}
|
||||
// One shared `NumFmt` per custom id a cell format or differential format refers to (`NumFmt`
|
||||
// is an `Arc`); declared but unused codes get no second copy.
|
||||
let mut used: HashSet<u32> = HashSet::new();
|
||||
for list in ["cellXfs", "cellStyleXfs"] {
|
||||
if let Some(x) = root.child(list) {
|
||||
used.extend(x.kids("xf").map(|xf| xf.attr_u32("numFmtId").unwrap_or(0)));
|
||||
}
|
||||
}
|
||||
if let Some(d) = root.child("dxfs") {
|
||||
used.extend(
|
||||
d.kids("dxf").filter_map(|x| x.child("numFmt")).filter(|n| n.attr("formatCode").is_none()).filter_map(|n| n.attr_u32("numFmtId")),
|
||||
);
|
||||
}
|
||||
let shared_fmts: HashMap<u32, NumFmt> = fmts.iter().filter(|(id, _)| used.contains(id)).map(|(&id, code)| (id, NumFmt::new(code))).collect();
|
||||
// The scheme link only means something on a font in the body face; elsewhere it stays as a new font has it.
|
||||
let body_font = |e: &El| {
|
||||
let mut f = read_font(e, &palette);
|
||||
@@ -345,10 +368,14 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
|
||||
let fonts: Vec<Font> = root.child("fonts").map(|f| f.kids("font").map(body_font).collect()).unwrap_or_default();
|
||||
let fills: Vec<Fill> = root.child("fills").map(|f| f.kids("fill").map(|e| read_fill(e, &palette, false)).collect()).unwrap_or_default();
|
||||
let borders: Vec<Borders> = root.child("borders").map(|f| f.kids("border").map(|e| read_border(e, &palette)).collect()).unwrap_or_default();
|
||||
let xfs =
|
||||
root.child("cellXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, checkboxes)).collect()).unwrap_or_default();
|
||||
let style_xfs: Vec<Style> =
|
||||
root.child("cellStyleXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, &[])).collect()).unwrap_or_default();
|
||||
let xfs = root
|
||||
.child("cellXfs")
|
||||
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, checkboxes)).collect())
|
||||
.unwrap_or_default();
|
||||
let style_xfs: Vec<Style> = root
|
||||
.child("cellStyleXfs")
|
||||
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, &[])).collect())
|
||||
.unwrap_or_default();
|
||||
let mut named = Vec::new();
|
||||
if let Some(cs) = root.child("cellStyles") {
|
||||
for c in cs.kids("cellStyle") {
|
||||
@@ -359,12 +386,12 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
|
||||
}
|
||||
}
|
||||
}
|
||||
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default();
|
||||
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &shared_fmts)).collect()).unwrap_or_default();
|
||||
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
|
||||
}
|
||||
|
||||
/// One differential format (`dxf`): the style it sets over a cell's own.
|
||||
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, String>) -> Style {
|
||||
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, NumFmt>) -> Style {
|
||||
let mut s = Style::default();
|
||||
if let Some(e) = x.child("font") {
|
||||
let f = read_font(e, palette);
|
||||
|
||||
@@ -280,6 +280,160 @@ fn hostile_formula_text_does_not_overflow_the_stack() {
|
||||
assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
|
||||
}
|
||||
|
||||
/// #2173: a hyperlink is copied into every cell it covers, so oversized targets are dropped,
|
||||
/// ScreenTips are cut to 255 characters and the cell cap holds across `<hyperlinks>` blocks.
|
||||
#[test]
|
||||
fn hyperlink_copies_are_bounded() {
|
||||
let long = "A".repeat(20_000);
|
||||
let tip = "t".repeat(20_000);
|
||||
let body = format!(
|
||||
r#"<sheetData/><hyperlinks><hyperlink ref="A1:A10000" location="{long}" tooltip="{tip}"/><hyperlink ref="B1:B3" location="Data!C1" tooltip="{tip}"/></hyperlinks>"#
|
||||
);
|
||||
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
|
||||
let links = &wb.sheets[0].hyperlinks;
|
||||
assert_eq!(links.len(), 3);
|
||||
assert!(links.values().all(|l| l.target == "Data!C1" && l.tooltip.as_ref().map(|t| t.chars().count()) == Some(255)));
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("longer than")), "{:?}", rep.warnings);
|
||||
|
||||
let blocks: String =
|
||||
["A", "B", "C"].iter().map(|c| format!(r#"<hyperlinks><hyperlink ref="{c}1:{c}8000" location="Data!A1"/></hyperlinks>"#)).collect();
|
||||
let (wb, _) = read_xlsx(&minimal(&format!("<sheetData/>{blocks}"), &[], "", "")).unwrap();
|
||||
assert_eq!(wb.sheets[0].hyperlinks.len(), 10_000);
|
||||
}
|
||||
|
||||
/// #2173: every rule of a conditional-format block copies the block's ranges; the kept range
|
||||
/// cells (ranges x rules) are capped per sheet.
|
||||
#[test]
|
||||
fn conditional_format_range_copies_are_bounded() {
|
||||
let sq = (1..=10_000).map(|r| format!("A{r}")).collect::<Vec<_>>().join(" ");
|
||||
let rules = r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(500);
|
||||
let body = format!(r#"<sheetData/><conditionalFormatting sqref="{sq}">{rules}</conditionalFormatting>"#);
|
||||
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
|
||||
let cfs = &wb.sheets[0].cond_formats;
|
||||
// 400 rules of 10,000 one-cell ranges reach the 4,000,000 kept-cell cap.
|
||||
assert_eq!(cfs.len(), 400);
|
||||
assert!(cfs.iter().all(|c| c.ranges.len() == 10_000));
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
/// #2173: the kept-range-cell cap counts every block of the sheet. A large block that fills the
|
||||
/// cap leaves no room for a later one-cell block.
|
||||
#[test]
|
||||
fn conditional_format_cap_holds_across_blocks() {
|
||||
let block = |sq: &str, n: usize| {
|
||||
format!(r#"<conditionalFormatting sqref="{sq}">{}</conditionalFormatting>"#, r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(n))
|
||||
};
|
||||
let body = format!("<sheetData/>{}{}", block("A1:D1000000", 1), block("E1", 1));
|
||||
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
|
||||
let cfs = &wb.sheets[0].cond_formats;
|
||||
assert_eq!(cfs.len(), 1, "4,000,000 cells fill the cap; the one-cell block would exceed it");
|
||||
assert_eq!(cfs[0].ranges, vec![gridcraft_core::RangeRef::parse("A1:D1000000").unwrap()]);
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
/// #2173: earlier blocks are charged their own cells, not the later block's size times the rule
|
||||
/// count, so a large block after a few small rules still fits under the cap.
|
||||
#[test]
|
||||
fn conditional_format_cap_admits_a_large_block_after_small_ones() {
|
||||
let block = |sq: &str, n: usize| {
|
||||
format!(r#"<conditionalFormatting sqref="{sq}">{}</conditionalFormatting>"#, r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(n))
|
||||
};
|
||||
let body = format!("<sheetData/>{}{}", block("E1", 3), block("A1:C1000000", 1));
|
||||
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
|
||||
assert_eq!(wb.sheets[0].cond_formats.len(), 4, "3 + 3,000,000 kept cells are under the cap");
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
/// #2173: cell formats share one copy of each custom number format (even an over-long one, which
|
||||
/// is kept as stored) and font names are cut to 255 characters.
|
||||
#[test]
|
||||
fn style_copies_are_bounded() {
|
||||
let code = "0".repeat(50_000);
|
||||
let font = "F".repeat(100_000);
|
||||
let xfs = r#"<xf numFmtId="164" fontId="0"/>"#.repeat(10_000);
|
||||
let styles = format!(
|
||||
r#"<styleSheet><numFmts><numFmt numFmtId="164" formatCode="{code}"/><numFmt numFmtId="165" formatCode="0.000"x""/></numFmts><fonts><font><name val="{font}"/></font></fonts><cellXfs>{xfs}<xf numFmtId="165"/><xf numFmtId="165"/></cellXfs><dxfs><dxf><numFmt numFmtId="166" formatCode="{code}"/></dxf></dxfs></styleSheet>"#
|
||||
);
|
||||
let st = crate::styles::read_styles(&crate::xml::parse(styles.as_bytes()).unwrap(), &[], "");
|
||||
assert_eq!(st.xfs.len(), 10_002);
|
||||
let first = &st.xfs[0].num_fmt.0;
|
||||
assert_eq!(first.len(), 50_000);
|
||||
assert!(st.xfs[..10_000].iter().all(|s| std::sync::Arc::ptr_eq(&s.num_fmt.0, first) && s.font.name.chars().count() == 255));
|
||||
assert_eq!(st.xfs[10_000].num_fmt.as_str(), "0.000\"x\"");
|
||||
assert!(std::sync::Arc::ptr_eq(&st.xfs[10_000].num_fmt.0, &st.xfs[10_001].num_fmt.0));
|
||||
assert_eq!(st.dxfs[0].num_fmt.as_str(), code);
|
||||
|
||||
// The same styles part read through the package.
|
||||
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
|
||||
let sheet = r#"<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c></row></sheetData>"#;
|
||||
let (wb, _) = read_xlsx(&minimal(sheet, &[("xl/styles.xml", &styles)], rels, "")).unwrap();
|
||||
assert_eq!(wb.sheets.len(), 1);
|
||||
}
|
||||
|
||||
/// #2173: a shared formula's master is copied into every dependent; an over-long master keeps
|
||||
/// the dependents' cached values instead of copying it into each of them.
|
||||
#[test]
|
||||
fn shared_formula_copies_are_bounded() {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
let master = format!("SUM({}1)", "1,".repeat(500_000));
|
||||
let mut rows = format!(r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A20000">{master}</f><v>1</v></c></row>"#);
|
||||
for r in 2..=20_000 {
|
||||
rows.push_str(&format!(r#"<row r="{r}"><c r="A{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
|
||||
}
|
||||
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||
let cells = &wb.sheets[0].cells;
|
||||
for r in [1, 9_999, 19_999] {
|
||||
let c = cells.get(CellRef::new(r, 0)).unwrap();
|
||||
assert!(c.formula.is_none());
|
||||
assert_eq!(c.value, Value::Number(7.0));
|
||||
}
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("shared formula is longer")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_shared_master_expands_into_only_part_of_many_cells() {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
// About 4,000 characters and 2,000 operands: parseable, but far more than an ordinary
|
||||
// formula cell, so each copy pays for its excess and ten thousand of them exceed the budget.
|
||||
let master = vec![format!("SUM({}1)", "1,".repeat(199)); 10].join("+");
|
||||
let mut rows = format!(r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A10000">{master}</f><v>1</v></c></row>"#);
|
||||
for r in 2..=10_000 {
|
||||
rows.push_str(&format!(r#"<row r="{r}"><c r="A{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
|
||||
}
|
||||
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||
let cells = &wb.sheets[0].cells;
|
||||
assert!(cells.get(CellRef::new(1, 0)).unwrap().formula.is_some(), "the first dependents still expand");
|
||||
let last = cells.get(CellRef::new(9_999, 0)).unwrap();
|
||||
assert!(last.formula.is_none());
|
||||
assert_eq!(last.value, Value::Number(7.0));
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("expand to too much data")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_ordinary_shared_formulas_all_expand() {
|
||||
let mut rows =
|
||||
String::from(r#"<row r="1"><c r="B1"><f t="shared" si="0" ref="B1:B60000">IF(A1>0,SUM(A1:A10)/COUNT(A1:A10),"")</f><v>1</v></c></row>"#);
|
||||
for r in 2..=60_000 {
|
||||
rows.push_str(&format!(r#"<row r="{r}"><c r="B{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
|
||||
}
|
||||
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||
let cells = &wb.sheets[0].cells;
|
||||
assert!(cells.get(gridcraft_core::CellRef::new(59_999, 1)).unwrap().formula.is_some());
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("shared formula")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_multibyte_shared_master_gets_no_length_warning() {
|
||||
// 5,001 characters but over 10,000 bytes, and unparseable for another reason (an open string).
|
||||
let master = format!("\"{}", "\u{e9}".repeat(5_000));
|
||||
assert!(master.len() > gridcraft_formula::MAX_FORMULA_LEN && master.chars().count() < gridcraft_formula::MAX_FORMULA_LEN);
|
||||
let rows = format!(
|
||||
r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A2">{master}</f><v>1</v></c></row><row r="2"><c r="A2"><f t="shared" si="0"/><v>7</v></c></row>"#
|
||||
);
|
||||
let (_, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("longer than")), "{:?}", rep.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_column_ranges_have_one_worksheet_work_budget() {
|
||||
// 62 * 16,384 = 1,015,808 assignments, while the map holds at most 16,384 columns.
|
||||
|
||||
@@ -65,7 +65,7 @@ connection; nothing sent after it runs. Blank lines are skipped.
|
||||
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB |
|
||||
| `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor |
|
||||
| `ui.ribbon` | `{tab}` | Switch ribbon tab |
|
||||
| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after a pick on another sheet, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog whose reference boxes (RefEdit) picked on another sheet goes back to its own sheet: `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. |
|
||||
| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button. `confirm` applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers `this dialog has no OK command; drive it with its own commands` and is driven with that command (`data.goalSeek`). That command does not go back to the dialog's sheet: after another sheet was shown, read the fields (`ui.inspect` reports the dialog's `values`), `cancel` the dialog (which shows its sheet again), then run `data.goalSeek` with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, `cancel` leaves the sheet shown as it is and replies `{"sheetDeleted": true}` (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running `data.goalSeek`, whose references without a sheet name would mean the sheet shown. A dialog with reference boxes (RefEdit) goes back to its own sheet, the one shown when it was opened (a sheet shown later, before any pick, does not take its place): `cancel` puts the selection from before picking back; `confirm` puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and `confirm` also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason. |
|
||||
| `ui.pythonMode` (via `engine.execute`) | `{on?}` | Python mode on the active cell, in the formula bar (an edit in progress keeps its text); `on: false` turns it off; without `on` it switches. A start that is refused (a locked cell on a protected sheet, a cell or draft inside another cell's spill, code hidden on a protected sheet) is an error with the reason. Opens no dialog |
|
||||
| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` |
|
||||
|
||||
@@ -89,7 +89,8 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| YEARFRAC basis 0, February-end start to day 31 | done: Excel 16.0 returns 31/360 for `YEARFRAC(2021-02-28,2021-03-31,0)` (0.08611111111111111); regression also covers leap day and reversed dates |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened
|
||||
| Set precision as displayed | done: with the option on (`formulas.calculationOptions` `{precisionAsDisplayed}`, read and saved as `<calcPr fullPrecision="0">` and the `.xls` CalcPrecision record), a formula result, each element of a spill, and a number typed or reformatted are stored as the cell's number format shows them, and other formulas read the rounded value. Fixed decimals, percent, thousands scaling (`0.0,,`), fractions and scientific formats round; General, date and time, text and digitless formats keep the full value, as observed in Excel. Conditions don't pick the section that rounds: the sign does, the first section for positives and the second for negatives, so `[<1]0.00;0` stores 2+1/3 as 2.33 though it shows `2`, and -10/3 as -3; with a condition and a single number section, negatives keep the full value; a completely empty section (`0.0;;0.000`) passes to the next. Turning it on rounds every number constant (permanently); turning it off restores formulas only. Differences: Excel also rounds General constants it shows in scientific notation when the option is turned on (GridCraft leaves General alone); a `%` inside a scientific format is left unrounded; no Options dialog checkbox yet (command only) |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
||||
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
|
||||
| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder |
|
||||
@@ -171,6 +172,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
|
||||
| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it |
|
||||
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
|
||||
| 2026-10-14 | minor | Set precision as displayed: results, spills and typed or reformatted numbers are stored as their format shows them when the option is on (#1343) |
|
||||
| 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) |
|
||||
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
|
||||
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
|
||||
|
||||
+10
-3
@@ -183,7 +183,10 @@ applies to as it is (#2348).
|
||||
Reference boxes (RefEdit) take a range by selection on the grid: while a box is the one being
|
||||
picked into, clicking or dragging on the sheet writes the absolute reference into it (with the
|
||||
sheet name, quoted when it needs quotes, when the selection is on another sheet:
|
||||
`'Rates Table'!$A$2:$A$4`), and typing still edits it. The box's collapse button shrinks the
|
||||
`'Rates Table'!$A$2:$A$4`), and typing still edits it. A click on the cell already selected
|
||||
writes it too, and so does a second quick click (it does not edit the cell), while clicking into
|
||||
the box alone keeps the box's text. While a box picks, a click on a link or a checkbox picks its
|
||||
cell, without following the link or toggling the checkbox. The box's collapse button shrinks the
|
||||
dialog to the box while the person selects; Escape, Enter or the expand button restore it (Enter
|
||||
there does not press OK, which is not shown while collapsed). Cancel, Escape, a ribbon command
|
||||
that opens another dialog over it, and a scripted `ui.dialog.cancel` put the sheet and selection
|
||||
@@ -209,7 +212,11 @@ the constraint value once the operator is int or bin) is no longer picked into:
|
||||
made meanwhile does not land in it when it is shown again.
|
||||
The dialog follows the sheet it was opened on by the sheet's identity while the app runs
|
||||
(fresh for every sheet added, inserted, copied or read from a file; kept through renames,
|
||||
moves, undo and redo), wherever it is moved and whatever it is renamed to. A sheet deleted and
|
||||
moves, undo and redo), wherever it is moved and whatever it is renamed to. The sheet is
|
||||
recorded when the dialog opens (from the ribbon or a script): a sheet shown before any box
|
||||
picks is not the dialog's, so picks there carry its name, and OK or Cancel goes back to the
|
||||
sheet the dialog was opened on and its selection; a Data Analysis tool chosen there takes that
|
||||
sheet's selection with its name. A sheet deleted and
|
||||
another inserted in its place or given its own name, or a copy of it, even in one script batch
|
||||
or one undo step, is not taken for it: OK is refused and Cancel leaves the selection where it
|
||||
is; an undo that brings the sheet back makes it the dialog's again. The collapse button is
|
||||
@@ -256,7 +263,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) |
|
||||
| 2026-11-03 | minor | Ctrl+. (Control+. on a Mac): Next Corner of Selection, `selection.nextCorner`: the active cell moves clockwise to the next corner of the selected area, which stays selected, and becomes the anchor, as in Excel (#1442) |
|
||||
| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet |
|
||||
| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into |
|
||||
| 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into; the dialog's sheet is recorded when it opens, not at its first pick |
|
||||
| 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused |
|
||||
| 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |
|
||||
| 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Hosting GridCraft for the web
|
||||
|
||||
|
||||
`gridcraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
|
||||
static site in `gridcraft-web-<version>/`:
|
||||
|
||||
@@ -77,6 +78,97 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
one. Use them when the host window already shows the file name and owns the files, so the sheet's
|
||||
own Open or Save As cannot replace what the host thinks is open. Everything stays on by default.
|
||||
|
||||
### The parent bridge (`?host=parent`)
|
||||
|
||||
A page framing GridCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL
|
||||
(`https://example.com/gridcraft/?host=parent`) and have GridCraft hand its files over instead of
|
||||
downloading them. The two sides talk `postMessage` with plain window messages that carry a
|
||||
`type`:
|
||||
|
||||
| Message | Direction | Fields |
|
||||
|---|---|---|
|
||||
| `gridcraft:ready` | app → page | `version`, `appVersion`: the app is listening; send a file now |
|
||||
| `gridcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |
|
||||
| `gridcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (Save, Save As, Export as PDF). The page stores it and reports failures itself. |
|
||||
| `gridcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
|
||||
| `gridcraft:failed` | app → page | `error`: the app couldn't start |
|
||||
|
||||
In host mode the app starts with an empty workbook instead of a sample, so the framing page
|
||||
should guard leaving the page while changes are unsaved. `&author=` names the user for the
|
||||
notes and comments the app creates. Opening `?host=parent` outside an iframe gives the normal
|
||||
app with downloads.
|
||||
|
||||
### Embedding without an iframe (page host mode)
|
||||
|
||||
A page that loads the GridCraft module into its own document — its own `<canvas
|
||||
id="gridcraft_canvas">` inside a component, sized to a box beside the page's own header and
|
||||
controls — opts in with `data-host="page"` on that canvas. The behaviour is the same as the
|
||||
parent bridge, except the transport is DOM `CustomEvent`s on the canvas instead of
|
||||
`postMessage`: the app dispatches `gridcraft:ready`, `gridcraft:save`, `gridcraft:dirty` and
|
||||
`gridcraft:failed` on the canvas with the message fields above as the event's `detail`, and the
|
||||
page dispatches `gridcraft:open` on the canvas with `detail: { name, bytes }` to open a
|
||||
workbook. Events don't bubble, so two editors on one page can't hear each other; listen on the
|
||||
canvas itself.
|
||||
|
||||
```html
|
||||
<canvas id="gridcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>
|
||||
```
|
||||
|
||||
```js
|
||||
import init from "./gridcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
|
||||
|
||||
// Listen first: the app announces itself while `init` runs.
|
||||
const canvas = document.getElementById("gridcraft_canvas");
|
||||
|
||||
canvas.addEventListener("gridcraft:ready", async () => {
|
||||
// Open a workbook of the page's own, as with File › Open.
|
||||
const bytes = await workbookBytes(); // an ArrayBuffer or Uint8Array
|
||||
canvas.dispatchEvent(new CustomEvent("gridcraft:open", {
|
||||
detail: { name: "Budget.xlsx", bytes },
|
||||
}));
|
||||
});
|
||||
|
||||
// Every file GridCraft would otherwise download (Save, Save As, Export as PDF) arrives here.
|
||||
canvas.addEventListener("gridcraft:save", (event) => {
|
||||
const { name, bytes } = event.detail; // bytes is an ArrayBuffer
|
||||
storeFile(name, bytes);
|
||||
});
|
||||
|
||||
canvas.addEventListener("gridcraft:dirty", (event) => {
|
||||
warnBeforeLeaving(event.detail.dirty);
|
||||
});
|
||||
canvas.addEventListener("gridcraft:failed", (event) => {
|
||||
showError(event.detail.error);
|
||||
});
|
||||
|
||||
await init({ module_or_path: "./gridcraft-web-<hash>_bg.wasm" });
|
||||
```
|
||||
|
||||
As with the parent bridge, the app starts with an empty workbook instead of a sample, and
|
||||
`?author=` on the page's URL names the user for new notes and comments. The `data-host` value
|
||||
must be exactly `page`; anything else is ignored, and the app runs as a standalone page with
|
||||
downloads.
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `gridcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. GridCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and GridCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `gridcraft:ready`, `gridcraft:open`, `gridcraft:save`, `gridcraft:dirty` and `gridcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
### Driving it from the page (`window.gridcraft`)
|
||||
|
||||
Every GridCraft web build puts `window.gridcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `gridcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app skips it if it has not started it; a call the app has already taken still finishes, so a retry after a timeout can apply a command twice.
|
||||
|
||||
```js
|
||||
const book = await gridcraft.docInspect(); // sheets, used ranges, tables, charts, names, selection
|
||||
await gridcraft.execute("cell.set", { cell: "A1", input: "Total" });
|
||||
const { data } = await gridcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a workbook, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection
|
||||
|
||||
GridCraft's web build is the same egui app as the desktop one, compiled to wasm
|
||||
|
||||
Reference in new issue
Block a user