diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cde0cec..c704eb4d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ -# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land -# a red tree that only the next agent session discovers. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -12,37 +11,18 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux build dependencies (winit, wgpu, rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev + libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6cdde6e47..054491322 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,6 +2,9 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: branches: [release] @@ -10,6 +13,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "57 2 * * *" workflow_dispatch: concurrency: diff --git a/AGENTS.md b/AGENTS.md index 963fc6d74..7fd1629cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **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/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`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables @@ -37,6 +37,7 @@ 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`). diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..1d1a77d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **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/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`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/Cargo.lock b/Cargo.lock index e041aafbc..7f5203188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,6 +619,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1496,9 +1505,10 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.10.0" +version = "0.11.1" dependencies = [ "arboard", + "chrono", "craft-print", "eframe", "egui", @@ -1518,7 +1528,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1540,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1551,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,8 +1563,9 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.10.0" +version = "0.11.1" dependencies = [ + "chrono", "js-sys", "serde", "serde_json", @@ -1563,7 +1574,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1595,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1604,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1612,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1621,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1633,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.10.0" +version = "0.11.1" [[package]] name = "gridcraft-ui-egui" -version = "0.10.0" +version = "0.11.1" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1666,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.10.0" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -1672,7 +1683,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.10.0" +version = "0.11.1" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e8a6dc67e..4d3fd4b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -30,6 +30,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" @@ -66,9 +68,11 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 2c224559c..d9dc5eb36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ GridCraft icon: a spreadsheet page on a green tile +

+

GridCraft

diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..1faf680ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | | 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | @@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | | 2026-10-19 | minor | Progress log: long flat operator chains (#916) | diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 0b763e378..972c023fa 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -43,6 +43,8 @@ const USAGE: &str = "usage: cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference) gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] + a script line is id={json} or {\"command\": \"id\", \"params\": {…}}; + blank lines and lines starting with # or // are skipped gridcraft-cli commands [--json] [--search X] gridcraft-cli functions [--json] [--search X] [--category C] gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] @@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result, String> { } if t.starts_with('{') { let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + // A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and + // `{"command": …, "name": "Report"}` ran the command with no parameters at all and the + // script still exited 0, so a typo made a valid, wrong workbook (#2256). + if let Some(o) = v.as_object() { + let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect(); + if !unknown.is_empty() { + unknown.sort_unstable(); + let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `")); + return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}")); + } + } let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); } @@ -567,6 +580,26 @@ mod tests { assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); assert_eq!(parse_command(" # comment").unwrap(), None); assert!(parse_command("x={bad").is_err()); + // A key this does not read is a mistake: the command used to run with no parameters and + // the script still exited 0, so a typo made a valid, wrong workbook (#2256). + for (line, named) in [ + (r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"), + (r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"), + (r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"), + ] { + let e = parse_command(line).expect_err("refused"); + assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}"); + assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}"); + } + // Several at once are listed in one message, sorted. + let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused"); + assert!(e.contains("unknown keys `args`, `name`"), "{e}"); + // `id` is still the accepted alternative to `command`, and `params` still carries them. + assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(), + Some(("home.insertSheet".into(), json!({"name": "R"}))) + ); } #[test] diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/it/cli.rs similarity index 80% rename from apps/gridcraft-cli/tests/cli.rs rename to apps/gridcraft-cli/tests/it/cli.rs index 3105819ec..dde018ca8 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::io::Write; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; @@ -206,3 +204,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() { assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit")))); let _ = std::fs::remove_dir_all(dir); } + +/// A script line whose parameters are under a key `run` does not read used to run the command +/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2, +/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256). +#[test] +fn a_script_line_with_an_unknown_key_stops_the_run() { + let dir = tmpdir("script-keys"); + let script = dir.join("s.jsonl"); + let out = dir.join("t.xlsx"); + let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap(); + + write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "the run must stop: {err}"); + assert!(err.contains("s.jsonl:2"), "it names the line: {err}"); + assert!(err.contains("unknown key `args`"), "and the key: {err}"); + assert!(!out.exists(), "and writes no workbook: {err}"); + + // The forms that are read still work, in one script: `params`, `id=json`, and a comment. + write(&[ + r#"{"command":"file.new"}"#, + r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#, + "# a comment", + r#"home.insertSheet={"name":"Report2"}"#, + ]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = ok(&["info", out.to_str().unwrap()]); + assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}"); + assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}"); + + // The usage text says what a line looks like, which is how this was found. + let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout); + assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..17f8db87f --- /dev/null +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +mod text_to_columns_quotes; diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs similarity index 100% rename from apps/gridcraft-cli/tests/text_to_columns_quotes.rs rename to apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs diff --git a/apps/gridcraft-web/index.html b/apps/gridcraft-web/index.html index 5fdd1ea54..321aa302e 100644 --- a/apps/gridcraft-web/index.html +++ b/apps/gridcraft-web/index.html @@ -2,7 +2,7 @@ - + GridCraft diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b34..94cf3fe9b 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index e3b56a8cb..a0f62d164 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -393,6 +399,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..7dc549cb7 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Unhide Sheet…", "sheet.unhide", None), ("-", "", None), ("Conditional Formatting…", "dialog:manageRules", None), - ("Format as Table", "insert.table", None), + ("Format as Table", "home.formatAsTable", None), ], ), ( diff --git a/contributors/people.toml b/contributors/people.toml index 62635c8ae..88e496fc7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -93,3 +93,6 @@ sync_github_name = false [people.bilalmughal] sync_github_name = true + +[people.tyrel] +real_name = "Tyrel Haveman" diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e5e287d38..622ed6cc9 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1768,3 +1768,16 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() { t.set("E11", "=ROWS(D1#)"); assert_eq!(t.get("E11"), Value::Error(CellError::Ref)); } + +#[test] +fn text_before_after_empty_instance_defaults_to_one() { + let mut t = T::new(); + t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)"); + assert_eq!(t.get("A1"), Value::Text("Little ".into())); + t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")"); + assert_eq!(t.get("A2"), Value::Text("b".into())); + t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")"); + assert_eq!(t.get("A3"), Value::Text("nf".into())); + t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); + assert_eq!(t.get("A4"), Value::Error(CellError::Value)); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1decf6257..e7d369552 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] serde = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index b61892f6c..b42c068a0 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -28,15 +28,22 @@ pub struct DateTime { pub weekday: u32, } -/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01. +/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year. +/// +/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400 +/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added +/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce` +/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave. pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y as i64 - 1 } else { y as i64 }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let m = m as i64; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 + let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. @@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 { } } +/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of +/// [`days_from_civil`]). +/// +/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle +/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the +/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range. +/// 1970-01-01 is `num_days_from_ce` 719_163. pub fn civil_from_days(z: i64) -> (i32, u32, u32) { - let z = z + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - ((if m <= 2 { y + 1 } else { y }) as i32, m, d) + use chrono::Datelike; + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day()) + } + // Unreachable: `rest + 719_163` is always a valid CE day. + None => (1970, 1, 1), + } } const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30) const UNIX_1904_01_01: i64 = -24107; +/// Whether `y` is a Gregorian leap year, for every `i32` year. pub fn is_leap(y: i32) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 + // A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so + // the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono. + let y = i64::from(y).rem_euclid(400) as i32; + chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some() } +/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every +/// `i32` year. pub fn days_in_month(y: i32, m: u32) -> u32 { - match m { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if is_leap(y) => 29, - 2 => 28, + // The length of a month is the day before the first of the next month. Month lengths repeat + // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and + // always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`. + if !(1..=12).contains(&m) { + return 0; + } + let y = i64::from(y).rem_euclid(400) as i32; + let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) { + (Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0), _ => 0, } } @@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th mod tests { use super::*; + #[test] + fn leap_and_month_length_outside_chrono_range() { + // In range, the ordinary Gregorian rule holds. + assert!(is_leap(2000)); + assert!(!is_leap(1900)); + assert!(is_leap(2024)); + assert!(!is_leap(2023)); + // Outside chrono's NaiveDate range the Gregorian rule is still applied. + assert!(is_leap(400_000)); // divisible by 400 + assert!(!is_leap(300_100)); // century not divisible by 400 + assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century + assert!(!is_leap(i32::MAX)); // 2147483647 is odd + // days_in_month keeps calendar lengths out of range and 0 for invalid months. + assert_eq!(days_in_month(400_000, 2), 29); + assert_eq!(days_in_month(300_100, 2), 28); + assert_eq!(days_in_month(400_000, 1), 31); + assert_eq!(days_in_month(400_000, 4), 30); + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2023, 0), 0); + assert_eq!(days_in_month(2023, 13), 0); + // December at i32::MAX does not overflow and still reports 31 days. + assert_eq!(days_in_month(i32::MAX, 12), 31); + assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year + } + + #[test] + fn civil_conversions_exact_beyond_chrono_range() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of + // the epoch; values round-trip through the proleptic Gregorian converters. + for (y, m, d, z) in [ + (262_144i32, 1u32, 1u32, 95_026_602i64), + (300_000, 1, 1, 108_853_222), + (400_000, 2, 29, 145_377_531), + (-262_145, 3, 1, -96_465_964), + (-300_000, 6, 15, -110_292_112), + ] { + assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}"); + assert_eq!(civil_from_days(z), (y, m, d), "{z}"); + } + // The extremes must not overflow or panic (i128 intermediates). + let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN)); + } + + #[test] + fn serials_unchanged_through_cycle_conversion() { + // The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see + // in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day + assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0)); + assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(days_in_month_in(s, 1900, 2), 29); + let s4 = DateSystem::D1904; + assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01 + assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year + assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system + assert_eq!(days_in_month_in(s4, 1900, 2), 28); + } + #[test] fn serial_from_unix_time() { assert_eq!(serial_from_unix_ms(0.0), 25569.0); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b58586aef..3f2b97a8a 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { } /// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects -/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept. +/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a +/// finite number (#2163). fn plain_number(s: &str, frac: bool) -> Option { let (int, dec) = match s.split_once('.') { Some((i, d)) if frac => (i, Some(d)), @@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option { if !digits(int) || dec.is_some_and(|d| !digits(d)) { return None; } - s.parse().ok() + s.parse().ok().filter(|x: &f64| x.is_finite()) } /// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. @@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option { return None; } let v = w + a / b; + if !v.is_finite() { + return None; + } Some(if neg { -v } else { v }) } @@ -366,6 +370,17 @@ mod tests { parse_input(s, DateSystem::D1900) } + /// #2163: digits too many for a finite number don't make one (∞/∞ was NaN). + #[test] + fn overlong_digits_stay_text() { + let big = "9".repeat(400); + for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] { + assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}"); + assert_eq!(parse_number_text(&s), None, "{s}"); + } + assert_eq!(parse_number_text("1 1/2"), Some(1.5)); + } + #[test] fn non_digit_time_and_fraction_parts_stay_text() { for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6b67e1b4..007cbf4bc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), + ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 58144202c..5e30f14f5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}", has_doc, range_set_values ), @@ -299,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; if rows.len() > MAX_ROWS as usize { return Err(bad("range.setValues", "too many rows")); } @@ -321,6 +330,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { Json::Bool(b) => { Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) } + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + Json::String(text) if !parse && !text.starts_with('=') => { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; @@ -1778,6 +1798,55 @@ mod tests { assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); } + /// Every string in `values` went through typed input, read with en-US rules, in silence: a + /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and + /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. + #[test] + fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5f665b765..a566f7f12 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -193,7 +193,24 @@ pub fn specs() -> Vec { new_script ), cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts), - cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script), + cmd!( + "automate.runScript", + "Run Script", + [], + None, + "{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)", + has_doc, + run_script + ), + cmd!( + noundo "automate.trustScripts", + "Trust Scripts", + ["Automate", "Scripting Tools"], + None, + "{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session", + has_doc, + trust_scripts + ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), // Cell controls, themes, borders, styles cmd!( @@ -785,6 +802,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result { let d = s.doc()?.clone(); let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); copy.selection = d.selection.clone(); + copy.scripts_trusted = d.scripts_trusted; copy.trust = d.trust; let i = s.add_document(copy); Ok(json!({"index": i})) @@ -977,10 +995,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let wb = &s.doc()?.wb; + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on // save but never executed here, so it has no step count. if let Some(bytes) = &wb.vba_project { @@ -989,17 +1010,79 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result { Ok(Json::Array(list)) } +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. +fn denied_script_step(id: &str, params: &Json) -> Option { + if id.starts_with("file.") || id.starts_with("link.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + if params.get("path").is_some() { + return Some(format!("scripts can't pass a file `path` to `{id}`")); + } + None +} + +fn trust_scripts(s: &mut Session, p: &Json) -> Result { + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. fn run_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } + let _sandbox = crate::io::ScriptSandbox::enter(); let mut n = 0; - for st in steps.iter().take(100_000) { + for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; } - s.execute(id, st["params"].clone())?; + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; n += 1; } Ok(json!({"ran": n})) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index aae5a9faa..2388d1f25 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -26,9 +26,9 @@ pub fn specs() -> Vec { "Format as Table", ["Home", "Styles"], None, - "{range?, style: \"TableStyleMedium2\", header?}", + "{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)", has_doc, - insert_table + format_as_table ), cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag( s, p, "totals" @@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { }) } +/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one. +fn format_as_table(s: &mut Session, p: &Json) -> Result { + // Without a range, the active area decides (as in insert_table), not the first one. + let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() }; + let sheet = target_sheet(s, p)?; + let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range)); + match inside { + Some((table, range)) => { + table_style(s, &with(p, "table", json!(table)))?; + Ok(json!({"table": table, "range": range.a1()})) + } + None => insert_table(s, p), + } +} + fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; if let Some(n) = str_param(p, "table") { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f0b2f77d3..b42aec9a6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")), cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")), cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), + cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), @@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { "gridlines" => &mut sh.show_gridlines, "headings" => &mut sh.show_headings, "zeros" => &mut sh.show_zeros, + "outlineSymbols" => &mut sh.show_outline_symbols, "rtl" => &mut sh.right_to_left, "printGridlines" => &mut sh.print.gridlines, _ => &mut sh.print.headings, diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..b18e8577b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { Ok(out) } +thread_local! { + /// How many [`ScriptSandbox`]es are alive on this thread. + static SCRIPT_SANDBOX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a +/// stored script's steps run inside one (#2178), so no command reaches the file system from a +/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal. +pub(crate) struct ScriptSandbox(()); + +impl ScriptSandbox { + pub(crate) fn enter() -> Self { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1))); + ScriptSandbox(()) + } +} + +impl Drop for ScriptSandbox { + fn drop(&mut self) { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1))); + } +} + +/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the +/// thread is being torn down). +fn script_sandbox_check(path: &str) -> Result<()> { + if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) { + return Err(EngineError::Other(format!("{path}: scripts can't read or write files"))); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; if meta.len() > 2 * 1024 * 1024 * 1024 { return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); @@ -567,11 +600,13 @@ pub fn read_file(path: &str) -> Result> { #[cfg(target_arch = "wasm32")] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; // Write to a temporary file, then rename: a crash never leaves a half-written workbook. let tmp = format!("{path}.sctmp"); std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { @@ -584,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(target_arch = "wasm32")] pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } @@ -902,6 +938,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// While a script runs (#2178), file reads and writes are refused whatever command or + /// parameter asked for them; nesting and dropping the sandbox restore access. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5cca176ef..8614bc094 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -173,7 +173,15 @@ pub struct DocState { /// The format `path`'s content was recognised as when its extension doesn't name one (an /// XLSX called `budget.dat`): Save writes it back in that format. pub content_kind: Option, - /// Whether the workbook's active content may run without asking. + /// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook + /// that arrives already carrying scripts (opened from a file) starts untrusted until the + /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts + /// trusted, since any script it gains later was written here. + pub scripts_trusted: bool, + /// Where the workbook's content came from: whether its active content may run without + /// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the + /// workbook is opened from a file, carried by content pasted from such a workbook, and never + /// changed by a trust decision. pub trust: Trust, } @@ -239,6 +247,7 @@ impl DocState { pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); + let scripts_trusted = !cmd::extra::has_scripts(&wb); let wb = Arc::new(wb); let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); DocState { @@ -255,6 +264,7 @@ impl DocState { sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, + scripts_trusted, trust: Trust::Local, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 157cdd918..4f2fc91bb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1093,6 +1093,131 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { s.execute("automate.runScript", json!({"name": "Lock"})).unwrap(); } +/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178). +fn opened_with_script() -> Session { + let mut author = s(); + author + .execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap(); + let mut s = Session::new(); + s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + s +} + +#[test] +fn scripts_written_in_this_session_run_without_a_trust_step() { + let mut s = s(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); +} + +#[test] +fn untrusted_workbook_scripts_are_refused_until_trusted() { + let mut s = opened_with_script(); + s.ui_requests.clear(); + assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted"); + let listed = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(listed[0]["trusted"], false, "{listed}"); + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("trust"), "{err}"); + assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs"); + // Trust can't be smuggled in as a string, and nothing opens a dialog. + assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err()); + assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests); + + // An explicit `trusted: true` runs it this once without trusting the workbook. + s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + assert!(!s.doc().unwrap().scripts_trusted); + s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + + // Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session. + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + s.execute("automate.runScript", json!({"name": "Fill"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + // A second window on the same workbook keeps the decision; withdrawing it disables them again. + s.execute("view.newWindow", json!({})).unwrap(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.trustScripts", json!({"trusted": false})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); +} + +#[test] +fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() { + let mut s = opened_with_script(); + // A `trusted` that isn't a boolean is an error and leaves the workbook untrusted. + for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] { + let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string(); + assert!(err.contains("trusted"), "{wrong}: {err}"); + assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook"); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + } + // The refusal names where the Trust Scripts button actually is. + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}"); + assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false); + assert!(!s.doc().unwrap().scripts_trusted); + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + assert!(s.doc().unwrap().scripts_trusted); +} + +#[test] +fn scripts_leave_file_access_working_after_they_run() { + // The sandbox a script runs in is lifted afterwards, also when a step fails. + let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, b"a,b\n1,2\n").unwrap(); + let csv = csv.to_string_lossy().to_string(); + let mut s = s(); + let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}}); + s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err()); + s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Ok"})).unwrap(); + s.execute("data.fromTextCsv", json!({"path": csv})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { + let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); + let leak = dir.join("leak.xlsx"); + let leak = leak.to_string_lossy().to_string(); + let mut s = opened_with_script(); + s.ui_requests.clear(); + s.execute("automate.trustScripts", json!({})).unwrap(); + let denied = [ + json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}), + json!({"command": "file.saveAs", "params": {"path": leak}}), + json!({"command": "file.save", "params": {}}), + json!({"command": "file.exportPdf", "params": {"path": leak}}), + json!({"command": "file.open", "params": {"path": leak}}), + json!({"command": "data.getData", "params": {"path": leak}}), + json!({"command": "data.fromTextCsv", "params": {"path": leak}}), + json!({"command": "insert.picture", "params": {"path": leak}}), + ]; + for step in denied { + // The harmless first step proves the whole script is refused before anything runs. + let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}}); + s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true})); + assert!(r.is_err(), "{step} must be refused"); + assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs"); + } + assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests); + assert!(!std::path::Path::new(&leak).exists()); + // Data a script supplies itself is still fine. + s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Csv"})).unwrap(); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); @@ -2595,6 +2720,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() { assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); } +/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting. +#[test] +fn show_outline_symbols_toggles() { + let mut s = s(); + let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols; + assert!(on(&s)); + assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false})); + assert!(!on(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(on(&s)); + s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap(); + reopen(&mut s); + assert!(!on(&s)); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/engine/tests/autofit_filter_buttons.rs b/crates/engine/tests/it/autofit_filter_buttons.rs similarity index 100% rename from crates/engine/tests/autofit_filter_buttons.rs rename to crates/engine/tests/it/autofit_filter_buttons.rs diff --git a/crates/engine/tests/cf_formula_validate.rs b/crates/engine/tests/it/cf_formula_validate.rs similarity index 100% rename from crates/engine/tests/cf_formula_validate.rs rename to crates/engine/tests/it/cf_formula_validate.rs diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/it/chart_workbook_names.rs similarity index 100% rename from crates/engine/tests/chart_workbook_names.rs rename to crates/engine/tests/it/chart_workbook_names.rs diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs similarity index 99% rename from crates/engine/tests/command_sequence_fuzz.rs rename to crates/engine/tests/it/command_sequence_fuzz.rs index f8cb603cb..012757f69 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -23,7 +23,7 @@ //! //! Knobs (environment variables): //! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with -//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`. +//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`. //! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line). //! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that //! still fails the same way, printed as `execute` calls ready for a regression test. @@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure) None => format!("end-of-run checks after {} steps", sequence.len()), }; let mut out = format!( - "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n", + "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n", f.kind, f.what ); let end = (f.step + 1).min(sequence.len()); diff --git a/crates/engine/tests/cross_sheet_cut.rs b/crates/engine/tests/it/cross_sheet_cut.rs similarity index 100% rename from crates/engine/tests/cross_sheet_cut.rs rename to crates/engine/tests/it/cross_sheet_cut.rs diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/it/csv_export_budget.rs similarity index 100% rename from crates/engine/tests/csv_export_budget.rs rename to crates/engine/tests/it/csv_export_budget.rs diff --git a/crates/engine/tests/custom_filter_blanks.rs b/crates/engine/tests/it/custom_filter_blanks.rs similarity index 100% rename from crates/engine/tests/custom_filter_blanks.rs rename to crates/engine/tests/it/custom_filter_blanks.rs diff --git a/crates/engine/tests/custom_view_freeze.rs b/crates/engine/tests/it/custom_view_freeze.rs similarity index 100% rename from crates/engine/tests/custom_view_freeze.rs rename to crates/engine/tests/it/custom_view_freeze.rs diff --git a/crates/engine/tests/dynamicmetadata.rs b/crates/engine/tests/it/dynamicmetadata.rs similarity index 100% rename from crates/engine/tests/dynamicmetadata.rs rename to crates/engine/tests/it/dynamicmetadata.rs diff --git a/crates/engine/tests/extreme_selection_moves.rs b/crates/engine/tests/it/extreme_selection_moves.rs similarity index 100% rename from crates/engine/tests/extreme_selection_moves.rs rename to crates/engine/tests/it/extreme_selection_moves.rs diff --git a/crates/engine/tests/it/format_as_table_restyle.rs b/crates/engine/tests/it/format_as_table_restyle.rs new file mode 100644 index 000000000..0419027c6 --- /dev/null +++ b/crates/engine/tests/it/format_as_table_restyle.rs @@ -0,0 +1,61 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn table() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap(); + s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap(); + s +} + +fn styles(s: &Session) -> Vec { + s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect() +} + +#[test] +fn format_as_table_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_on_a_range_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleDark3"]); +} + +#[test] +fn format_as_table_across_a_table_edge_is_rejected() { + let mut s = table(); + let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err(); + assert!(e.to_string().contains("overlap"), "{e}"); + assert_eq!(styles(&s), ["TableStyleMedium2"]); +} + +#[test] +fn format_as_table_inside_a_table_returns_that_table() { + let mut s = table(); + let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap(); + assert_eq!(r, json!({"table":"T", "range":"A1:B3"})); +} + +#[test] +fn format_as_table_uses_the_active_area_of_a_multi_area_selection() { + let mut s = table(); + s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() { + let mut s = table(); + s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/it/hidden_sheet_navigation.rs similarity index 100% rename from crates/engine/tests/hidden_sheet_navigation.rs rename to crates/engine/tests/it/hidden_sheet_navigation.rs diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/it/ink_roundtrip.rs similarity index 100% rename from crates/engine/tests/ink_roundtrip.rs rename to crates/engine/tests/it/ink_roundtrip.rs diff --git a/crates/engine/tests/line_count_overflow.rs b/crates/engine/tests/it/line_count_overflow.rs similarity index 100% rename from crates/engine/tests/line_count_overflow.rs rename to crates/engine/tests/it/line_count_overflow.rs diff --git a/crates/engine/tests/long-operator-chains.rs b/crates/engine/tests/it/long_operator_chains.rs similarity index 100% rename from crates/engine/tests/long-operator-chains.rs rename to crates/engine/tests/it/long_operator_chains.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..385ec76ca --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,29 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod autofit_filter_buttons; +mod cf_formula_validate; +mod chart_workbook_names; +mod command_sequence_fuzz; +mod cross_sheet_cut; +mod csv_export_budget; +mod custom_filter_blanks; +mod custom_view_freeze; +mod dynamicmetadata; +mod extreme_selection_moves; +mod format_as_table_restyle; +mod hidden_sheet_navigation; +mod ink_roundtrip; +mod line_count_overflow; +mod long_operator_chains; +mod offset_overflow; +mod percent_entry; +mod pivot_unknown_func; +mod pivotchart_link; +mod range_fill_formats; +mod row_autofit; +mod sheet_name_quoting; +mod sparse_clear_invalidation; +mod stored_list_shape; +mod table_resize_overlap; +mod table_total_row_last_row; +mod wrap_row_height; diff --git a/crates/engine/tests/offset_overflow.rs b/crates/engine/tests/it/offset_overflow.rs similarity index 100% rename from crates/engine/tests/offset_overflow.rs rename to crates/engine/tests/it/offset_overflow.rs diff --git a/crates/engine/tests/percent_entry.rs b/crates/engine/tests/it/percent_entry.rs similarity index 100% rename from crates/engine/tests/percent_entry.rs rename to crates/engine/tests/it/percent_entry.rs diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/it/pivot_unknown_func.rs similarity index 100% rename from crates/engine/tests/pivot_unknown_func.rs rename to crates/engine/tests/it/pivot_unknown_func.rs diff --git a/crates/engine/tests/pivotchart_link.rs b/crates/engine/tests/it/pivotchart_link.rs similarity index 100% rename from crates/engine/tests/pivotchart_link.rs rename to crates/engine/tests/it/pivotchart_link.rs diff --git a/crates/engine/tests/range_fill_formats.rs b/crates/engine/tests/it/range_fill_formats.rs similarity index 100% rename from crates/engine/tests/range_fill_formats.rs rename to crates/engine/tests/it/range_fill_formats.rs diff --git a/crates/engine/tests/row_autofit.rs b/crates/engine/tests/it/row_autofit.rs similarity index 100% rename from crates/engine/tests/row_autofit.rs rename to crates/engine/tests/it/row_autofit.rs diff --git a/crates/engine/tests/sheet_name_quoting.rs b/crates/engine/tests/it/sheet_name_quoting.rs similarity index 100% rename from crates/engine/tests/sheet_name_quoting.rs rename to crates/engine/tests/it/sheet_name_quoting.rs diff --git a/crates/engine/tests/sparse_clear_invalidation.rs b/crates/engine/tests/it/sparse_clear_invalidation.rs similarity index 100% rename from crates/engine/tests/sparse_clear_invalidation.rs rename to crates/engine/tests/it/sparse_clear_invalidation.rs diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/it/stored_list_shape.rs similarity index 100% rename from crates/engine/tests/stored_list_shape.rs rename to crates/engine/tests/it/stored_list_shape.rs diff --git a/crates/engine/tests/table_resize_overlap.rs b/crates/engine/tests/it/table_resize_overlap.rs similarity index 100% rename from crates/engine/tests/table_resize_overlap.rs rename to crates/engine/tests/it/table_resize_overlap.rs diff --git a/crates/engine/tests/table_total_row_last_row.rs b/crates/engine/tests/it/table_total_row_last_row.rs similarity index 100% rename from crates/engine/tests/table_total_row_last_row.rs rename to crates/engine/tests/it/table_total_row_last_row.rs diff --git a/crates/engine/tests/wrap_row_height.rs b/crates/engine/tests/it/wrap_row_height.rs similarity index 100% rename from crates/engine/tests/wrap_row_height.rs rename to crates/engine/tests/it/wrap_row_height.rs diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..62d59bff3 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String { write_struct_ref(s, false) } +/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets +/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`). +fn sheet_split(n: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut escaped = false; + let mut at = None; + for (i, c) in n.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\'' if depth > 0 => escaped = true, + '[' => depth = depth.saturating_add(1), + ']' => depth = depth.saturating_sub(1), + '!' if depth == 0 => at = Some(i), + _ => {} + } + } + at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?))) +} + fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { @@ -132,7 +154,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { } fn number(n: f64) -> String { - number_to_text(n) + let text = number_to_text(n); + // 15 significant digits can round past f64::MAX, which no longer parses. + if text.parse::().is_ok_and(f64::is_finite) { + return text; + } + let exact = format!("{:E}", n); + match exact.split_once('E') { + Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"), + _ => exact, + } } /// Prints an expression in A1 notation. @@ -178,7 +209,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => match n.rsplit_once('!') { + Expr::Name(n) => match sheet_split(n) { // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. Some((sheet, name)) => { s.push_str("e_sheet(sheet)); diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs new file mode 100644 index 000000000..01bff98c1 --- /dev/null +++ b/crates/formula/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod printer_roundtrip; +mod spanish_input; diff --git a/crates/formula/tests/it/printer_roundtrip.rs b/crates/formula/tests/it/printer_roundtrip.rs new file mode 100644 index 000000000..206e5efff --- /dev/null +++ b/crates/formula/tests/it/printer_roundtrip.rs @@ -0,0 +1,15 @@ +use gridcraft_formula::{Expr, parse, print}; + +#[test] +fn number_near_f64_max_round_trips() { + for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] { + let e = parse(src).unwrap(); + let printed = print(&e); + let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}")); + assert_eq!(print(&back), printed); + match (&e, &back) { + (Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b), + _ => assert_eq!(e, back), + } + } +} diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/it/spanish_input.rs similarity index 100% rename from crates/formula/tests/spanish_input.rs rename to crates/formula/tests/it/spanish_input.rs diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 67132e98f..455b8a1d6 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { match v { Value::Number(n) => { let i = n.trunc(); - if i < 1.0 || i > db.cols as f64 { + // NaN too: it would pass both comparisons and become index 0 (#2163). + if i.is_nan() || i < 1.0 || i > db.cols as f64 { return Err(CellError::Value); } - Ok(Some(i as usize - 1)) + Ok(Some((i as usize).saturating_sub(1))) } Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value), Value::Bool(_) => Err(CellError::Value), diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index d7ccc8726..e4f7a1f24 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -264,6 +264,37 @@ mod tests { } } + /// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`). + #[test] + fn nan_index_is_a_value_error() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]])); + let nan = || av(Value::Number(f64::NAN)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0)); + } + + /// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces + /// as before (every match, `$n` groups). + #[test] + fn regexreplace_bounds_its_result() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let many = "a".repeat(32_767); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767))); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c")); + assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); + assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + // One match whose replacement repeats it 10,000 times: refused before it is built. + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value)); + } + #[test] fn hostile_inputs_never_panic() { use crate::util::testutil::*; @@ -279,6 +310,9 @@ mod tests { n(-1e308), n(1e10), n(2.5), + // Numbers no formula should make, but code can (#2163). + av(Value::Number(f64::NAN)), + av(Value::Number(f64::INFINITY)), t(""), t("abc"), t("*?~"), diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index f9c0b6f44..acb23f2ea 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R { return Err(*e); } let idx = num(args, 2)?; - if idx < 1.0 { + // NaN too: it would pass `idx < 1.0` and become index 0 (#2163). + if idx.is_nan() || idx < 1.0 { return Err(CellError::Value); } - let idx = idx.trunc() as usize - 1; + let idx = (idx.trunc() as usize).saturating_sub(1); let approx = opt_bool(args, 3, true)?; let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) }; if idx >= len { diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 71a6ccb4b..b9b06e608 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -5,8 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text}; use crate::criteria::wildcard_find; use crate::util::{ - A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, - text_val, + A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, int, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, + text, text_val, }; use crate::{Arg, Ctx, FnSpec, VAR}; @@ -550,7 +550,7 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end fn text_before_after(a: &[Arg], before: bool) -> R { let s = chars(&text(a, 0)?); let delims = delimiters(&arg(a, 1)?.value)?; - let inst = opt_int(a, 2, 1)?; + let inst = if has(a, 2) { int(a, 2)? } else { 1 }; let mode = opt_int(a, 3, 0)?; let match_end = opt_bool(a, 4, false)?; if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { @@ -763,23 +763,99 @@ fn convert_replacement(r: &str) -> String { out } +/// A piece of a replacement: literal text or a capture group (by number or name). +enum Piece { + Text(String), + Index(usize), + Name(String), +} + +/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes +/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a +/// group number, and a `$` that starts no reference is itself. +fn template(rep: &str) -> Vec { + let mut out = Vec::new(); + let mut lit = String::new(); + let mut rest = rep; + while let Some(i) = rest.find('$') { + lit.push_str(&rest[..i]); + rest = &rest[i + 1..]; + if let Some(r) = rest.strip_prefix('$') { + lit.push('$'); + rest = r; + continue; + } + // A braced name is a reference even when empty (it expands to nothing). + let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..]))); + let (name, after) = braced.unwrap_or_else(|| { + let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len()); + (&rest[..j], &rest[j..]) + }); + if braced.is_none() && name.is_empty() { + lit.push('$'); + continue; + } + if !lit.is_empty() { + out.push(Piece::Text(std::mem::take(&mut lit))); + } + out.push(match name.parse::() { + Ok(n) => Piece::Index(n), + Err(_) => Piece::Name(name.to_string()), + }); + rest = after; + } + lit.push_str(rest); + if !lit.is_empty() { + out.push(Piece::Text(lit)); + } + out +} + +/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit` +/// bytes, before anything that long is built (#2162). +fn expand_capped(cap: ®ex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> { + for p in pieces { + let s = match p { + Piece::Text(t) => t.as_str(), + Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()), + Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()), + }; + if out.len().saturating_add(s.len()) > limit { + return None; + } + out.push_str(s); + } + Some(()) +} + fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = text(a, 0)?; - let rep = convert_replacement(&text(a, 2)?); + let rep = template(&convert_replacement(&text(a, 2)?)); let occ = opt_int(a, 3, 0)?; let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; + // Built match by match within a bound past which the text can only be too long (a character + // is at most four bytes), instead of building every replacement first (#2162). + let limit = MAX_TEXT * 4; + let mut out = String::new(); if occ == 0 { - return check_len(re.replace_all(&s, rep.as_str()).into_owned()); + let mut last = 0; + for cap in re.captures_iter(&s) { + let Some(m) = cap.get(0) else { continue }; + out.push_str(s.get(last..m.start()).unwrap_or_default()); + expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?; + last = m.end(); + } + out.push_str(s.get(last..).unwrap_or_default()); + return check_len(out); } let caps: Vec = re.captures_iter(&s).collect(); let k = occ.unsigned_abs() as usize; let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) }; let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) }; let Some(m) = cap.get(0) else { return text_val(s) }; - let mut out = String::new(); - out.push_str(&s[..m.start()]); - cap.expand(&rep, &mut out); - out.push_str(&s[m.end()..]); + out.push_str(s.get(..m.start()).unwrap_or_default()); + expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?; + out.push_str(s.get(m.end()..).unwrap_or_default()); check_len(out) } @@ -957,6 +1033,20 @@ pub(crate) fn specs() -> Vec { mod tests { use gridcraft_core::{CellError, Value}; + /// The pieces expand exactly as `regex::Captures::expand` does. + #[test] + fn template_expands_like_regex() { + let re = regex::Regex::new(r"(?\d+)-(\d+)").unwrap(); + let cap = re.captures("x 2026-10 y").unwrap(); + for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] { + let mut want = String::new(); + cap.expand(rep, &mut want); + let mut got = String::new(); + assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some()); + assert_eq!(got, want, "{rep:?}"); + } + } + use crate::util::testutil::*; #[test] diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 1901d8048..f7ef8f3be 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -76,6 +76,10 @@ pub struct Sheet { pub show_headings: bool, pub show_formulas: bool, pub show_zeros: bool, + /// The outline's level buttons and brackets (Excel's Show Outline Symbols, Ctrl+8); the + /// outline itself stays. + #[serde(default = "yes")] + pub show_outline_symbols: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, @@ -113,6 +117,10 @@ pub struct Sheet { pub preserved: Option>, } +fn yes() -> bool { + true +} + /// Default width of a column and height of a row (pixels at 96 dpi) for the default font. pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; @@ -140,6 +148,7 @@ impl Sheet { tab_color: None, visibility: Visibility::Visible, show_gridlines: true, + show_outline_symbols: true, show_headings: true, show_formulas: false, show_zeros: true, diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index dcee71173..9eb3cc69c 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -79,6 +79,9 @@ fn main() { } i += 1; } + // Catalog the installed fonts first (the app scans in the background from its first frame), so + // the workbook's fonts are drawn in their own faces, not a substitute. + let _ = gridcraft_ui_egui::fonts::catalog(); let mut session = Session::new(); if let Some(p) = &input { session.execute("file.open", json!({"path": p})).expect("open"); @@ -127,7 +130,9 @@ fn main() { app }, ); - harness.run_steps(8); + // The workbook's installed fonts bind once the font scan is done; frames past the first few + // apply them, so the snapshot shows them as the app does. + harness.run_steps(14); let img = harness.render().expect("render"); img.save(&out).expect("save png"); eprintln!("wrote {out} ({}x{})", img.width(), img.height()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 612ca93b2..bfb203c16 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1207,7 +1207,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(ui, &mut d), + "macros" => macros(app, ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1753,7 +1753,7 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { /// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the /// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is /// preserved and not executed. -fn macros(ui: &mut egui::Ui, d: &mut Dialog) { +fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); ui.add_space(6.0); let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); @@ -1761,6 +1761,17 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { ui.weak("This workbook has no scripts or macros."); return; } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for it in &items { let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..590294904 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -102,6 +102,19 @@ pub enum Drag { }, } +/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo +/// another way. It only applies while nothing else changed the workbook (`revision`) and the +/// filled cells are still selected. +#[derive(Clone, Debug, PartialEq)] +pub struct FillTag { + pub src: RangeRef, + pub target: RangeRef, + pub uid: u64, + pub sheet: usize, + pub revision: u64, + pub undo_len: usize, +} + #[derive(Default)] pub struct GridState { pub drag: Drag, @@ -109,6 +122,10 @@ pub struct GridState { /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). pub reveal_moving_end: bool, + /// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when + /// extending), set only by those keys. A move from the scrolled rows or columns into the + /// frozen ones scrolls them back to their top or left. + pub reveal_from: Option, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -120,6 +137,8 @@ pub struct GridState { pub context_menu_frame: u64, /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, + /// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells. + pub fill_tag: Option, /// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, @@ -235,13 +254,15 @@ impl Geo { let frozen_h = (ri.start(fr) as f32) * z; let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols)); - let mut band_w = crate::outline::band(row_outline.levels, zoom); - let mut band_h = crate::outline::band(col_outline.levels, zoom); + // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). + let symbols = sh.show_outline_symbols; + let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; // Without headings the level buttons still need a corner to sit in. - if row_outline.levels > 0 && header_h == 0.0 { + if symbols && row_outline.levels > 0 && header_h == 0.0 { band_h = band_h.max(crate::outline::lane(zoom) + 3.0); } - if col_outline.levels > 0 && header_w == 0.0 { + if symbols && col_outline.levels > 0 && header_w == 0.0 { band_w = band_w.max(crate::outline::lane(zoom) + 3.0); } let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); @@ -681,6 +702,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if app.grid.ensure_visible { app.grid.ensure_visible = false; let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); @@ -694,6 +716,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.bottom() > area.bottom() - 2.0 { view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; } if target.col >= geo.fc { if cr.left() < area.left() { @@ -701,6 +728,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.right() > area.right() - 2.0 { view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; } view.scroll = view.scroll.max(egui::Vec2::ZERO); geo = make_geo(app, view.scroll); @@ -790,6 +819,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, &geo, sh); object_menu(app, ui); error_check_tag(app, ui, &geo, sh); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; @@ -1268,13 +1298,18 @@ pub(crate) fn paint_quadrant( let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); let mut w = galley.size().x; // Numbers and dates never overflow: they show #### when too narrow. - if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { - let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; } - // Repeat fill (`*` in number formats) / Fill alignment. + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. if let Some((ch, pos)) = fill_char + && !hashed && pos <= first_line.len() { let base_w = w; @@ -2364,7 +2399,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - select_ranges(app, RangeRef::cell(c), true, c); + // Ctrl+click 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 + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { @@ -2533,8 +2576,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. let mode = if mods.command { "alternate" } else { "series" }; - if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), } } } @@ -2967,9 +3011,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let cmd = m.command; let shift = m.shift; let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; }; match key { Key::ArrowUp => mv(app, -1, 0), @@ -3317,6 +3363,85 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + if !geo.cells.contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in FILL_MODES { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + /// The menu a right-drag of the fill handle opens on release, as in Excel. fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; @@ -3324,14 +3449,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items = [ - ("Copy Cells", "copy"), - ("Fill Series", "extend"), - ("Fill Formatting Only", "formats"), - ("Fill Without Formatting", "values"), - ("-", ""), - ("Series…", "ui:series"), - ]; + let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { @@ -4216,6 +4334,34 @@ mod pane_tests { assert!(range_in_pane(sel, (2, 20), (2, 10))); } + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } + } + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. fn frozen_geo(sh: &mut Sheet) -> Geo { sh.freeze = Some((2, 2)); diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 254ff68f9..482852d8d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -980,10 +980,163 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Unprotect Sheet…", "シート保護の解除…"), ]; +/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the +/// Portuguese catalog has no entry for them either). Kept apart so the two catalogs above keep the +/// same key set; it never overlaps them. +pub(super) const EXTRA: &[(&str, &str)] = &[ + ("Cut (⌘X)", "切り取り (⌘X)"), + ("Copy (⌘C)", "コピー (⌘C)"), + ("Bold (⌘B)", "太字 (⌘B)"), + ("Italic (⌘I)", "斜体 (⌘I)"), + ("Underline (⌘U)", "下線 (⌘U)"), + ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), + ("Filter (⌘⇧F)", "フィルター (⌘⇧F)"), + ("Flash Fill (⌘E)", "フラッシュ フィル (⌘E)"), + ("Insert Link (⌘K)", "リンクの挿入 (⌘K)"), + ("Paste (⌘V)", "貼り付け (⌘V)"), + ("Redo (⌘Y)", "やり直し (⌘Y)"), + ("Save (⌘S)", "上書き保存 (⌘S)"), + ("Search commands (⌘⇧Space)", "コマンドの検索 (⌘⇧Space)"), + ("Table (⌘T)", "テーブル (⌘T)"), + ("Undo (⌘Z)", "元に戻す (⌘Z)"), + ("New Workbook", "新しいブック"), + ("Save As…", "名前を付けて保存…"), + ("Print…", "印刷…"), + ("Export as PDF…", "PDF としてエクスポート…"), + ("Save a Copy as Excel Workbook (.xlsx)…", "Excel ブック (.xlsx) としてコピーを保存…"), + ("Export as CSV…", "CSV としてエクスポート…"), + ("Export as Web Page (.html)…", "Web ページ (.html) としてエクスポート…"), + ("Bottom Border", "下罫線"), + ("Top Border", "上罫線"), + ("Left Border", "左罫線"), + ("Right Border", "右罫線"), + ("No Border", "枠なし"), + ("All Borders", "格子"), + ("Outside Borders", "外枠"), + ("Thick Outside Borders", "太い外枠"), + ("Bottom Double Border", "下二重罫線"), + ("Thick Bottom Border", "下太罫線"), + ("Top and Bottom Border", "上下罫線"), + ("Top and Thick Bottom Border", "上罫線 + 下太罫線"), + ("Top and Double Bottom Border", "上罫線 + 下二重罫線"), + ("Inside Borders", "内側罫線"), + ("More Borders…", "その他の罫線…"), + ("More Functions…", "その他の関数…"), + ("No Fill", "塗りつぶしなし"), + ("Theme Colors", "テーマの色"), + ("Standard Colors", "標準の色"), + ("Highlight Cells Rules", "セルの強調表示ルール"), + ("Greater Than…", "指定の値より大きい…"), + ("Less Than…", "指定の値より小さい…"), + ("Between…", "指定の範囲内…"), + ("Equal To…", "指定の値に等しい…"), + ("Text that Contains…", "文字列…"), + ("A Date Occurring…", "日付…"), + ("Duplicate Values…", "重複する値…"), + ("Top/Bottom Rules", "上位/下位ルール"), + ("Blue", "青"), + ("Green", "緑"), + ("Red", "赤"), + ("Orange", "オレンジ"), + ("Light Blue", "水色"), + ("Purple", "紫"), + ("Green - Yellow - Red", "緑 - 黄 - 赤"), + ("Red - Yellow - Green", "赤 - 黄 - 緑"), + ("Green - White - Red", "緑 - 白 - 赤"), + ("Blue - White - Red", "青 - 白 - 赤"), + ("White - Green", "白 - 緑"), + ("White - Red", "白 - 赤"), + ("3 Arrows", "3 つの矢印"), + ("3 Traffic Lights", "3 つの信号"), + ("3 Symbols", "3 つの記号"), + ("4 Ratings", "4 段階評価"), + ("5 Arrows", "5 つの矢印"), + ("5 Quarters", "5 つの四分円"), + ("New Rule…", "新しいルール…"), + ("Clear Rules", "ルールのクリア"), + ("Manage Rules…", "ルールの管理…"), + ("Good, Bad and Neutral", "良い、悪い、どちらでもない"), + ("Bad", "悪い"), + ("Good", "良い"), + ("Neutral", "どちらでもない"), + ("Data and Model", "データとモデル"), + ("Calculation", "計算"), + ("Check Cell", "チェック セル"), + ("Explanatory Text", "説明文"), + ("Input", "入力"), + ("Linked Cell", "リンク セル"), + ("Note", "メモ"), + ("Output", "出力"), + ("Warning Text", "警告文"), + ("Titles and Headings", "タイトルと見出し"), + ("Heading 1", "見出し 1"), + ("Heading 2", "見出し 2"), + ("Heading 3", "見出し 3"), + ("Heading 4", "見出し 4"), + ("Total", "集計"), + ("Themed Cell Styles", "テーマのセルのスタイル"), + ("Number Format", "表示形式"), + ("Comma [0]", "桁区切り [0]"), + ("Currency [0]", "通貨表示形式 [0]"), + ("Percent", "パーセント"), + ("Place in Cell", "セル内に配置"), + ("Place over Cells", "セルの上に配置"), + ("Rectangle", "四角形"), + ("Rounded Rectangle", "角丸四角形"), + ("Oval", "楕円"), + ("Triangle", "三角形"), + ("Arrow", "矢印"), + ("Column", "縦棒"), + ("Win/Loss", "勝敗"), + ("Type to filter…", "入力して絞り込み…"), + ("Sort Ascending", "昇順"), + ("Sort Descending", "降順"), + ("Search", "検索"), + ("Apply", "適用"), + ("20% - Accent1", "20% - アクセント 1"), + ("20% - Accent2", "20% - アクセント 2"), + ("20% - Accent3", "20% - アクセント 3"), + ("20% - Accent4", "20% - アクセント 4"), + ("20% - Accent5", "20% - アクセント 5"), + ("20% - Accent6", "20% - アクセント 6"), + ("40% - Accent1", "40% - アクセント 1"), + ("40% - Accent2", "40% - アクセント 2"), + ("40% - Accent3", "40% - アクセント 3"), + ("40% - Accent4", "40% - アクセント 4"), + ("40% - Accent5", "40% - アクセント 5"), + ("40% - Accent6", "40% - アクセント 6"), + ("60% - Accent1", "60% - アクセント 1"), + ("60% - Accent2", "60% - アクセント 2"), + ("60% - Accent3", "60% - アクセント 3"), + ("60% - Accent4", "60% - アクセント 4"), + ("60% - Accent5", "60% - アクセント 5"), + ("60% - Accent6", "60% - アクセント 6"), + ("Accent1", "アクセント 1"), + ("Accent2", "アクセント 2"), + ("Accent3", "アクセント 3"), + ("Accent4", "アクセント 4"), + ("Accent5", "アクセント 5"), + ("Accent6", "アクセント 6"), + ("Recent Colors", "最近使用した色"), + ("More Colors…", "その他の色…"), + ("Hex", "16進"), + ("Page Break Preview", "改ページ プレビュー"), + ("Bubble", "バブル"), + ("Box & Whisker", "箱ひげ図"), + ("Treemap", "ツリーマップ"), + ("Sunburst", "サンバースト"), + ("Stock", "株価"), + ("Bar", "横棒"), + ( + "Drag to fill cells. Drag from inside a cell to select a range.", + "ドラッグしてセルをオートフィルします。セルの内側からドラッグすると範囲を選択します。", + ), +]; + /// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can /// fall back to English. Dialog strings come first, the shared core second. pub(super) fn translate(text: &str) -> Option<&'static str> { - DIALOGS.iter().chain(CORE).find(|(english, _)| *english == text).map(|(_, translated)| *translated) + DIALOGS.iter().chain(CORE).chain(EXTRA).find(|(english, _)| *english == text).map(|(_, translated)| *translated) } #[cfg(test)] @@ -1088,6 +1241,18 @@ mod tests { assert_eq!(catalog_keys().count(), 954); } + #[test] + fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + let catalog: HashSet<&str> = catalog_keys().collect(); + let mut seen = HashSet::new(); + for (english, japanese) in EXTRA { + assert!(!japanese.trim().is_empty(), "empty translation for {english:?}"); + assert!(!catalog.contains(english), "{english:?} is already in CORE or DIALOGS"); + assert!(seen.insert(*english), "duplicate EXTRA key: {english:?}"); + assert_eq!(translate(english), Some(*japanese)); + } + } + #[test] fn covers_every_key_the_portuguese_catalog_translates() { let portuguese = portuguese_keys(); diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 699e30ff3..9e0d92516 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}", "Print failed: {error}" => "Imprimarea a eșuat: {error}", "Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.", - "Options|Editing" => "Editing", + "Options|Editing" => "Editare", + "Duplicate" => "Duplicare", + "Rename" => "Redenumire", + "Product Information" => "Informații despre produs", + "Tab Color" => "Culoare filă", + "Select All Sheets" => "Selectare toate foile", + "Fit selection" => "Încadrare selecție", + "All sheets" => "Toate foile", + "Version" => "Versiune", + "Unhide…" => "Reafișare…", + "Platform" => "Platformă", + "Unprotect Sheet…" => "Deprotejare foaie…", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)" + } + "Licence" => "Licență", + "Account" => "Cont", + "No Color" => "Fără culoare", + "Move or Copy…" => "Mutare sau copiere…", + "Number Stored as Text" => "Număr stocat ca text", + "Select Data…" => "Selectare date…", + "Convert to Number" => "Conversie în număr", + "Format Chart Area…" => "Formatare zonă diagramă…", + "Error checking options" => "Opțiuni de verificare a erorilor", + "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", _ => return None, }) } diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index fea791b69..5840036a1 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0); let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64; let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; + let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = m.shift; + app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 61c40811c..19fd88497 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -224,6 +224,10 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { /// The button under `p` (logical coordinates), if any. pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option { + // Hidden symbols (or no outline) leave empty bands: a click on their edge is a cell click. + if geo.band_w <= 0.0 && geo.band_h <= 0.0 { + return None; + } let (rows, cols) = bands(geo); if !rows.contains(p) && !cols.contains(p) { return None; diff --git a/crates/ui-egui/tests/it/accounting_fill.rs b/crates/ui-egui/tests/it/accounting_fill.rs new file mode 100644 index 000000000..492f581cd --- /dev/null +++ b/crates/ui-egui/tests/it/accounting_fill.rs @@ -0,0 +1,77 @@ +//! Repeat fill (`*` in a number format, as in the Accounting format): a number that fits is drawn +//! across its cell, and one too wide for its column shows #### instead of losing its leading digits. + +use egui::Shape; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const ACCOUNTING: &str = r#"_-* #,##0.00\ "CHF"_-;\-* #,##0.00\ "CHF"_-;_-* "-"??\ "CHF"_-;_-@_-"#; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B2", "values": [[344.75, 344.75], [2509, 2509]]})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1:B2", "style": {"num_fmt": ACCOUNTING}})).unwrap(); + // Column A is too narrow for 2,509.00 CHF; column B fits both. + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap(); + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +/// Painted text shapes whose text satisfies `f`: (text, left x, width). +fn texts(h: &Harness, f: impl Fn(&str) -> bool) -> Vec<(String, f32, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if f(&t.galley.job.text) => Some((t.galley.job.text.clone(), t.pos.x, t.galley.size().x)), + _ => None, + }) + .collect() +} + +#[test] +fn too_wide_fill_number_shows_hashes_not_a_clipped_value() { + let h = harness(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let a2 = geo.cell_rect(sheet, CellRef::parse("A2").unwrap()); + let b2 = geo.cell_rect(sheet, CellRef::parse("B2").unwrap()); + // A2 is drawn as #### inside its cell. + let hashes = texts(&h, |t| !t.is_empty() && t.chars().all(|c| c == '#')); + assert!(hashes.iter().any(|(_, x, w)| *x >= a2.left() && x + w <= a2.right() + 0.5), "A2 shows ####: {hashes:?}"); + // The only 2,509.00 drawn is B2's, whole and inside column B. + let values = texts(&h, |t| t.contains("509.00")); + assert_eq!(values.len(), 1, "{values:?}"); + let (text, x, w) = &values[0]; + assert!(text.contains("2,509.00 CHF"), "{text:?}"); + assert!(*x >= b2.left() && x + w <= b2.right() + 0.5, "B2's value stays in its cell: {values:?}"); +} + +#[test] +fn fill_spreads_a_fitting_number_across_its_cell() { + let h = harness(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let b1 = geo.cell_rect(sheet, CellRef::parse("B1").unwrap()); + let drawn = texts(&h, |t| t.contains("344.75 CHF")); + let (_, x, w) = drawn.iter().find(|(_, x, _)| *x >= b1.left()).expect("B1 is drawn"); + // The fill pushes the value to the right edge (less the trailing `_-` space). + assert!(b1.right() - (x + w) < 6.0, "B1 ends at its right edge: {drawn:?} in {b1:?}"); + assert!(*x - b1.left() < 6.0, "the fill starts at the left edge: {drawn:?} in {b1:?}"); +} diff --git a/crates/ui-egui/tests/advanced_filter.rs b/crates/ui-egui/tests/it/advanced_filter.rs similarity index 100% rename from crates/ui-egui/tests/advanced_filter.rs rename to crates/ui-egui/tests/it/advanced_filter.rs diff --git a/crates/ui-egui/tests/argument_names.rs b/crates/ui-egui/tests/it/argument_names.rs similarity index 100% rename from crates/ui-egui/tests/argument_names.rs rename to crates/ui-egui/tests/it/argument_names.rs diff --git a/crates/ui-egui/tests/autosave_persist.rs b/crates/ui-egui/tests/it/autosave_persist.rs similarity index 100% rename from crates/ui-egui/tests/autosave_persist.rs rename to crates/ui-egui/tests/it/autosave_persist.rs diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/it/borders.rs similarity index 100% rename from crates/ui-egui/tests/borders.rs rename to crates/ui-egui/tests/it/borders.rs diff --git a/crates/ui-egui/tests/cell_editor_caret.rs b/crates/ui-egui/tests/it/cell_editor_caret.rs similarity index 100% rename from crates/ui-egui/tests/cell_editor_caret.rs rename to crates/ui-egui/tests/it/cell_editor_caret.rs diff --git a/crates/ui-egui/tests/cell_pictures.rs b/crates/ui-egui/tests/it/cell_pictures.rs similarity index 100% rename from crates/ui-egui/tests/cell_pictures.rs rename to crates/ui-egui/tests/it/cell_pictures.rs diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs similarity index 100% rename from crates/ui-egui/tests/chart_resize.rs rename to crates/ui-egui/tests/it/chart_resize.rs diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs similarity index 100% rename from crates/ui-egui/tests/clipboard.rs rename to crates/ui-egui/tests/it/clipboard.rs diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/it/comments_pane.rs similarity index 100% rename from crates/ui-egui/tests/comments_pane.rs rename to crates/ui-egui/tests/it/comments_pane.rs diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/it/commit_after_pointing.rs similarity index 100% rename from crates/ui-egui/tests/commit_after_pointing.rs rename to crates/ui-egui/tests/it/commit_after_pointing.rs diff --git a/crates/ui-egui/tests/committed_text_fields.rs b/crates/ui-egui/tests/it/committed_text_fields.rs similarity index 100% rename from crates/ui-egui/tests/committed_text_fields.rs rename to crates/ui-egui/tests/it/committed_text_fields.rs diff --git a/crates/ui-egui/tests/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs similarity index 100% rename from crates/ui-egui/tests/context_menu_stays_open.rs rename to crates/ui-egui/tests/it/context_menu_stays_open.rs diff --git a/crates/ui-egui/tests/control_timeout.rs b/crates/ui-egui/tests/it/control_timeout.rs similarity index 100% rename from crates/ui-egui/tests/control_timeout.rs rename to crates/ui-egui/tests/it/control_timeout.rs diff --git a/crates/ui-egui/tests/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs similarity index 100% rename from crates/ui-egui/tests/dialog_button_row.rs rename to crates/ui-egui/tests/it/dialog_button_row.rs diff --git a/crates/ui-egui/tests/dialog_confirm.rs b/crates/ui-egui/tests/it/dialog_confirm.rs similarity index 100% rename from crates/ui-egui/tests/dialog_confirm.rs rename to crates/ui-egui/tests/it/dialog_confirm.rs diff --git a/crates/ui-egui/tests/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/dialog_focus.rs rename to crates/ui-egui/tests/it/dialog_focus.rs diff --git a/crates/ui-egui/tests/dialog_move.rs b/crates/ui-egui/tests/it/dialog_move.rs similarity index 100% rename from crates/ui-egui/tests/dialog_move.rs rename to crates/ui-egui/tests/it/dialog_move.rs diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs similarity index 100% rename from crates/ui-egui/tests/drag_cursor.rs rename to crates/ui-egui/tests/it/drag_cursor.rs diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs similarity index 100% rename from crates/ui-egui/tests/file_menu.rs rename to crates/ui-egui/tests/it/file_menu.rs diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/it/fill_handle_double_click.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_double_click.rs rename to crates/ui-egui/tests/it/fill_handle_double_click.rs diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/it/fill_handle_right_drag.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_right_drag.rs rename to crates/ui-egui/tests/it/fill_handle_right_drag.rs diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs new file mode 100644 index 000000000..2cb209fa9 --- /dev/null +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -0,0 +1,211 @@ +//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells, +//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403). + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.mirror_pos(geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0)) +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.screen_cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn left_drag_shows_the_auto_fill_options_button() { + let mut h = harness(one_two); + assert!(h.query_by_label("Auto Fill Options").is_none()); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B6"]), [n(3.0), n(6.0)]); + assert!(h.query_by_label("Auto Fill Options").is_some()); +} + +#[test] +fn copy_cells_replaces_the_series_the_drag_made() { + let mut h = harness(one_two); + let steps_before = h.state().session.active().unwrap().undo.len(); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert_eq!(selection(&h), "B1:B6"); + assert_eq!(h.state().session.active().unwrap().undo.len(), steps_before + 1, "one fill step, not two"); +} + +#[test] +fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Primary); + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(5.0), n(5.0), n(5.0)]); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_without_formatting_keeps_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4")); + assert!(italic(&h, "B4")); +} + +#[test] +fn the_button_goes_away_once_the_selection_moves() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + let away = center(&h, "E9"); + h.input_mut().events.push(Event::PointerMoved(away)); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn right_drag_makes_no_button() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn the_button_goes_away_once_the_workbook_changes() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + h.state_mut().session.execute("cell.set", json!({"cell": "D1", "input": "x"})).unwrap(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:B6", "the selection did not move"); + assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); +} + +#[test] +fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { + let mut h = harness(|s| { + one_two(s); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let button = h.get_by_label("Auto Fill Options").rect(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert!(geo.cells.contains_rect(button), "{button:?} outside {:?}", geo.cells); + let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); + assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); +} diff --git a/crates/ui-egui/tests/filter_list_scroll.rs b/crates/ui-egui/tests/it/filter_list_scroll.rs similarity index 100% rename from crates/ui-egui/tests/filter_list_scroll.rs rename to crates/ui-egui/tests/it/filter_list_scroll.rs diff --git a/crates/ui-egui/tests/filter_menu_enter.rs b/crates/ui-egui/tests/it/filter_menu_enter.rs similarity index 100% rename from crates/ui-egui/tests/filter_menu_enter.rs rename to crates/ui-egui/tests/it/filter_menu_enter.rs diff --git a/crates/ui-egui/tests/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/find_dialog_focus.rs rename to crates/ui-egui/tests/it/find_dialog_focus.rs diff --git a/crates/ui-egui/tests/floating_pictures.rs b/crates/ui-egui/tests/it/floating_pictures.rs similarity index 100% rename from crates/ui-egui/tests/floating_pictures.rs rename to crates/ui-egui/tests/it/floating_pictures.rs diff --git a/crates/ui-egui/tests/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs similarity index 100% rename from crates/ui-egui/tests/font_size_box.rs rename to crates/ui-egui/tests/it/font_size_box.rs diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/it/format_painter.rs similarity index 100% rename from crates/ui-egui/tests/format_painter.rs rename to crates/ui-egui/tests/it/format_painter.rs diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs similarity index 100% rename from crates/ui-egui/tests/formula_locale.rs rename to crates/ui-egui/tests/it/formula_locale.rs diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs similarity index 100% rename from crates/ui-egui/tests/formula_point_headers.rs rename to crates/ui-egui/tests/it/formula_point_headers.rs diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/it/formula_popup.rs similarity index 100% rename from crates/ui-egui/tests/formula_popup.rs rename to crates/ui-egui/tests/it/formula_popup.rs diff --git a/crates/ui-egui/tests/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs similarity index 100% rename from crates/ui-egui/tests/freeze_handles.rs rename to crates/ui-egui/tests/it/freeze_handles.rs diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs similarity index 100% rename from crates/ui-egui/tests/header_autofit.rs rename to crates/ui-egui/tests/it/header_autofit.rs diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/it/header_column_drag.rs similarity index 100% rename from crates/ui-egui/tests/header_column_drag.rs rename to crates/ui-egui/tests/it/header_column_drag.rs diff --git a/crates/ui-egui/tests/host_chrome.rs b/crates/ui-egui/tests/it/host_chrome.rs similarity index 100% rename from crates/ui-egui/tests/host_chrome.rs rename to crates/ui-egui/tests/it/host_chrome.rs diff --git a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs new file mode 100644 index 000000000..2e557e12e --- /dev/null +++ b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs @@ -0,0 +1,85 @@ +//! Ctrl+click on a hyperlink cell follows the link (#1198), like a plain click does. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "B2", "input": "web"})).unwrap(); + session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap(); + session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap(); + session.execute("insert.link", json!({"cell": "B4", "target": "Sheet1!F9"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn at_fraction(h: &Harness, cell: &str, fraction: f32) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let r = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()); + egui::pos2(r.left() + r.width() * fraction, r.center().y) +} + +fn click(h: &mut Harness, cell: &str, modifiers: Modifiers) { + click_at(h, cell, 0.3, modifiers); +} + +fn click_at(h: &mut Harness, cell: &str, fraction: f32, modifiers: Modifiers) { + let pos = at_fraction(h, cell, fraction); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.extend([ + 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(1); +} + +#[test] +fn ctrl_click_on_a_web_link_asks_to_open_it() { + let mut h = harness(); + click(&mut h, "B2", Modifiers::COMMAND); + let d = h.state().dialog.as_ref().expect("Ctrl+click on a web link opens the confirmation"); + assert_eq!(d.name, "openLink"); +} + +#[test] +fn ctrl_click_on_an_internal_link_goes_there() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} + +#[test] +fn plain_click_still_follows() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} + +#[test] +fn ctrl_click_beside_the_link_text_still_extends_the_selection() { + let mut h = harness(); + click_at(&mut h, "B2", 0.9, Modifiers::NONE); + click_at(&mut h, "B4", 0.9, Modifiers::COMMAND); + let d = h.state().session.active().unwrap(); + let ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection"); + assert!(h.state().dialog.is_none(), "no link was followed"); +} diff --git a/crates/ui-egui/tests/in_cell_editor_size.rs b/crates/ui-egui/tests/it/in_cell_editor_size.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_size.rs rename to crates/ui-egui/tests/it/in_cell_editor_size.rs diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/it/in_cell_editor_wrap.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_wrap.rs rename to crates/ui-egui/tests/it/in_cell_editor_wrap.rs diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/it/insert_copied_cells.rs similarity index 100% rename from crates/ui-egui/tests/insert_copied_cells.rs rename to crates/ui-egui/tests/it/insert_copied_cells.rs diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/it/interaction.rs similarity index 100% rename from crates/ui-egui/tests/interaction.rs rename to crates/ui-egui/tests/it/interaction.rs diff --git a/crates/ui-egui/tests/interface_scale.rs b/crates/ui-egui/tests/it/interface_scale.rs similarity index 100% rename from crates/ui-egui/tests/interface_scale.rs rename to crates/ui-egui/tests/it/interface_scale.rs diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/keyboard_shortcuts.rs rename to crates/ui-egui/tests/it/keyboard_shortcuts.rs diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs similarity index 100% rename from crates/ui-egui/tests/keytips_generated.rs rename to crates/ui-egui/tests/it/keytips_generated.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..b89253bb9 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,91 @@ +//! The crate's integration tests, one module per file, built as one test binary. +//! +//! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state +//! (the installed-font catalog, a registered test command) that would leak into these tests. + +mod accounting_fill; +mod advanced_filter; +mod argument_names; +mod autosave_persist; +mod borders; +mod cell_editor_caret; +mod cell_pictures; +mod chart_resize; +mod clipboard; +mod comments_pane; +mod commit_after_pointing; +mod committed_text_fields; +mod context_menu_stays_open; +mod control_timeout; +mod dialog_button_row; +mod dialog_confirm; +mod dialog_focus; +mod dialog_move; +mod drag_cursor; +mod file_menu; +mod fill_handle_double_click; +mod fill_handle_right_drag; +mod fill_options_button; +mod filter_list_scroll; +mod filter_menu_enter; +mod find_dialog_focus; +mod floating_pictures; +mod font_size_box; +mod format_painter; +mod formula_locale; +mod formula_point_headers; +mod formula_popup; +mod freeze_handles; +mod header_autofit; +mod header_column_drag; +mod host_chrome; +mod hyperlink_ctrl_click; +mod in_cell_editor_size; +mod in_cell_editor_wrap; +mod insert_copied_cells; +mod interaction; +mod interface_scale; +mod keyboard_shortcuts; +mod keytips_generated; +mod menu_hover; +mod merged_cells; +mod more_colors; +mod native_print; +mod nowrap_line_breaks; +mod number_stored_as_text; +mod open_encrypted; +mod outline; +mod page_layout_view; +mod page_setup_margins; +mod paste_special_dialog; +mod pivot_pane; +mod popup_dismissal; +mod protect_password; +mod right_to_left; +mod scroll_position; +mod scrollbar_column_fill; +mod selection; +mod selection_fill; +mod selection_glide; +mod selection_scroll; +mod sheet_navigator; +mod sheet_tab_rename; +mod sheet_tabs; +mod show_objects; +mod shrink_to_fit; +mod sort_dialog_columns; +mod symbol_glyphs; +mod system_theme; +mod table_headings; +mod text_box_editing; +mod text_overflow_scroll; +mod touch_pan; +mod trace_navigation; +mod translated_form_fields; +mod ui_clock; +mod validation_alert; +mod validation_input_message; +mod wheel_zoom; +mod whole_line_shortcuts; +mod widgets; +mod zoom_dialog; diff --git a/crates/ui-egui/tests/menu_hover.rs b/crates/ui-egui/tests/it/menu_hover.rs similarity index 100% rename from crates/ui-egui/tests/menu_hover.rs rename to crates/ui-egui/tests/it/menu_hover.rs diff --git a/crates/ui-egui/tests/merged_cells.rs b/crates/ui-egui/tests/it/merged_cells.rs similarity index 100% rename from crates/ui-egui/tests/merged_cells.rs rename to crates/ui-egui/tests/it/merged_cells.rs diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/it/more_colors.rs similarity index 100% rename from crates/ui-egui/tests/more_colors.rs rename to crates/ui-egui/tests/it/more_colors.rs diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/it/native_print.rs similarity index 100% rename from crates/ui-egui/tests/native_print.rs rename to crates/ui-egui/tests/it/native_print.rs diff --git a/crates/ui-egui/tests/nowrap_line_breaks.rs b/crates/ui-egui/tests/it/nowrap_line_breaks.rs similarity index 100% rename from crates/ui-egui/tests/nowrap_line_breaks.rs rename to crates/ui-egui/tests/it/nowrap_line_breaks.rs diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/it/number_stored_as_text.rs similarity index 100% rename from crates/ui-egui/tests/number_stored_as_text.rs rename to crates/ui-egui/tests/it/number_stored_as_text.rs diff --git a/crates/ui-egui/tests/open_encrypted.rs b/crates/ui-egui/tests/it/open_encrypted.rs similarity index 100% rename from crates/ui-egui/tests/open_encrypted.rs rename to crates/ui-egui/tests/it/open_encrypted.rs diff --git a/crates/ui-egui/tests/outline.rs b/crates/ui-egui/tests/it/outline.rs similarity index 100% rename from crates/ui-egui/tests/outline.rs rename to crates/ui-egui/tests/it/outline.rs diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs similarity index 100% rename from crates/ui-egui/tests/page_layout_view.rs rename to crates/ui-egui/tests/it/page_layout_view.rs diff --git a/crates/ui-egui/tests/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs similarity index 100% rename from crates/ui-egui/tests/page_setup_margins.rs rename to crates/ui-egui/tests/it/page_setup_margins.rs diff --git a/crates/ui-egui/tests/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs similarity index 100% rename from crates/ui-egui/tests/paste_special_dialog.rs rename to crates/ui-egui/tests/it/paste_special_dialog.rs diff --git a/crates/ui-egui/tests/pivot_pane.rs b/crates/ui-egui/tests/it/pivot_pane.rs similarity index 100% rename from crates/ui-egui/tests/pivot_pane.rs rename to crates/ui-egui/tests/it/pivot_pane.rs diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/it/popup_dismissal.rs similarity index 100% rename from crates/ui-egui/tests/popup_dismissal.rs rename to crates/ui-egui/tests/it/popup_dismissal.rs diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/it/protect_password.rs similarity index 100% rename from crates/ui-egui/tests/protect_password.rs rename to crates/ui-egui/tests/it/protect_password.rs diff --git a/crates/ui-egui/tests/right_to_left.rs b/crates/ui-egui/tests/it/right_to_left.rs similarity index 100% rename from crates/ui-egui/tests/right_to_left.rs rename to crates/ui-egui/tests/it/right_to_left.rs diff --git a/crates/ui-egui/tests/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs similarity index 100% rename from crates/ui-egui/tests/scroll_position.rs rename to crates/ui-egui/tests/it/scroll_position.rs diff --git a/crates/ui-egui/tests/scrollbar_column_fill.rs b/crates/ui-egui/tests/it/scrollbar_column_fill.rs similarity index 100% rename from crates/ui-egui/tests/scrollbar_column_fill.rs rename to crates/ui-egui/tests/it/scrollbar_column_fill.rs diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/it/selection.rs similarity index 100% rename from crates/ui-egui/tests/selection.rs rename to crates/ui-egui/tests/it/selection.rs diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/it/selection_fill.rs similarity index 100% rename from crates/ui-egui/tests/selection_fill.rs rename to crates/ui-egui/tests/it/selection_fill.rs diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/it/selection_glide.rs similarity index 100% rename from crates/ui-egui/tests/selection_glide.rs rename to crates/ui-egui/tests/it/selection_glide.rs diff --git a/crates/ui-egui/tests/it/selection_scroll.rs b/crates/ui-egui/tests/it/selection_scroll.rs new file mode 100644 index 000000000..9a78f9b9f --- /dev/null +++ b/crates/ui-egui/tests/it/selection_scroll.rs @@ -0,0 +1,292 @@ +//! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. +//! Go To and Select over the control channel scroll the active cell into view. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::control::{Outcome, handle}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl on Windows and Linux sets both `ctrl` and `command`. +const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL }; + +/// Column A filled down to A200 and row 1 filled across to CV1, far past one screen each way. +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let down: Vec> = (1..=200).map(|r| vec![r]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": down})).unwrap(); + let across: Vec = (1..=100).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn key(h: &mut Harness, k: Key, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +/// Whether the cell's centre is on screen (a whole cell can sit a few pixels under the edge when +/// scrolling far down widens the row headings). +fn visible(h: &Harness, a1: &str) -> bool { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cells.contains(geo.cell_rect(sheet, CellRef::parse(a1).unwrap()).center()) +} + +fn assert_extended(h: &Harness, anchor: &str, moving_end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let (anchor_cell, end_cell) = (CellRef::parse(anchor).unwrap(), CellRef::parse(moving_end).unwrap()); + assert_eq!(selection.current(), RangeRef::new(anchor_cell, end_cell)); + assert_eq!(selection.anchor, anchor_cell); + assert_eq!(selection.active, anchor_cell, "extending keeps the active cell on the anchor"); + assert!(visible(h, moving_end), "the moving end {moving_end} is scrolled into view"); +} + +#[test] +fn ctrl_shift_arrows_scroll_the_moving_end_into_view() { + // Down, then back Up past the top once the view has scrolled. + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL_SHIFT); + assert_extended(&h, "A1", "A200"); + assert!(!visible(&h, "A1"), "the view follows the moving end, away from the anchor"); + let mut h = harness(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(visible(&h, "A200") && !visible(&h, "A1")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + // Right, then back Left. + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL_SHIFT); + assert_extended(&h, "A1", "CV1"); + let mut h = harness(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(visible(&h, "CV1") && !visible(&h, "A1")); + key(&mut h, Key::ArrowLeft, CTRL_SHIFT); + assert_extended(&h, "CV1", "A1"); +} + +#[test] +fn shift_arrows_scroll_the_moving_end_into_view() { + let mut h = harness(); + for _ in 0..60 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "A61"); + for _ in 0..30 { + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + } + assert_extended(&h, "A1", "AE61"); +} + +#[test] +fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { + let mut h = harness(); + key(&mut h, Key::End, CTRL_SHIFT); + assert_extended(&h, "A1", "CV200"); + let mut h = harness(); + key(&mut h, Key::End, CTRL); + assert!(!visible(&h, "A1")); + key(&mut h, Key::Home, CTRL_SHIFT); + assert_extended(&h, "CV200", "A1"); +} + +fn control(h: &mut Harness, method: &str, params: Json) -> Json { + let ctx = h.ctx.clone(); + let out = match handle(h.state_mut(), &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + h.run_steps(2); + out +} + +fn active(h: &Harness) -> CellRef { + h.state().session.active().unwrap().selection.active +} + +#[test] +fn go_to_over_the_control_channel_scrolls_the_target_into_view() { + let mut h = harness(); + assert!(!visible(&h, "Z300")); + let r = control(&mut h, "engine.execute", json!({"command": "edit.goTo", "params": {"reference": "Z300"}})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(active(&h), CellRef::parse("Z300").unwrap()); + assert!(visible(&h, "Z300"), "Go To scrolls Z300 into view"); + assert!(!visible(&h, "A1")); + // A command named as the method runs the same way. + control(&mut h, "edit.goTo", json!({"reference": "A1"})); + assert!(visible(&h, "A1") && !visible(&h, "Z300")); +} + +#[test] +fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { + let mut h = harness(); + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"range": "BZ150:CA160"}})); + assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); + assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); +} + +fn scroll(h: &Harness) -> egui::Vec2 { + h.state().view().scroll +} + +#[test] +fn page_down_and_back_up_from_row_10_shows_rows_2_to_9_again() { + // The reported steps: row 1 frozen, row 10 selected, Page Down, Page Up, Page Up. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A10"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::PageDown, Modifiers::NONE); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A10").unwrap()); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2") && visible(&h, "A9")); +} + +#[test] +fn page_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + // Row 1 frozen: Page Up from far down ends on A1, and rows 2 onwards show under it again + // instead of the view staying a page down. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::NONE); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0, "the scrolled rows are back at the top"); + assert!(visible(&h, "A2")); + // Ctrl+Up from the bottom lands on A1 the same way. + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert!(visible(&h, "A2")); +} + +#[test] +fn ctrl_left_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + key(&mut h, Key::ArrowLeft, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn moving_within_a_frozen_row_leaves_the_scrolled_rows_where_they_are() { + // Selecting in row 1 while the rows below are scrolled down (without a reveal, as a click + // does), then moving along the frozen row, must not jump the view back to the top. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + assert!(down > 0.0); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down, "the view stays scrolled down"); +} + +#[test] +fn alt_page_up_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::ALT); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn extending_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + assert!(visible(&h, "A2")); +} + +#[test] +fn other_reveals_onto_a_frozen_cell_leave_the_scrolled_rows_where_they_are() { + // Only arrow and paging keys bring the scrolled rows back; selecting a header cell from the + // control channel (as Find, Go To or Undo would) keeps the place the user scrolled to. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"cell": "B1"}})); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down); +} + +#[test] +fn pointing_along_a_frozen_row_keeps_the_edited_cell_in_view() { + // Editing a formula far down and pointing at header cells with the arrows must not scroll + // the cell being edited away. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + let ed = h.state_mut().editor.as_mut().expect("editing"); + ed.caret = ed.text.chars().count(); + ed.point_cell = Some(CellRef::parse("B1").unwrap()); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.point_cell), Some(CellRef::parse("C1").unwrap()), "the arrow moved the point"); + assert_eq!(scroll(&h).y, down); + assert!(visible(&h, "A200"), "the edited cell stays in view"); +} + +#[test] +fn down_from_a_frozen_header_row_goes_to_row_2_at_the_top_from_far_down() { + // Excel's quick way back to the top: select a cell in the frozen header row while far down, + // press Down, and the view jumps to row 2 under the header. Selecting the header cell alone + // doesn't scroll. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A100000"})).unwrap(); + h.state_mut().grid.ensure_visible = true; + h.run_steps(2); + assert!(!visible(&h, "A2") && visible(&h, "A100000")); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + assert!(visible(&h, "A100000")); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A2").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2")); +} diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/it/sheet_navigator.rs similarity index 100% rename from crates/ui-egui/tests/sheet_navigator.rs rename to crates/ui-egui/tests/it/sheet_navigator.rs diff --git a/crates/ui-egui/tests/sheet_tab_rename.rs b/crates/ui-egui/tests/it/sheet_tab_rename.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tab_rename.rs rename to crates/ui-egui/tests/it/sheet_tab_rename.rs diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/it/sheet_tabs.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tabs.rs rename to crates/ui-egui/tests/it/sheet_tabs.rs diff --git a/crates/ui-egui/tests/show_objects.rs b/crates/ui-egui/tests/it/show_objects.rs similarity index 100% rename from crates/ui-egui/tests/show_objects.rs rename to crates/ui-egui/tests/it/show_objects.rs diff --git a/crates/ui-egui/tests/shrink_to_fit.rs b/crates/ui-egui/tests/it/shrink_to_fit.rs similarity index 100% rename from crates/ui-egui/tests/shrink_to_fit.rs rename to crates/ui-egui/tests/it/shrink_to_fit.rs diff --git a/crates/ui-egui/tests/sort_dialog_columns.rs b/crates/ui-egui/tests/it/sort_dialog_columns.rs similarity index 100% rename from crates/ui-egui/tests/sort_dialog_columns.rs rename to crates/ui-egui/tests/it/sort_dialog_columns.rs diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/it/symbol_glyphs.rs similarity index 100% rename from crates/ui-egui/tests/symbol_glyphs.rs rename to crates/ui-egui/tests/it/symbol_glyphs.rs diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/it/system_theme.rs similarity index 100% rename from crates/ui-egui/tests/system_theme.rs rename to crates/ui-egui/tests/it/system_theme.rs diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/it/table_headings.rs similarity index 100% rename from crates/ui-egui/tests/table_headings.rs rename to crates/ui-egui/tests/it/table_headings.rs diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/it/text_box_editing.rs similarity index 100% rename from crates/ui-egui/tests/text_box_editing.rs rename to crates/ui-egui/tests/it/text_box_editing.rs diff --git a/crates/ui-egui/tests/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs similarity index 100% rename from crates/ui-egui/tests/text_overflow_scroll.rs rename to crates/ui-egui/tests/it/text_overflow_scroll.rs diff --git a/crates/ui-egui/tests/touch_pan.rs b/crates/ui-egui/tests/it/touch_pan.rs similarity index 100% rename from crates/ui-egui/tests/touch_pan.rs rename to crates/ui-egui/tests/it/touch_pan.rs diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/it/trace_navigation.rs similarity index 100% rename from crates/ui-egui/tests/trace_navigation.rs rename to crates/ui-egui/tests/it/trace_navigation.rs diff --git a/crates/ui-egui/tests/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs similarity index 100% rename from crates/ui-egui/tests/translated_form_fields.rs rename to crates/ui-egui/tests/it/translated_form_fields.rs diff --git a/crates/ui-egui/tests/ui_clock.rs b/crates/ui-egui/tests/it/ui_clock.rs similarity index 100% rename from crates/ui-egui/tests/ui_clock.rs rename to crates/ui-egui/tests/it/ui_clock.rs diff --git a/crates/ui-egui/tests/validation_alert.rs b/crates/ui-egui/tests/it/validation_alert.rs similarity index 100% rename from crates/ui-egui/tests/validation_alert.rs rename to crates/ui-egui/tests/it/validation_alert.rs diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/it/validation_input_message.rs similarity index 100% rename from crates/ui-egui/tests/validation_input_message.rs rename to crates/ui-egui/tests/it/validation_input_message.rs diff --git a/crates/ui-egui/tests/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs similarity index 100% rename from crates/ui-egui/tests/wheel_zoom.rs rename to crates/ui-egui/tests/it/wheel_zoom.rs diff --git a/crates/ui-egui/tests/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/whole_line_shortcuts.rs rename to crates/ui-egui/tests/it/whole_line_shortcuts.rs diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/it/widgets.rs similarity index 100% rename from crates/ui-egui/tests/widgets.rs rename to crates/ui-egui/tests/it/widgets.rs diff --git a/crates/ui-egui/tests/zoom_dialog.rs b/crates/ui-egui/tests/it/zoom_dialog.rs similarity index 100% rename from crates/ui-egui/tests/zoom_dialog.rs rename to crates/ui-egui/tests/it/zoom_dialog.rs diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs deleted file mode 100644 index 719f1a0f2..000000000 --- a/crates/ui-egui/tests/selection_scroll.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Extending the selection from the keyboard scrolls its moving end into view, not the anchor. -//! Go To and Select over the control channel scroll the active cell into view. - -use egui::{Event, Key, Modifiers}; -use gridcraft_engine::Session; -use gridcraft_engine::core::{CellRef, RangeRef}; -use gridcraft_ui_egui::control::{Outcome, handle}; -use gridcraft_ui_egui::{SheetApp, grid::Geo}; -use serde_json::{Value as Json, json}; - -type Harness = egui_kittest::Harness<'static, SheetApp>; - -/// Ctrl on Windows and Linux sets both `ctrl` and `command`. -const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; -const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL }; - -/// Column A filled down to A200 and row 1 filled across to CV1, far past one screen each way. -fn harness() -> Harness { - let mut s = Session::new(); - s.new_workbook(); - let down: Vec> = (1..=200).map(|r| vec![r]).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": down})).unwrap(); - let across: Vec = (1..=100).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); - s.execute("selection.set", json!({"cell": "A1"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(s, Default::default()), - ); - h.run_steps(4); - h -} - -fn key(h: &mut Harness, k: Key, m: Modifiers) { - h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m }); - h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m }); - h.run_steps(2); -} - -/// Whether the cell's centre is on screen (a whole cell can sit a few pixels under the edge when -/// scrolling far down widens the row headings). -fn visible(h: &Harness, a1: &str) -> bool { - let app = h.state(); - let sheet = app.session.active().unwrap().wb.active().unwrap(); - let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); - geo.cells.contains(geo.cell_rect(sheet, CellRef::parse(a1).unwrap()).center()) -} - -fn assert_extended(h: &Harness, anchor: &str, moving_end: &str) { - let selection = &h.state().session.active().unwrap().selection; - let (anchor_cell, end_cell) = (CellRef::parse(anchor).unwrap(), CellRef::parse(moving_end).unwrap()); - assert_eq!(selection.current(), RangeRef::new(anchor_cell, end_cell)); - assert_eq!(selection.anchor, anchor_cell); - assert_eq!(selection.active, anchor_cell, "extending keeps the active cell on the anchor"); - assert!(visible(h, moving_end), "the moving end {moving_end} is scrolled into view"); -} - -#[test] -fn ctrl_shift_arrows_scroll_the_moving_end_into_view() { - // Down, then back Up past the top once the view has scrolled. - let mut h = harness(); - key(&mut h, Key::ArrowDown, CTRL_SHIFT); - assert_extended(&h, "A1", "A200"); - assert!(!visible(&h, "A1"), "the view follows the moving end, away from the anchor"); - let mut h = harness(); - key(&mut h, Key::ArrowDown, CTRL); - assert!(visible(&h, "A200") && !visible(&h, "A1")); - key(&mut h, Key::ArrowUp, CTRL_SHIFT); - assert_extended(&h, "A200", "A1"); - // Right, then back Left. - let mut h = harness(); - key(&mut h, Key::ArrowRight, CTRL_SHIFT); - assert_extended(&h, "A1", "CV1"); - let mut h = harness(); - key(&mut h, Key::ArrowRight, CTRL); - assert!(visible(&h, "CV1") && !visible(&h, "A1")); - key(&mut h, Key::ArrowLeft, CTRL_SHIFT); - assert_extended(&h, "CV1", "A1"); -} - -#[test] -fn shift_arrows_scroll_the_moving_end_into_view() { - let mut h = harness(); - for _ in 0..60 { - key(&mut h, Key::ArrowDown, Modifiers::SHIFT); - } - assert_extended(&h, "A1", "A61"); - for _ in 0..30 { - key(&mut h, Key::ArrowRight, Modifiers::SHIFT); - } - assert_extended(&h, "A1", "AE61"); -} - -#[test] -fn ctrl_shift_home_and_end_scroll_the_moving_end_into_view() { - let mut h = harness(); - key(&mut h, Key::End, CTRL_SHIFT); - assert_extended(&h, "A1", "CV200"); - let mut h = harness(); - key(&mut h, Key::End, CTRL); - assert!(!visible(&h, "A1")); - key(&mut h, Key::Home, CTRL_SHIFT); - assert_extended(&h, "CV200", "A1"); -} - -fn control(h: &mut Harness, method: &str, params: Json) -> Json { - let ctx = h.ctx.clone(); - let out = match handle(h.state_mut(), &ctx, method, ¶ms) { - Outcome::Done(v) => v, - Outcome::Screenshot { .. } => json!(null), - }; - h.run_steps(2); - out -} - -fn active(h: &Harness) -> CellRef { - h.state().session.active().unwrap().selection.active -} - -#[test] -fn go_to_over_the_control_channel_scrolls_the_target_into_view() { - let mut h = harness(); - assert!(!visible(&h, "Z300")); - let r = control(&mut h, "engine.execute", json!({"command": "edit.goTo", "params": {"reference": "Z300"}})); - assert_eq!(r["ok"], json!(true), "{r}"); - assert_eq!(active(&h), CellRef::parse("Z300").unwrap()); - assert!(visible(&h, "Z300"), "Go To scrolls Z300 into view"); - assert!(!visible(&h, "A1")); - // A command named as the method runs the same way. - control(&mut h, "edit.goTo", json!({"reference": "A1"})); - assert!(visible(&h, "A1") && !visible(&h, "Z300")); -} - -#[test] -fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { - let mut h = harness(); - control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"range": "BZ150:CA160"}})); - assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); - assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); -} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index bef43a5aa..c5e1f686c 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -183,6 +183,18 @@ mod tests { } } + #[test] + fn struct_ref_column_with_bang() { + // A `!` inside the brackets is part of the column name, not a sheet separator. + assert_eq!(to_file(&Formula::new("SUM(Table1[Done!])")), "SUM(Table1[Done!])"); + assert_eq!(text_to_file("=Table1[[#This Row],[a!b]]"), "Table1[[#This Row],[a!b]]"); + // `Table1[#REF!]` was found by the command-sequence fuzzer (seed 40). + for text in ["SUM(Table1[Done!])", "Table1[#REF!]", "Table1[@[x!]]", "Sheet2!A1+Table1[[#All],[a!b]]"] { + let f = Formula::new(text); + assert_eq!(from_file(&to_file(&f)), f.text); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 83955ee1f..5af3aeab3 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -274,6 +274,7 @@ fn read_views(e: &El, sheet: &mut Sheet) { sheet.show_headings = v.flag("showRowColHeaders", true); sheet.show_formulas = v.flag("showFormulas", false); sheet.show_zeros = v.flag("showZeros", true); + sheet.show_outline_symbols = v.flag("showOutlineSymbols", true); sheet.right_to_left = v.flag("rightToLeft", false); sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default(); if let Some(z) = v.attr_u32("zoomScale") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index daac03daa..e0e863ec3 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -172,6 +172,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if !sheet.show_zeros { s.push_str(" showZeros=\"0\""); } + if !sheet.show_outline_symbols { + s.push_str(" showOutlineSymbols=\"0\""); + } if sheet.right_to_left { s.push_str(" rightToLeft=\"1\""); } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index e746b01de..91b5d4b9c 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -330,3 +330,16 @@ fn ordinary_overlapping_column_ranges_keep_last_declaration() { assert!(last.hidden); assert_eq!(wb.styles.get(last.style.unwrap()).num_fmt.as_str(), "0.00"); } + +/// Show Outline Symbols off (`showOutlineSymbols="0"`) is read and written back. +#[test] +fn outline_symbols_flag_round_trips() { + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert!(!wb.sheet(0).unwrap().show_outline_symbols); + let (back, _) = read_xlsx(&crate::write_xlsx(&wb).unwrap()).unwrap(); + assert!(!back.sheet(0).unwrap().show_outline_symbols); + // Absent means shown. + let (wb, _) = read_xlsx(&minimal("", &[], "", "")).unwrap(); + assert!(wb.sheet(0).unwrap().show_outline_symbols); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..ee8bcfb75 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 291 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -17,7 +17,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | -| Automate | 3 | 3 | 100% | +| Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -324,13 +324,14 @@ Implemented: - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) -## Automate (3 / 3, 100%) +## Automate (4 / 4, 100%) Implemented: - `automate.newScript` New Script (Scripting Tools) - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) +- `automate.trustScripts` Trust Scripts (Scripting Tools) ## Table Design (13 / 13, 100%) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4894d77b1..62e1735f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTable calculated fields) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (test command) · **Target:** Microsoft Excel (Microsoft 365) Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); numbers and method are in @@ -100,7 +100,8 @@ to check our files, and Windows machines with the reported GPU drivers. - `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md) (ribbon catalog vs registered commands); `cargo xtask ci` checks it is current. -- `cargo test --workspace`: engine, formula, function, XLSX and UI behaviour. +- `cargo xtask test`: engine, formula, function, XLSX and UI behaviour (cargo-nextest when + installed, else `cargo test`), then the doctests. - `cargo run --release -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`: look at the UI offscreen. - Function coverage: the script method in [function-parity.md](function-parity.md). A @@ -112,6 +113,7 @@ to check our files, and Windows machines with the reported GPU drivers. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Measuring: tests run through `cargo xtask test` | | 2026-10-11 | minor | PivotTable calculated fields landed | | 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges | | 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08a9124bd..99a1e0717 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -341,7 +341,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | -| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | +| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/it/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..c7cbc349e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -50,8 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | @@ -60,7 +59,7 @@ this logic, with one undo step. | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | -| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | +| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | @@ -135,9 +134,9 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | -| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | @@ -176,6 +175,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | +| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | +| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | +| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | diff --git a/packaging/env.sh b/packaging/env.sh index bacb167e1..6f36506b6 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${GRIDCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${GRIDCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${GRIDCRAFT_BUILD_SHA:-}" ]]; then GRIDCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export GRIDCRAFT_BUILD_SHA @@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" f - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi + if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi done } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e4ee9da7b..ab98e9800 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -16,7 +16,7 @@ APP_ID=ai.storyteller.gridcraft LINUX="$ROOT/packaging/linux" SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; -h | --help) sed -n '2,11p' "$0"; exit 0 ;; @@ -34,7 +34,7 @@ BASENAME="gridcraft-$VERSION-freebsd-$ARCH" echo "==> GridCraft $VERSION for FreeBSD $ARCH" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) fi BIN="$CARGO_TARGET_DIR/release" diff --git a/packaging/icons.sh b/packaging/icons.sh index 2adcb9a9f..c6f325e3d 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,10 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/gridcraft" ] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/gridcraft" ]] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=gridcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 105ac744f..4de3aaf80 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -9,8 +9,9 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage -# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, +# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool and the AppImage +# runtime for the AppImage (pinned downloads into $CARGO_TARGET_DIR, or APPIMAGETOOL and +# APPIMAGE_RUNTIME, e.g. on riscv64, which has no pinned ones). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail @@ -21,11 +22,11 @@ APP_ID=ai.storyteller.gridcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + -h | --help) sed -n '2,18p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -42,8 +43,8 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) @@ -75,7 +76,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -99,6 +103,32 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + # appimagetool would otherwise embed type2-runtime's unchecked `continuous` build. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) APPIMAGETOOL_SHA256='' APPIMAGE_RUNTIME_SHA256='' ;; + esac + # Downloads a pinned file into the cache once and checks its hash on every run. + fetch_pinned() { + local path="$1" url="$2" sha256="$3" + if [[ ! -x "$path" ]]; then + curl --proto '=https' -fsSL -o "$path.download" "$url" + printf '%s %s\n' "$sha256" "$path.download" | sha256sum -c - + chmod 755 "$path.download" + mv "$path.download" "$path" + fi + printf '%s %s\n' "$sha256" "$path" | sha256sum -c - + return + } APPDIR="$WORK/GridCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -108,18 +138,33 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" - fi + if [[ -z "$TOOL" ]] && [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be - # relative, e.g. target/agent-). + RUNTIME="${APPIMAGE_RUNTIME:-}" + if [[ -z "$RUNTIME" ]] && [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; set APPIMAGE_RUNTIME" >&2 + exit 2 + fi + if [[ -z "$TOOL" ]]; then + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + fetch_pinned "$TOOL" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" \ + "$APPIMAGETOOL_SHA256" + fi + if [[ -z "$RUNTIME" ]]; then + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + fetch_pinned "$RUNTIME" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" \ + "$APPIMAGE_RUNTIME_SHA256" + fi + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR, APPIMAGETOOL or + # APPIMAGE_RUNTIME may be relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -131,18 +176,18 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/gridcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 41d9538bf..4611ffe05 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b128c3c7f..b45164ee0 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/gridcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/gridcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> GridCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli "${args[@]}") @@ -74,12 +74,26 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The +# network connection was lost", Error 68). Stapling and validating are idempotent, so retry. +stapler_retry() { + local action="$1" attempt + for attempt in 1 2 3 4 5; do + if xcrun stapler "$@"; then + return 0 + fi + [[ "$attempt" = 5 ]] && { echo "stapler $action failed 5 times" >&2; return 1; } + warn "stapler $action failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + sleep $((attempt * 15)) + done +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -88,8 +102,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -117,11 +131,11 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/GridCraft-notarize.zip" notarize "$WORK/GridCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -144,10 +158,10 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi @@ -162,7 +176,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/gridcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/gridcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8b1bca63b..7c5be6134 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,15 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" SITE="$ROOT/dist/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } - [ -f "$ROOT/apps/gridcraft-web/index.html" ] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } + [[ -f "$ROOT/apps/gridcraft-web/index.html" ]] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } # --dist/--public-url here, so the output doesn't depend on Trunk.toml (which should agree: # public_url = "./"). (cd "$ROOT/apps/gridcraft-web" && trunk build --release --dist "$SITE" --public-url ./) fi -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index a886a8de6..222d5705b 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'GridCraft', '/du', 'https://github.com/storytold/gridcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 diff --git a/packaging/windows/test-desktop-shortcut.ps1 b/packaging/windows/test-desktop-shortcut.ps1 index 9a5b4cb04..f3c25b2c8 100644 --- a/packaging/windows/test-desktop-shortcut.ps1 +++ b/packaging/windows/test-desktop-shortcut.ps1 @@ -72,10 +72,10 @@ function Invoke-Msi([string] $Name, [string[]] $Arguments) { throw "$Name exceeded two minutes; see $log" } if ($process.ExitCode -ne 0) { - if (Test-Path $log) { Get-Content $log -Tail 60 | Write-Host } + if (Test-Path $log) { Get-Content $log -Tail 60 } throw "$Name exited $($process.ExitCode); see $log" } - Write-Host "ok $Name" + Write-Output "ok $Name" } finally { $process.Dispose() } @@ -146,7 +146,7 @@ function Uninstall-AsSystem($Package) { $info = Get-ScheduledTaskInfo -TaskName $taskName if ($info.LastRunTime -ge $started.AddSeconds(-1) -and $task.State -ne 'Running' -and $info.LastTaskResult -ne 267009) { Assert-That ($info.LastTaskResult -eq 0) "Cross-account uninstall failed with $($info.LastTaskResult); see $log" - Write-Host 'ok uninstall-as-system' + Write-Output 'ok uninstall-as-system' return } } while ((Get-Date) -lt $deadline) diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 63b1554a0..f6df981b7 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -1,6 +1,6 @@ //! Regenerate the keytip sequence table and its per-sequence test suite from `keytips.json`. //! -//! `cargo xtask keytips` regenerate the table + `tests/keytips_generated.rs` +//! `cargo xtask keytips` regenerate the table + `tests/it/keytips_generated.rs` //! `cargo xtask keytips --check` fail if either generated file differs from the JSON source //! //! Requires the default `engine` feature: the generator probes the live command @@ -92,7 +92,7 @@ pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> { { let json_path = root.join("crates/ui-egui/src/keytips/keytips.json"); let table_path = root.join("crates/ui-egui/src/keytips/keytip_table/generated.rs"); - let tests_path = root.join("crates/ui-egui/tests/keytips_generated.rs"); + let tests_path = root.join("crates/ui-egui/tests/it/keytips_generated.rs"); let source = fs::read_to_string(&json_path).map_err(|e| format!("read {}: {e}", json_path.display()))?; let (entries, probe) = parse_and_probe(&source)?; let generated = diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 14e9af32a..0c9f5547d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,17 +15,19 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, assets, layers, wasm, parity --check - (stops at the first failure; the parity check runs only if docs/parity-checklist.md exists) + ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, + keytips --check (stops at the first failure; the parity check runs only if + docs/parity-checklist.md exists) + test workspace tests (cargo nextest when installed, else cargo test), then doctests assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md layers enforce the crate dependency layering (AGENTS.md \"Layering\") - wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web) + wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] write docs/parity-checklist.md from gridcraft_engine::catalog (implemented vs missing ids) keytips [--check] @@ -44,6 +46,7 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root(), &rest), @@ -139,17 +142,19 @@ fn wasm_set() -> Result, String> { .collect()) } +/// One `cargo check` for the whole set; `--keep-going` reports every failing crate, not just the +/// first. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; if set.is_empty() { return Err("no wasm crates found in the workspace".into()); } let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown"]); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for p in &set { c.args(["-p", p]); } - let r = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {}", set.join(" -p "))); + let r = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))); println!("\nwasm32-unknown-unknown check ({}): {}", set.join(", "), if r.is_ok() { "ok" } else { "FAIL" }); if r.is_err() { println!("(if the target is missing: rustup target add wasm32-unknown-unknown)"); @@ -157,6 +162,35 @@ fn cmd_wasm() -> Result<(), String> { r } +/// Targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") +} + fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ @@ -172,18 +206,12 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets -- -D warnings") - }), - ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") + c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") }), ), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)),