diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b8efbdc..0a82a91 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -51,16 +51,15 @@ jobs: GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} GRIDCRAFT_BUILD_SHA: ${{ github.sha }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -88,7 +87,7 @@ jobs: cargo build --workspace --all-targets --locked CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: inputs.package with: name: release-freebsd diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ffd5fe7..e9927c5 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de15fd8..ca270a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -73,18 +73,17 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -104,7 +103,7 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -132,18 +131,17 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override @@ -167,7 +165,7 @@ jobs: AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -191,10 +189,9 @@ jobs: GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -208,8 +205,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -233,7 +230,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -256,8 +253,8 @@ jobs: GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }} GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -271,7 +268,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -287,18 +284,17 @@ jobs: GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install trunk @@ -314,7 +310,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -348,7 +344,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 927ce1d..92239fa 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,11 +28,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -43,7 +43,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: gridcraft-windows-arm64 path: dist/arm64/* @@ -56,7 +56,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: gridcraft-windows-arm64 path: dist diff --git a/AGENTS.md b/AGENTS.md index 467a9e0..aaf3448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. -- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`. +- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. diff --git a/CLAUDE.md b/CLAUDE.md index ff12ac8..54e9d03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. -- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`. +- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`. diff --git a/README.md b/README.md index b27fbfb..ef3f26b 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,19 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +### Logs + +The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder +next to its settings: `~/.config/gridcraft/logs` on Linux and BSD (`$XDG_CONFIG_HOME/gridcraft/logs` +when set), `~/Library/Application Support/GridCraft/logs` on macOS and +`%APPDATA%\GridCraft\logs` on Windows. Each start moves the previous log to `gridcraft.1.log` and +that one to `gridcraft.2.log`, so the log of a run that crashed survives the next start; attach +them to a bug report. A log file stops growing at 16 MiB. By default GridCraft's own crates log +at `info` and everything else at `warn`; `RUST_LOG` replaces that with env_logger-style +directives, e.g. `RUST_LOG=debug` or `RUST_LOG=warn,gridcraft_engine=debug` (a trailing `*` +matches a prefix: `gridcraft*=debug`). Runs with `GRIDCRAFT_NO_PREFS` set (no saved settings) +log to standard error only. + ## For agents: MCP, CLI and the control channel ```sh @@ -175,6 +188,21 @@ are in [ROADMAP.md](ROADMAP.md). Installers and executables are code-signed. +**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. +Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's +`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit. +`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In +PowerShell, from the folder containing the executable: + +```powershell +$env:WGPU_BACKEND = "vulkan" +& .\gridcraft.exe +Remove-Item Env:WGPU_BACKEND # restore the default for later launches +``` + +An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux +and web backend defaults are unchanged. + ### macOS | Build | File | Notes | diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd34..32ddbcd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,11 +81,13 @@ by open equivalents or left out, not cloned. autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose, operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists, text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special, - undo/redo with history, format painter. + undo/redo with history, format painter. Quick selection drags retain their final endpoint; + the fill handle explains how to fill versus select cells. - Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates, duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic row heights, autofit, hide/unhide, freeze panes. + Border presets include visual diagrams; Format Cells previews draft borders before applying. - Data: sort (multi-level, by colour, custom lists), AutoFilter (values, custom, top 10, average, colour), tables with totals rows, remove duplicates, text to columns, data validation with error alerts, grouping/outline, subtotals. diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs new file mode 100644 index 0000000..c35f857 --- /dev/null +++ b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs @@ -0,0 +1,26 @@ +use std::process::Command; + +#[test] +fn text_to_columns_preserves_doubled_quotes() { + let dir = std::env::temp_dir().join(format!("gridcraft-t2c-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("make temp directory"); + let script = dir.join("steps.jsonl"); + std::fs::write( + &script, + concat!( + "{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"\\\"a\\\"\\\"b\\\",c\"}}\n", + "{\"command\":\"data.textToColumns\",\"params\":{\"range\":\"A1\",\"delimiters\":[\",\"]}}\n", + ), + ) + .expect("write commands"); + let output = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")) + .args(["run", "--script"]) + .arg(&script) + .args(["--print", "A1:B1", "--csv", "--quiet"]) + .output() + .expect("run Gridcraft CLI"); + std::fs::remove_file(script).expect("remove script"); + std::fs::remove_dir(dir).expect("remove temp directory"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "\"a\"\"b\",c"); +} diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index bdbc28d..e9e05a2 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // eframe's default includes GL. Creating that backend can crash inside AMD's + // atio6axx.dll before adapter selection or any Rust error handling runs, so the + // window never appears. Choosing an adapter afterwards is too late: only leaving GL + // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 + // workaround without mutating process environment variables. + create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn configured_backends(backend_override: Option) -> Backends { + let mut options = NativeOptions::default(); + configure(&mut options, backend_override); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + create.instance_descriptor.backends + } + + #[test] + fn windows_default_initializes_only_dx12() { + // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); + assert_eq!(configured_backends(None), Backends::DX12); + } + + #[test] + fn explicit_backend_override_is_preserved() { + for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { + assert_eq!(configured_backends(Some(backends)), backends); + } + } +} diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs new file mode 100644 index 0000000..d73cd71 --- /dev/null +++ b/apps/gridcraft/src/logging.rs @@ -0,0 +1,579 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (a sample that failed to +//! build, a graphics adapter warning, a panic a command's guard recovered from) vanished. A launch +//! from a desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/gridcraft.log`, next to `ui.json` and `prefs.json` (`config_dir` in +//! `main.rs`). Each start moves the previous log to `gridcraft.1.log` (and that one to `.2`), so +//! the log of a run that crashed survives the next launch. Runs without preferences +//! (`GRIDCRAFT_NO_PREFS`) log to standard error only. +//! +//! Levels: `info` for GridCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,gridcraft_engine=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`gridcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. +//! +//! This is the desktop app's logger only; `gridcraft-cli` and `gridcraft-web` set up their own. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "gridcraft.log"; +/// How many previous logs are kept (`gridcraft.1.log` … `gridcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,gridcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +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()) +} + +/// 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) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("gridcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + // No file is coming ([`Sink::no_file`]): standard error had the line. + } else if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + self.written = 0; + self.capped = false; + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error + /// standard error stays the only sink. + pub fn attach_dir(&self, dir: &Path) -> Result { + // A thread that panicked while logging leaves the sink usable: take it back. + let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner); + match rotate(dir) { + Ok((path, file)) => { + sink.attach(file); + Ok(path) + } + Err(e) => { + sink.no_file(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file for this run (runs without preferences): log to standard error only. + pub fn no_file(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file(); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + #[test] + fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); + assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); + assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "gridcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,gridcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "gridcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), + "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); + assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); + record(&logger, log::Level::Debug, "gridcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("gridcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "gridcraft", "after the poison"); + assert!(read(&path).contains("gridcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 901a6c6..b6f1eaf 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -9,6 +9,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +#[cfg(any(target_os = "windows", test))] +mod graphics; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -61,8 +64,19 @@ fn config_dir() -> Option { } } +/// Runs without preferences (`GRIDCRAFT_NO_PREFS`, agents' test runs) neither read nor write +/// them, and keep no log file. +fn prefs_enabled() -> bool { + std::env::var_os("GRIDCRAFT_NO_PREFS").is_none() +} + +/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). +fn log_dir() -> Option { + Some(config_dir()?.join("logs")) +} + fn load_prefs(app: &mut SheetApp) { - if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } let Some(dir) = config_dir() else { return }; @@ -79,7 +93,7 @@ fn load_prefs(app: &mut SheetApp) { } fn save_prefs(app: &SheetApp) { - if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } let Some(dir) = config_dir() else { return }; @@ -134,6 +148,26 @@ fn icon() -> Option { } fn main() -> eframe::Result<()> { + // First, so the panic hook and every start-up warning are recorded (`logging`). + let logger = logging::install(); + // Commands catch their panics (`Session::execute`), but the default hook only prints to + // standard error; this one sends every panic, caught or not, to the log file too, with a + // backtrace when RUST_BACKTRACE is set. + std::panic::set_hook(Box::new(|info| { + let trace = std::backtrace::Backtrace::capture(); + let report = if trace.status() == std::backtrace::BacktraceStatus::Captured { + format!("internal error: {info}\n{trace}") + } else { + format!("internal error: {info}") + }; + // Standard error and the log file; standard error alone when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{report}"); + } else { + // `eprintln!` panics on a broken stderr pipe, and a panic inside the panic hook aborts. + let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("gridcraft: {report}\n")); + } + })); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { println!("GridCraft {}", env!("CARGO_PKG_VERSION")); @@ -158,15 +192,29 @@ fn main() -> eframe::Result<()> { } i += 1; } + // The log file lives in the settings folder, next to the preferences; opened after the + // arguments, so `--version` leaves no file behind. Records logged until now are written to it + // first. Runs without preferences (agents' test runs) log to standard error only, so they + // don't rotate away the user's own logs. + if let Some(logger) = logger { + match log_dir().filter(|_| prefs_enabled()) { + Some(dir) => match logger.attach_dir(&dir) { + Ok(path) => log::info!("GridCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + }, + None => logger.no_file(), + } + } let mut session = Session::new(); if let Some(s) = &sample && let Err(e) = session.execute("file.new", json!({"sample": s})) { - eprintln!("sample: {e}"); + log::warn!("sample {s}: {e}"); } for f in &files { if let Err(e) = session.execute("file.open", json!({"path": f})) { - eprintln!("{f}: {e}"); + log::warn!("{f}: {e}"); } } if session.documents().is_empty() { @@ -189,6 +237,13 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; + // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + #[cfg(target_os = "windows")] + let options = { + let mut options = options; + graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + options + }; eframe::run_native( "GridCraft", options, @@ -197,7 +252,7 @@ fn main() -> eframe::Result<()> { if let Some(port) = control_port { match control_server::start(port, cc.egui_ctx.clone()) { Ok(rx) => app.control_rx = Some(rx), - Err(e) => eprintln!("control channel on port {port}: {e}"), + Err(e) => log::error!("control channel on port {port}: {e}"), } } Ok(Box::new(App( diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 43e57a4..260d900 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, channel}; use gridcraft_ui_egui::SheetApp; use muda::accelerator::Accelerator; -use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::{Value, json}; type Entry = (&'static str, &'static str, Option<&'static str>); @@ -78,7 +78,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Freeze Top Row", "view.freezeTopRow", None), ("Unfreeze Panes", "view.unfreezePanes", None), ("-", "", None), - ("Dark Mode", "view.darkMode", None), + ("Display Theme", "view.theme", None), ("Collapse Ribbon", "view.collapseRibbon", Some("CMD+ALT+R")), ], ), @@ -181,6 +181,18 @@ fn tree() -> Vec<(&'static str, Vec)> { pub struct NativeMenu { _menu: Menu, events: Receiver, + theme_items: Vec<(&'static str, CheckMenuItem)>, +} + +fn select_theme(app: &mut SheetApp, id: &str) -> bool { + let mode = match id { + "theme:system" => "system", + "theme:light" => "light", + "theme:dark" => "dark", + _ => return false, + }; + app.run_or_alert("view.theme", json!({"mode": mode})); + true } impl NativeMenu { @@ -200,6 +212,7 @@ impl NativeMenu { &PredefinedMenuItem::quit(None), ]); let _ = menu.append(&app_menu); + let mut theme_items = Vec::new(); for (title, entries) in tree() { let sub = Submenu::new(title, true); for (label, id, acc) in entries { @@ -207,6 +220,16 @@ impl NativeMenu { let _ = sub.append(&PredefinedMenuItem::separator()); continue; } + if id == "view.theme" { + let themes = Submenu::new(label, true); + for (mode, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + let item = CheckMenuItem::with_id(format!("theme:{mode}"), label, true, false, None); + let _ = themes.append(&item); + theme_items.push((mode, item)); + } + let _ = sub.append(&themes); + continue; + } let accel = acc.and_then(|a| Accelerator::from_str(a).ok()); let item = MenuItem::with_id(id, label, true, accel); let _ = sub.append(&item); @@ -220,12 +243,15 @@ impl NativeMenu { let _ = tx.send(e); ctx.request_repaint(); })); - NativeMenu { _menu: menu, events: rx } + NativeMenu { _menu: menu, events: rx, theme_items } } pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + if select_theme(app, &id) { + continue; + } if let Some(url) = id.strip_prefix("url:") { if let Some(open) = &app.services.open_url { open(url); @@ -252,5 +278,28 @@ impl NativeMenu { app.run_or_alert(&id, json!({})); } } + for (mode, item) in &self.theme_items { + let checked = app.ui.theme_mode() == *mode; + if item.is_checked() != checked { + item.set_checked(checked); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_choices_use_the_shared_preference_command() { + let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); + for mode in ["dark", "system", "light"] { + assert!(select_theme(&mut app, &format!("theme:{mode}"))); + assert_eq!(app.ui.theme_mode(), mode); + assert!(app.message.is_none()); + } + assert!(!select_theme(&mut app, "view.formulaBar")); + assert_eq!(app.ui.theme_mode(), "light"); } } diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db2..2e6b83b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,7 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.nihilist-narwhal] +display_name = "nihilist-narwhal" +sync_github_name = false diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54f..e36be2d 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -54,6 +54,10 @@ pub trait Host { fn random(&mut self) -> f64; /// Called when a volatile or dynamic-reference function is used. fn note_volatile(&mut self) {} + /// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill). + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied() + } /// Values of a block, row-major. Hosts can override with a sparse fast path. fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { range.iter().map(|c| self.cell_value(sheet, c)).collect() @@ -322,6 +326,19 @@ impl<'h> Evaluator<'h> { } fn unary(&mut self, op: UnOp, x: &Expr) -> Ev { + if op == UnOp::Spill { + return match self.eval(x) { + Ev::R(areas) => match areas.as_slice() { + [a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) { + Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]), + None => err(CellError::Ref), + }, + _ => err(CellError::Ref), + }, + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Ref), + }; + } if op == UnOp::At { let ev = self.eval(x); return match ev { @@ -343,7 +360,7 @@ impl<'h> Evaluator<'h> { UnOp::Neg => Value::number(-n), UnOp::Plus => Value::number(n), UnOp::Percent => Value::number(n / 100.0), - UnOp::At => Value::number(n), + UnOp::At | UnOp::Spill => Value::number(n), }, op == UnOp::Plus, )) @@ -696,8 +713,20 @@ impl<'h> Evaluator<'h> { return err(CellError::Value); } let mut evaluated = Vec::with_capacity(args.len()); - for a in args { - let ev = self.eval(a); + // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. + let mut criteria_shape = None; + for (i, a) in args.iter().enumerate() { + let mut ev = self.eval(a); + if resizes_sum_range(name, args.len()) { + if i == 0 { + criteria_shape = ev_shape(&ev); + } else if i == 2 + && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) + && let [area] = areas.as_slice() + { + ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); + } + } let from_ref = matches!(ev, Ev::R(_)); let value = match ev { Ev::R(areas) if areas.len() > 1 => { @@ -1306,30 +1335,64 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value { } } +/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. +fn resizes_sum_range(name: &str, args: usize) -> bool { + args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") +} + +/// Height and width of a single-area reference or an array argument. +fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { + match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => Some((a.range.height(), a.range.width())), + _ => None, + }, + Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), + Ev::V(_) => Some((1, 1)), + Ev::L(_) => None, + } +} + +/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns +/// as the criteria range (`height` × `width`), cut off at the sheet's edge. +pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { + let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); + let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); + RangeRef::new(sum.start, CellRef::new(row, col)) +} + /// Collects the static references of an expression with their sheets resolved; `dynamic` is /// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { let mut out = Vec::new(); let mut dynamic = false; - e.walk(&mut |x| match x { - Expr::Ref(r) => { - let range = r.range(); - match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } - } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); - } - } + let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); } } } + }; + e.walk(&mut |x| match x { + Expr::Ref(r) => push(&mut out, r, r.range()), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => dynamic = true, + }, Expr::Call(n, _) if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => { diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c4..500dec2 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -225,6 +225,26 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { let k = (sheet, c); if self.pending.contains(&k) || self.results.contains_key(&k) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db083..7b582ac 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -33,6 +33,40 @@ impl T { } } +#[test] +fn spill_range_operator() { + let mut t = T::new(); + t.set("A1", "3"); + t.set("B1", "=SEQUENCE(A1)"); + t.set("C1", "=SUM(B1#)"); + t.set("C2", "=ROWS(B1#)"); + assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0)); + // Growing and shrinking the spill recalculates its users. + t.set("A1", "5"); + assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0)); + t.set("A1", "2"); + assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0)); + // A blocked spill, or a cell that doesn't spill, gives #REF!. + t.set("B2", "x"); + assert_eq!(t.get("C1"), Value::Error(CellError::Ref)); + t.set("B2", ""); + assert_eq!(t.num("C1"), 3.0); + t.set("D1", "=SUM(A1#)"); + assert_eq!(t.get("D1"), Value::Error(CellError::Ref)); + // Through a defined name. + t.wb.names.push(gridcraft_model::DefinedName { + name: "Seq".into(), + scope: None, + formula: "Sheet1!$B$1#".into(), + comment: String::new(), + hidden: false, + }); + t.set("E1", "=SUM(Seq)*10"); + assert_eq!(t.num("E1"), 30.0); + t.set("A1", "4"); + assert_eq!(t.num("E1"), 100.0); +} + #[test] fn arithmetic_and_dependencies() { let mut t = T::new(); @@ -215,3 +249,94 @@ fn recalc_all_after_load() { t.calc.recalc_all(&mut t.wb); assert_eq!(t.num("A3"), 12.0); } + +#[test] +fn sumif_sum_range_takes_the_criteria_size() { + // Excel adds up the range that starts at sum_range's top-left cell with the criteria range's + // height and width, whatever size sum_range has. + let mut t = T::new(); + for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() { + t.set(&format!("A{}", r + 1), k); + t.set(&format!("B{}", r + 1), &v.to_string()); + } + t.set("D1", "=SUMIF(A1:A3,\"a\",B1)"); + assert_eq!(t.num("D1"), 4.0); + t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)"); + assert_eq!(t.num("D2"), 2.0); + t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)"); + assert_eq!(t.num("D3"), 4.0); + t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)"); + assert_eq!(t.num("D4"), 4.0); + t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)"); + assert_eq!(t.num("D5"), 4.0); + t.set("D6", "=SUMIF(A:A,\"a\",B1)"); + assert_eq!(t.num("D6"), 4.0); + t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)"); + assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty) + t.set("D8", "=SUMIF(A1:A3,\"a\")"); + assert_eq!(t.num("D8"), 0.0); + // A horizontal criteria range resizes across. + t.set("H1", "1"); + t.set("I1", "0"); + t.set("J1", "1"); + t.set("H2", "10"); + t.set("I2", "20"); + t.set("J2", "30"); + t.set("D9", "=SUMIF(H1:J1,1,H2)"); + assert_eq!(t.num("D9"), 40.0); + // The resized cells are precedents: editing one recalculates. + t.set("B3", "5"); + assert_eq!(t.num("D1"), 6.0); + assert_eq!(t.num("D2"), 3.0); + assert_eq!(t.num("D3"), 6.0); + assert_eq!(t.num("D4"), 6.0); + assert_eq!(t.num("D6"), 6.0); + // SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!. + t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")"); + assert_eq!(t.get("D11"), Value::Error(CellError::Value)); + t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")"); + assert_eq!(t.get("D12"), Value::Error(CellError::Value)); + t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")"); + assert_eq!(t.num("D13"), 6.0); +} + +#[test] +fn sumif_resized_sum_range_stops_at_the_sheet_edge() { + let mut t = T::new(); + t.set("A1", "a"); + t.set("A2", "a"); + t.set("B1048575", "5"); + t.set("B1048576", "7"); + t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)"); + assert_eq!(t.num("C1"), 7.0); + t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)"); + assert_eq!(t.num("C2"), 12.0); + t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)"); + assert_eq!(t.num("C3"), 0.0); +} + +#[test] +fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { + let mut t = T::new(); + t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data"))); + t.wb.names.push(gridcraft_model::DefinedName { + name: "Amounts".into(), + scope: None, + formula: "Data!$B$1".into(), + comment: String::new(), + hidden: false, + }); + for (r, k) in ["a", "b", "a"].iter().enumerate() { + t.set(&format!("A{}", r + 1), k); + t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64)); + } + t.calc.rebuild(&t.wb); + t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)"); + assert_eq!(t.num("C1"), 4.0); + t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)"); + assert_eq!(t.num("C2"), 2.0); + t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0)); + t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]); + assert_eq!(t.num("C1"), 10.0); + assert_eq!(t.num("C2"), 5.0); +} diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23f..b824434 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 { } } +/// Days in a month of a date system's calendar: like [`days_in_month`], except that February +/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29). +pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 { + if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) } +} + /// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next -/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31. +/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900 +/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60. +/// `None` before the epoch or after 9999-12-31. pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { // DATE(): years 0–1899 are added to 1900. - let mut y = if (0..1900).contains(&year) { year + 1900 } else { year }; - let m0 = month - 1; - y += m0.div_euclid(12); + let y = if (0..1900).contains(&year) { year + 1900 } else { year }; + let m0 = month.checked_sub(1)?; + let y = y.checked_add(m0.div_euclid(12))?; let m = m0.rem_euclid(12) + 1; if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 { return None; } - let base = days_from_civil(y as i32, m as u32, 1) + day - 1; - let serial = match sys { - DateSystem::D1900 => { - let s = base - UNIX_1899_12_30; - // Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30. - if s <= 60 { s - 1 } else { s } - } - DateSystem::D1904 => base - UNIX_1904_01_01, + let first = days_from_civil(y as i32, m as u32, 1); + // Serial of the first of the month; the day (and any overflow) counts on from there. + let first = match sys { + // Up to February 1900 the serial is one lower than the day count from 1899-12-30: the + // fictitious 1900-02-29 (serial 60) comes before 1 March. + DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2), + DateSystem::D1904 => first - UNIX_1904_01_01, }; + let serial = first.checked_add(day)?.checked_sub(1)?; if !(0..=2_958_465).contains(&serial) { return None; } @@ -168,10 +176,58 @@ mod tests { assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0)); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count + // through it. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, -1), None); + assert_eq!(serial_from_ymd(s, 1900, 0, 1), None); + // Later years keep the real calendar. + assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1)); + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); + // Every serial up to 1900-12-31 comes back from its own year, month and day. + for serial in 0..=366 { + let d = datetime_from_serial(s, serial as f64).unwrap(); + assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); + } + assert_eq!(days_in_month_in(s, 1900, 2), 29); + assert_eq!(days_in_month_in(s, 1901, 2), 28); + assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28); + // The 1904 system has no fictitious day: 1904 is a real leap year. + let s = DateSystem::D1904; + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); + assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + } + + #[test] + fn hostile_month_and_day() { + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None); + assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None); + assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None); + } + #[test] fn roundtrip() { let s = DateSystem::D1900; - for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] { + for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { let d = datetime_from_serial(s, serial).unwrap(); assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b08cf2a..5d0be7a 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { if !(1..=12).contains(&m) || d < 1 { return None; } - let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 }; + let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64; if d > max { return None; } @@ -357,4 +357,19 @@ mod tests { assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75)); assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0)); } + + #[test] + fn fictitious_1900_02_29() { + // Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61. + assert_eq!(p("2/29/1900").value, Value::Number(60.0)); + assert_eq!(p("1900-02-29").value, Value::Number(60.0)); + assert_eq!(p("29-Feb-1900").value, Value::Number(60.0)); + assert_eq!(p("2/28/1900").value, Value::Number(59.0)); + assert_eq!(p("3/1/1900").value, Value::Number(61.0)); + assert_eq!(p("2/29/1901").value, Value::text("2/29/1901")); + assert_eq!(p("2/30/1900").value, Value::text("2/30/1900")); + // The 1904 system starts after it. + assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); + assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); + } } diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8961d87..6ec645e 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -662,8 +662,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { } } for (c, t) in out { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None); + let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); let shm = cx.sheet_mut(sheet)?; match cell { Some(x) => shm.cells.set(c, x), @@ -683,9 +682,15 @@ fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) let mut cur = String::new(); let mut in_q = false; let mut last_delim = false; - for c in text.chars() { + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { if c == quote { - in_q = !in_q; + if in_q && chars.peek() == Some("e) { + cur.push(quote); + chars.next(); + } else { + in_q = !in_q; + } continue; } if !in_q && delims.contains(&c) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cffe154..9b7db40 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -119,9 +119,10 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- entry /// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = old.map(|c| c.style).unwrap_or_default(); +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; return Ok(if c.is_blank() { None } else { Some(c) }); @@ -148,6 +149,7 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_ parsed = gridcraft_formula::parse(&text); } let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; + let expr = wb.name_call_case(expr); let mut cell = Cell::formula(Formula::from_expr(expr)); cell.style = style; // Formulas whose result is a date/time get a format from functions like TODAY(). @@ -206,8 +208,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { if array { // Legacy Ctrl+Shift+Enter array formula over the selection. let range = cx.sel.current(); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned(); - let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default(); + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } @@ -216,8 +217,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { cx.touch(sheet, range.start); return Ok(Json::Null); } - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); - let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.cells.set(at, c), @@ -265,15 +265,16 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { for (ci, v) in cols.iter().enumerate() { let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; let input = json_to_input(v); - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, - style: old.as_ref().map(|c| c.style).unwrap_or_default(), + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), }), - Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), - _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, + 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() }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; match cell { @@ -297,8 +298,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let input = str_param(p, "input").unwrap_or("").to_string(); let origin = s.doc()?.selection.active; edit(s, |cx| { - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned(); - let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); @@ -665,8 +665,7 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { for (ri, row) in rows.iter().enumerate() { for (ci, v) in row.iter().enumerate() { let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); let sh = cx.sheet_mut(sheet)?; match cell { Some(cell) => sh.cells.set(c, cell), @@ -1175,7 +1174,11 @@ fn find(s: &mut Session, p: &Json) -> Result { .cells .iter() .filter_map(|(c, cell)| { - let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() }; + let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { + crate::display::cell_text(&d.wb, sh, c) + } else { + cell.input_text() + }; matches(&text, &what, case, whole).then_some((c, text)) }) .collect(); @@ -1234,8 +1237,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { } else { replace_ci(&text, &what, &with) }; - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned(); - let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(c, cell), diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index d6bc2ba..657fcfa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result { let base = border_style(str_param(p, "style").unwrap_or("thin")); let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; + if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) { + return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); + } edit(s, |cx| { for r in &ranges { if r.count() > 2_000_000 { @@ -453,7 +456,29 @@ fn borders(s: &mut Session, p: &Json) -> Result { } }); cx.sheet_mut(sheet)?.set_style(c, new); - // Clear the neighbour's facing edge when removing borders, so it really goes. + if preset == "none" { + // A shared line can be stored on either cell. Selected cells are handled + // by this loop; remove only facing edges outside this range. + for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] { + if !boundary { + continue; + } + let Some(neighbor) = c.offset(dr, dc) else { continue }; + let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default(); + let new = cx.wb.styles.derive(st, |s| { + let edge = match (dr, dc) { + (-1, _) => &mut s.borders.bottom, + (1, _) => &mut s.borders.top, + (_, -1) => &mut s.borders.right, + _ => &mut s.borders.left, + }; + *edge = BorderLine::default(); + }); + if new != st { + cx.sheet_mut(sheet)?.set_style(neighbor, new); + } + } + } } } Ok(Json::Null) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 2bea26c..f3576e4 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -36,14 +36,14 @@ pub fn specs() -> Vec { has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?}", has_doc, delete_name), + cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?, right?}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", has_doc, create_from_selection ), @@ -218,15 +218,26 @@ fn valid_name(n: &str) -> bool { if !(first.is_alphabetic() || first == '_' || first == '\\') { return false; } - if n.len() > 255 || n.eq_ignore_ascii_case("R") || n.eq_ignore_ascii_case("C") { - return false; - } - if CellRef::parse(n).is_some() { + if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) { return false; } n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?')) } +/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't +/// accept as a name. +fn is_r1c1(n: &str) -> bool { + let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count(); + let mut rest = n; + if let Some(r) = rest.strip_prefix(['R', 'r']) { + rest = r.get(digits(r)..).unwrap_or(""); + } + if let Some(c) = rest.strip_prefix(['C', 'c']) { + rest = c.get(digits(c)..).unwrap_or(""); + } + rest.is_empty() && !n.is_empty() +} + fn define_name(s: &mut Session, p: &Json) -> Result { let Some(name) = str_param(p, "name").map(str::to_string) else { s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({}))); @@ -264,9 +275,14 @@ fn define_name(s: &mut Session, p: &Json) -> Result { fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + let scope = match str_param(p, "scope") { + None => None, + Some("Workbook") => Some(None), + Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)), + }; edit(s, |cx| { let before = cx.wb.names.len(); - cx.wb.names.retain(|n| !n.name.eq_ignore_ascii_case(&name)); + cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc))); if cx.wb.names.len() == before { return Err(bad("formulas.deleteName", "no such name")); } @@ -295,6 +311,9 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let top = bool_param(p, "top").unwrap_or(true); let left = bool_param(p, "left").unwrap_or(false); + let bottom = bool_param(p, "bottom").unwrap_or(false); + let right = bool_param(p, "right").unwrap_or(false); + let replace = bool_param(p, "replace").unwrap_or(false); let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let q = gridcraft_formula::quote_sheet(&sh.name); @@ -309,28 +328,38 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { }; let clean = |t: String| -> String { let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect(); - if n.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&n).is_some() { + if !n.is_empty() && !valid_name(&n) { n.insert(0, '_'); } - n + if valid_name(&n) { n } else { String::new() } }; + // The labels in the chosen edge rows/columns name the cells between them. + let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32)); + let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32)); let mut made = Vec::new(); - if top && r.height() > 1 { - for c in r.start.col..=r.end.col { - let name = clean(sh.value(CellRef::new(r.start.row, c)).display()); - if !name.is_empty() { - made.push((name, abs(RangeRef::new(CellRef::new(r.start.row + 1, c), CellRef::new(r.end.row, c))))); - } - } - } - if left && r.width() > 1 { - for row in r.start.row + top as u32..=r.end.row { - let name = clean(sh.value(CellRef::new(row, r.start.col)).display()); - if !name.is_empty() { - made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col))))); + if r0 <= r1 && c0 <= c1 { + for (on, row) in [(top, r.start.row), (bottom, r.end.row)] { + for c in (c0..=c1).filter(|_| on) { + let name = clean(sh.value(CellRef::new(row, c)).display()); + if !name.is_empty() { + made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c))))); + } + } + } + for (on, col) in [(left, r.start.col), (right, r.end.col)] { + for row in (r0..=r1).filter(|_| on) { + let name = clean(sh.value(CellRef::new(row, col)).display()); + if !name.is_empty() { + made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1))))); + } } } } + // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and + // reported. + let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { for (name, f) in made { @@ -338,7 +367,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false }); } cx.structural = true; - Ok(json!({"created": n})) + Ok(json!({"created": n, "skipped": skipped})) }) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 397a05c..a32401b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -209,6 +209,9 @@ pub struct Ctx<'a> { pub sel: &'a mut Selection, /// Rows whose automatic height should be recomputed (sheet, row). pub fit_rows: Vec<(usize, u32)>, + /// The command applied sheet protection itself (row/column inserts and deletes the + /// protection allows), so cells it shifts aren't refused as edits of locked cells. + pub protection_checked: bool, } pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> Result { @@ -220,9 +223,12 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some(); let t0 = std::time::Instant::now(); let mut sel = d.selection.clone(); - let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() }; + let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; let r = f(&mut ctx)?; - let Ctx { mut wb, changed, structural, mut fit_rows, .. } = ctx; + let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx; + if !protection_checked { + check_protection(&d.wb, &wb)?; + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -250,6 +256,43 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } +/// Excel's message for an edit of a locked cell on a protected sheet. +pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; + +/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or +/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or +/// deleting a sheet isn't an edit of its cells. +fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> { + for sh in &old.sheets { + if !sh.is_protected() { + continue; + } + let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue }; + if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() { + continue; + } + for c in sh.cells.diff(&after.cells) { + if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked { + return Err(EngineError::Other(PROTECTED.into())); + } + } + } + Ok(()) +} + +/// Two cells hold the same value or formula (formats aside). +fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool { + let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty()); + match (a, b) { + (Some(a), Some(b)) => match (&a.formula, &b.formula) { + (Some(x), Some(y)) => x.text == y.text && x.array == y.array, + (None, None) => a.value == b.value, + _ => false, + }, + _ => empty(a) && empty(b), + } +} + /// Recomputes an automatic row height from its content: the largest font, and wrapped or /// multi-line text. Rows with a user-set height keep it. pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a51a2f1..09ea606 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -923,7 +923,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 HAlign::Justify | HAlign::Fill => HAlign::Left, other => other, }; - let lines: Vec = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] }; + let rot = st.align.rotation; + let rotated = rot != 0 && rot != 255; + // Rotated text wraps along its own direction: upright (±90°) text to the row height. + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size)), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), + (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], + (false, _) => vec![text], + }; let line_h = size * 1.2; let n = lines.len().max(1) as f32; let first_baseline = match st.align.v { @@ -933,7 +941,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 }; // Overflow into empty neighbours (single-line text, not merged). let (mut clip_l, mut clip_r) = (x, x + w); - if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w { + if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w { let need = tw + 2.0 * pad + indent - w; let grow = |dir: i64, mut need: f32| -> f32 { let mut ext = 0.0; @@ -966,13 +974,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let clip_r = clip_r.min(limits.1); pg.save(); pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); - let rot = st.align.rotation; - if rot != 0 && rot != 255 && !wrap { + if rotated { // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. + // Wrapped lines stack rightward for upward text and leftward for downward text, so + // downward text starts its first line far enough in for the last to end at the left pad. let deg = (rot as f32).clamp(-90.0, 90.0); let angle = -deg.to_radians(); - let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) }; - if let Some(t) = lines.first() { + let last = lines.len().saturating_sub(1) as f32; + for (i, t) in lines.iter().enumerate() { + let i = i as f32; + let (ox, oy) = if deg > 0.0 { + (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + } else { + (x + pad + (last - i) * line_h, y + pad + size * 0.8) + }; pg.text_rotated(ox, oy, size, font, color, t, angle); } pg.restore(); @@ -1762,4 +1777,26 @@ mod tests { let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); assert!(w.len() > 2); } + + #[test] + fn rotated_wrapped_text_prints_rotated() { + // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. + let mut s = s(); + let header = "Term 1 : Drama : Continuous Assessment"; + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + let pdf = String::from_utf8_lossy(&pdf); + // The page holds only these two cells, so every text block on it is a piece of a header. + let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); + let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); + let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); + // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. + assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); + assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..2d3be7f 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -74,20 +74,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: None } }; - let map_range = |r: RangeRef| -> Option { - let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; - let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { - sheet: gridcraft_formula::SheetSel::Current, - kind: gridcraft_formula::RefKind::Range( - gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, - gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, - ), - }); - match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) { - gridcraft_formula::Expr::Ref(r) => Some(r.range()), - _ => None, - } - }; + let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + let map_range = |r: RangeRef| map_range(r, &e); let map_cell = |c: CellRef| -> Option { match axis { Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)), @@ -196,8 +184,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) { } } +/// Inserting or deleting cells moves the copied block away from the stored source range, so, like +/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells). +fn cancel_copy_mode(s: &mut Session) { + let uid = s.doc().map(|d| d.uid).ok(); + if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) { + s.clipboard = None; + } +} + fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + check_lines_protection(s, p, axis, at, count, true)?; let sheet = target_sheet(s, p)?; // Refuse to push data off the sheet. let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; @@ -207,8 +205,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into())); } } - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); + cx.protection_checked = true; { let sh = cx.sheet_mut(sheet)?; match axis { @@ -230,14 +229,18 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { let (at, count) = lines(s, p, axis)?; + check_lines_protection(s, p, axis, at, count, false)?; let sheet = target_sheet(s, p)?; - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); + cx.protection_checked = true; { let sh = cx.sheet_mut(sheet)?; let block = match axis { @@ -254,7 +257,9 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn insert_cells(s: &mut Session, p: &Json) -> Result { @@ -265,27 +270,24 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let right = shift == "right"; - edit(s, |cx| { + let out = edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if right { sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64); } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64); } - // Formulas referring to cells that moved: approximate with a whole-sheet move edit. - let moved = if right { - RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width())) - } else { - RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col)) - }; + shift_cell_features(sh, &e); let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); - let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 }); + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn delete_cells(s: &mut Session, p: &Json) -> Result { @@ -296,9 +298,10 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let left = shift == "left"; - edit(s, |cx| { + let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); { let sh = cx.sheet_mut(sheet)?; @@ -308,19 +311,15 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64)); } + shift_cell_features(sh, &e); } - // References into the deleted block become #REF!; the rest move. - let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col }; - let _ = deleted; - let moved_from = if left { - RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1)) - } else { - RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) - }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col }); + // References into the deleted cells become #REF!; the rest move. + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) - }) + })?; + cancel_copy_mode(s); + Ok(out) } fn insert_sheet(s: &mut Session, p: &Json) -> Result { @@ -359,6 +358,39 @@ fn edit_doc(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result) - super::commit(d, f) } +/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and +/// deleted lines must not hold locked cells. +fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> { + let sheet = target_sheet(s, p)?; + let wb = &s.doc()?.wb; + let Some(sh) = wb.sheet(sheet) else { return Ok(()) }; + let Some(pr) = &sh.protection else { return Ok(()) }; + let allowed = match (axis, insert) { + (Axis::Rows, true) => pr.insert_rows, + (Axis::Rows, false) => pr.delete_rows, + (Axis::Cols, true) => pr.insert_columns, + (Axis::Cols, false) => pr.delete_columns, + }; + let locked = |st: gridcraft_model::StyleId| wb.styles.get(st).protection.locked; + let refuse = || EngineError::Other(PROTECTED.into()); + if !allowed { + return Err(refuse()); + } + if insert { + return Ok(()); + } + let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 }); + let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols }; + if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) { + return Err(refuse()); + } + let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) }; + if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) { + return Err(refuse()); + } + Ok(()) +} + fn delete_sheet(s: &mut Session, p: &Json) -> Result { let i = target_sheet(s, p)?; let d = s.doc()?; @@ -392,6 +424,13 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { { *sc -= 1; } + // Names referring to the sheet become #REF!. + if let Ok(e) = gridcraft_formula::parse(&n.formula) { + let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name); + if ne != e { + n.formula = gridcraft_formula::print(&ne); + } + } } if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i { cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)); @@ -461,10 +500,31 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { if copy { let mut sh = (*src).clone(); let base = sh.name.clone(); - let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base); + let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone()); sh.name = name.chars().take(31).collect(); sh.tables.clear(); // table names must be unique let at = to.min(n); + // The copy gets its own copies of the sheet's names, referring to the copy. + let copies: Vec = cx + .wb + .names + .iter() + .filter(|nm| nm.scope == Some(i)) + .map(|nm| { + let formula = gridcraft_formula::parse(&nm.formula) + .map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name))) + .unwrap_or_else(|_| nm.formula.clone()); + gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() } + }) + .collect(); + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + cx.wb.names.extend(copies); cx.wb.sheets.insert(at, Arc::new(sh)); cx.wb.active_sheet = at; } else { @@ -472,6 +532,17 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { let at = to.min(n - 1); cx.wb.sheets.insert(at, sh); cx.wb.active_sheet = at; + // Sheet-level names follow their sheet. + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() { + *sc = match *sc { + x if x == i => at, + x if i < x && x <= at => x - 1, + x if at <= x && x < i => x + 1, + x => x, + }; + } + } } cx.structural = true; Ok(Json::Null) @@ -538,3 +609,53 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result { s.doc_mut()?.wb_switch_sheet(i as usize); ok() } + +/// Where a range of the edited sheet goes after a structural edit (`None` = deleted). +fn map_range(r: RangeRef, e: &Edit) -> Option { + let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { + sheet: gridcraft_formula::SheetSel::Current, + kind: gridcraft_formula::RefKind::Range( + gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, + gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, + ), + }); + match gridcraft_formula::adjust::adjust(expr, "S", "S", e) { + gridcraft_formula::Expr::Ref(r) => Some(r.range()), + _ => None, + } +} + +/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional +/// format, validation, table, filter and print-area ranges, notes, links and sparklines. +fn shift_cell_features(sh: &mut Sheet, e: &Edit) { + let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start); + sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect(); + for cf in sh.cond_formats.iter_mut() { + cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.cond_formats.retain(|cf| !cf.ranges.is_empty()); + for dv in sh.validations.iter_mut() { + dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.validations.retain(|dv| !dv.ranges.is_empty()); + for t in sh.tables.iter_mut() { + t.range = map_range(t.range, e).unwrap_or_default(); + } + sh.tables.retain(|t| t.range != RangeRef::default()); + if let Some(af) = sh.autofilter.as_mut() { + match map_range(af.range, e) { + Some(r) => af.range = r, + None => sh.autofilter = None, + } + } + sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + for sp in sh.sparklines.iter_mut() { + if let Some(c) = map_cell(sp.cell) { + sp.cell = c; + } + } + if let Some(pa) = sh.print.print_area { + sh.print.print_area = map_range(pa, e); + } +} diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 111225d..c0a61df 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -489,8 +489,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { for (c, v) in cells.iter().zip(sc.values.iter()) { let input = super::edit::json_to_input(v); - let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned(); - let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?; + let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?; let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(*c, cell), diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 24df503..e32422a 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -31,11 +31,28 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a +/// protected sheet. A spilled cell follows its anchor. +pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { + if !sheet.is_protected() { + return false; + } + let at = match sheet.cell(c) { + Some(x) if x.formula.is_some() => c, + _ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) { + Some((anchor, _)) => *anchor, + None => return false, + }, + }; + wb.styles.get(sheet.style_id(at)).protection.hidden +} + /// The text a cell shows (full precision General; the grid narrows General to fit the column). pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let v = sheet.value(c); if sheet.show_formulas && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) + && !formula_hidden(wb, sheet, c) { return format!("={}", f.text); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1c49414..1bca5e3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13,6 +13,77 @@ fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } +#[test] +fn entering_empty_cells_preserves_whole_sheet_formatting() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "existing"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:XFD1048576"})).unwrap(); + s.execute("home.alignCenter", json!({})).unwrap(); + let border = gridcraft_model::BorderLine { style: gridcraft_model::BorderStyle::Thin, color: gridcraft_model::Color::Auto }; + s.execute("home.formatCells", json!({"style": {"borders": {"top": border, "bottom": border, "left": border, "right": border}}})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B1").unwrap())).clone() + }; + assert_eq!(expected.align.h, gridcraft_model::HAlign::Center); + for (cell, input) in [("A1", "edited"), ("B1", "new"), ("C1", "=1+2"), ("XFD1048576", "edge")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("range.setValues", json!({"range": "D1", "values": [["text", 7, true]]})).unwrap(); + for cell in ["D1", "E1", "F1"] { + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(cell).unwrap())), &expected, "{cell}"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("D1").unwrap())), &expected); + assert_eq!(d.wb.active().unwrap().cells.len(), 7); + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&d.wb).unwrap()).unwrap(); + for cell in ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "XFD1048576"] { + assert_eq!( + reopened.styles.get(reopened.active().unwrap().style_id(CellRef::parse(cell).unwrap())), + &expected, + "{cell} after XLSX round trip" + ); + } +} + +#[test] +fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() { + let mut s = s(); + s.execute("home.bold", json!({"range": "B:B", "on": true})).unwrap(); + s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@", "font": {"italic": true}}})).unwrap(); + s.execute("home.fontColor", json!({"range": "B3", "color": "#FF0000"})).unwrap(); + let expected = { + let d = s.doc().unwrap(); + d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse("B3").unwrap())).clone() + }; + s.execute("cell.set", json!({"cell": "B3", "input": "=1+2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "00123"})).unwrap(); + s.execute("edit.paste", json!({"at": "D3", "text": "=2+3\t00456"})).unwrap(); + assert_eq!(v(&s, "B3"), Value::text("=1+2")); + assert_eq!(v(&s, "C3"), Value::text("00123")); + assert_eq!(v(&s, "D3"), Value::text("=2+3")); + assert_eq!(v(&s, "E3"), Value::text("00456")); + s.execute("cell.set", json!({"cell": "B4", "input": "12%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B3").unwrap())), &expected); + for cell in ["C3", "D3", "E3"] { + let st = d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())); + assert!(st.font.italic); + assert!(!st.font.bold, "row style takes precedence over column style"); + assert_eq!(st.num_fmt.as_str(), "@"); + } + let percent = d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())); + assert!(percent.font.bold); + assert_ne!(percent.num_fmt.as_str(), "General"); +} + #[test] fn enter_values_and_formulas() { let mut s = s(); @@ -39,6 +110,81 @@ fn undo_redo() { assert!(s.doc().unwrap().is_dirty()); } +#[test] +fn no_border_clears_only_shared_edges_and_undo_restores_them() { + use gridcraft_core::RangeRef; + use gridcraft_model::{BorderLine, Borders}; + + for (range, neighbors) in [ + ("B2", &[("B1", "bottom"), ("B3", "top"), ("A2", "right"), ("C2", "left")][..]), + ( + "B2:C3", + &[("B1", "bottom"), ("C1", "bottom"), ("B4", "top"), ("C4", "top"), ("A2", "right"), ("A3", "right"), ("D2", "left"), ("D3", "left")][..], + ), + ] { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]]})).unwrap(); + s.execute("cell.set", json!({"cell": "D4", "input": "=SUM(A1:C1)"})).unwrap(); + s.execute("home.bold", json!({"range": "A1:D4", "on": true})).unwrap(); + for preset in ["all", "diagonalDown"] { + s.execute("home.borders", json!({"range": "A1:D4", "preset": preset, "style": "double", "color": "#CC3344"})).unwrap(); + } + let before: Vec<_> = RangeRef::parse("A1:D4").unwrap().iter().map(|c| s.execute("cell.get", json!({"cell": c.a1()})).unwrap()).collect(); + s.execute("home.borders", json!({"range": range, "preset": "none"})).unwrap(); + let selected = RangeRef::parse(range).unwrap(); + for original in &before { + let address = original["cell"].as_str().unwrap(); + let mut expected = original.clone(); + if selected.contains(CellRef::parse(address).unwrap()) { + expected["style"]["borders"] = json!(Borders::default()); + } else if let Some((_, edge)) = neighbors.iter().find(|(cell, _)| *cell == address) { + expected["style"]["borders"][*edge] = json!(BorderLine::default()); + } + assert_eq!(s.execute("cell.get", json!({"cell": address})).unwrap(), expected, "clearing {range}, cell {address}"); + } + s.execute("edit.undo", json!({})).unwrap(); + for original in before { + assert_eq!(s.execute("cell.get", json!({"cell": original["cell"]})).unwrap(), original); + } + } +} + +#[test] +fn no_border_handles_sheet_corners_without_creating_out_of_bounds_cells() { + for (range, corner, neighbors) in [ + ("A1:B2", "A1", [("B1", "left"), ("A2", "top")]), + ("XFC1048575:XFD1048576", "XFD1048576", [("XFC1048576", "right"), ("XFD1048575", "bottom")]), + ] { + let mut s = s(); + s.execute("home.borders", json!({"range": range, "preset": "all"})).unwrap(); + s.execute("home.borders", json!({"range": corner, "preset": "none"})).unwrap(); + for (neighbor, edge) in neighbors { + assert_eq!(s.execute("cell.get", json!({"cell": neighbor})).unwrap()["style"]["borders"][edge]["style"], "None"); + } + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(sh.cells.iter().all(|(c, _)| c.is_valid())); + assert!(sh.cells.iter().count() <= 4); + } +} + +#[test] +fn borders_respect_sheet_formatting_protection() { + let mut s = s(); + s.execute("home.borders", json!({"range": "A1:B2", "preset": "all"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_count = s.doc().unwrap().undo.len(); + for preset in ["none", "left"] { + assert!(s.execute("home.borders", json!({"range": "A1", "preset": preset})).is_err()); + assert_eq!(s.doc().unwrap().wb.styles, before.styles); + assert_eq!(s.doc().unwrap().wb.active().unwrap().cells, before.active().unwrap().cells); + assert_eq!(s.doc().unwrap().undo.len(), undo_count); + } + s.execute("review.protectSheet", json!({"formatCells": true})).unwrap(); + s.execute("home.borders", json!({"range": "A1", "preset": "none"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["style"]["borders"]["left"]["style"], "None"); +} + #[test] fn copy_paste_shifts_formulas() { let mut s = s(); @@ -70,6 +216,27 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn inserting_or_deleting_cells_cancels_copy_mode() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("edit.cut", json!({"range": "3:3"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "3:3"})).unwrap(); + assert!(s.clipboard.is_none()); + // The cut data is now in row 4; a paste must not move the new blank row 3 instead. + assert!(s.execute("edit.paste", json!({"at": "A10"})).is_err()); + assert_eq!(v(&s, "A4"), Value::Number(3.0)); + s.execute("edit.copy", json!({"range": "A:A"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "B:B"})).unwrap(); + assert!(s.clipboard.is_none()); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + s.execute("home.insertCells", json!({"range": "A1", "shift": "down"})).unwrap(); + assert!(s.clipboard.is_none()); + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("home.deleteCells", json!({"range": "A1", "shift": "up"})).unwrap(); + assert!(s.clipboard.is_none()); +} + #[test] fn fill_series() { let mut s = s(); @@ -84,6 +251,53 @@ fn fill_series() { assert_eq!(v(&s, "C2"), Value::from("Item 10")); } +#[test] +fn insert_delete_cells_adjust() { + let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUM(A1:A5)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A4*10"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "=SUM(A1:B5)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Fourth", "refersTo": "=Sheet1!$A$4"})).unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "A4:A5", "rule": {"type": "cellIs", "operator": "greater", "value": "3", "preset": "redText"}}), + ) + .unwrap(); + s.execute("data.validation", json!({"range": "A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:A5", "type": "line"})).unwrap(); + // Insert A3 shifting down: ranges across the insertion grow, references below move. + s.execute("home.insertCells", json!({"range": "A3", "shift": "down"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A6)"); + assert_eq!(formula(&mut s, "C2"), "=A5*10"); + assert_eq!(formula(&mut s, "C3"), "=SUM(A1:B5)"); + assert_eq!(v(&s, "C2"), Value::Number(40.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$A$5"); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5:A6"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A6"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$6"); + // Delete A5 (the 4) shifting up: references to it become #REF!, those below move up. + s.execute("home.deleteCells", json!({"range": "A5", "shift": "up"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A5)"); + assert_eq!(formula(&mut s, "C2"), "=#REF!*10"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "#REF!"); + assert_eq!(v(&s, "C1"), Value::Number(11.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A5"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$5"); + // Shifting right and left. + s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "E3", "input": "=G1+SUM(E1:G1)"})).unwrap(); + s.execute("home.insertCells", json!({"range": "F1", "shift": "right"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=H1+SUM(E1:H1)"); + s.execute("home.deleteCells", json!({"range": "E1:F1", "shift": "left"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=F1+SUM(E1:F1)"); + assert_eq!(v(&s, "E3"), Value::Number(8.0)); +} + #[test] fn sort_and_filter() { let mut s = s(); @@ -97,6 +311,100 @@ fn sort_and_filter() { assert_eq!(r["hiddenRows"], 1); } +/// 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(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1+D1"})).unwrap(); + s.execute("range.setValues", json!({"range": "D1", "values": [[10], [20], [30]]})).unwrap(); + s.execute("home.lockCell", json!({"range": "D1:D3", "on": false})).unwrap(); + s.execute("home.lockCell", json!({"range": "10:10", "on": false})).unwrap(); + s.execute("review.protectSheet", extra).unwrap(); + s +} + +#[test] +fn protection_refuses_edits_of_locked_cells() { + let refused = |s: &mut Session, id: &str, p: serde_json::Value| { + let before = s.doc().unwrap().wb.clone(); + let e = s.execute(id, p).expect_err(id).to_string(); + assert!(e.contains("protected sheet"), "{id}: {e}"); + assert_eq!(*s.doc().unwrap().wb, *before, "{id} changed the workbook"); + }; + let mut s = protected(json!({})); + refused(&mut s, "cell.set", json!({"cell": "A1", "input": "9"})); + refused(&mut s, "edit.clearContents", json!({"range": "A1:A3"})); + refused(&mut s, "edit.clearAll", json!({"range": "A1"})); + refused(&mut s, "range.setValues", json!({"range": "A2", "values": [[9]]})); + refused(&mut s, "range.fill", json!({"range": "A5:A6", "input": "x"})); + refused(&mut s, "edit.fillDown", json!({"range": "A1:A3"})); + refused(&mut s, "edit.fillRight", json!({"range": "A1:B1"})); + refused(&mut s, "edit.fillUp", json!({"range": "A1:A3"})); + refused(&mut s, "edit.fillLeft", json!({"range": "A1:B1"})); + refused(&mut s, "edit.autoFill", json!({"source": "A1", "target": "A1:A5"})); + s.execute("edit.copy", json!({"range": "D1"})).unwrap(); + refused(&mut s, "edit.paste", json!({"at": "A2"})); + refused(&mut s, "edit.paste", json!({"at": "A2", "text": "x\ty"})); + refused(&mut s, "home.deleteRows", json!({"rows": "2:2"})); + refused(&mut s, "home.deleteColumns", json!({"cols": "A:A"})); + refused(&mut s, "home.insertRows", json!({"rows": "1:1"})); + refused(&mut s, "home.deleteCells", json!({"range": "A1", "shift": "up"})); + refused(&mut s, "home.insertCells", json!({"range": "A1", "shift": "down"})); + refused(&mut s, "edit.replace", json!({"what": "2", "with": "5"})); + refused(&mut s, "data.goalSeek", json!({"set": "B1", "to": 50, "changing": "A1"})); + // Unlocked cells stay editable. + s.execute("edit.paste", json!({"at": "D2"})).unwrap(); + assert_eq!(v(&s, "D2"), Value::Number(10.0)); + s.execute("range.setValues", json!({"range": "D2", "values": [[5]]})).unwrap(); + s.execute("edit.fillDown", json!({"range": "D1:D3"})).unwrap(); + assert_eq!(v(&s, "D3"), Value::Number(10.0)); + s.execute("edit.clearContents", json!({"range": "D3"})).unwrap(); + assert_eq!(v(&s, "D3"), Value::Empty); + s.execute("data.goalSeek", json!({"set": "B1", "to": 50, "changing": "D1"})).unwrap(); + assert!((v(&s, "B1").as_f64().unwrap() - 50.0).abs() < 0.001); + s.execute("edit.replace", json!({"what": "49", "with": "48", "wholeCell": true})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); +} + +#[test] +fn protection_allows_permitted_row_and_column_edits() { + let mut s = protected(json!({"insertRows": true, "deleteRows": true, "insertColumns": true})); + // Inserting shifts locked cells, which the protection allows. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(1.0)); + s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap(); + // Deleting a row with locked cells isn't; deleting an unlocked row is. + assert!(s.execute("home.deleteRows", json!({"rows": "2:2"})).is_err()); + s.execute("home.deleteRows", json!({"rows": "11:11"})).unwrap(); + assert!(s.execute("home.deleteColumns", json!({"cols": "Z:Z"})).is_err()); +} + +#[test] +fn hidden_formulas_stay_hidden_on_protected_sheets() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)*7"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap(); + s.execute("formulas.showFormulas", json!({"on": true})).unwrap(); + let text = |s: &Session, a: &str| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + crate::display::cell_text(&d.wb, sh, CellRef::parse(a).unwrap()) + }; + assert_eq!(text(&s, "A1"), "=SEQUENCE(3)*7"); + assert_eq!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).unwrap()["count"], 1); + s.execute("review.protectSheet", json!({})).unwrap(); + assert_eq!(text(&s, "A1"), "7"); + assert_eq!(text(&s, "B1"), "=1+1"); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(crate::display::formula_hidden(&d.wb, sh, CellRef::parse("A2").unwrap())); + assert!(!crate::display::formula_hidden(&d.wb, sh, CellRef::parse("B1").unwrap())); + assert!(s.execute("edit.find", json!({"what": "SEQUENCE", "all": true})).is_err()); + let r = s.execute("edit.find", json!({"what": "7", "all": true})).unwrap(); + assert_eq!(r["results"][0]["value"], "7"); +} + #[test] fn formatting_and_styles() { let mut s = s(); @@ -110,6 +418,23 @@ fn formatting_and_styles() { s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap(); } +#[test] +fn spill_references_roundtrip_through_xlsx() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SEQUENCE(4)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=sum(b1#)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Seq", "refersTo": "=Sheet1!$B$1#"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=MAX(Seq)"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$B$1#"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); +} + #[test] fn tables_and_structured_refs() { let mut s = s(); @@ -121,6 +446,65 @@ fn tables_and_structured_refs() { assert_eq!(v(&s, "B4"), Value::Number(7.0)); } +#[test] +fn create_names_from_selection() { + let names = |s: &Session| -> Vec<(String, String)> { + let mut v: Vec<_> = s.doc().unwrap().wb.names.iter().map(|n| (n.name.clone(), n.formula.clone())).collect(); + v.sort(); + v + }; + let named = |n: &str, f: &str| (n.to_string(), f.to_string()); + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["", "Jan", "Feb", ""], ["North", 1, 2, "N"], ["South", 3, 4, "S"], ["", "First", "Second", ""]]}), + ) + .unwrap(); + s.execute("formulas.createFromSelection", json!({"range": "A1:D4", "top": false, "bottom": true, "right": true})).unwrap(); + assert_eq!( + names(&s), + [named("First", "Sheet1!$B$1:$B$3"), named("N", "Sheet1!$A$2:$C$2"), named("S", "Sheet1!$A$3:$C$3"), named("Second", "Sheet1!$C$1:$C$3")] + ); + // Existing names are kept unless replacing is asked for. + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:C3", "top": true, "left": true})).unwrap(); + assert_eq!(r["created"], 4); + let r = s.execute("formulas.createFromSelection", json!({"range": "B1:C3", "top": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(0), json!(["Jan", "Feb"]))); + s.execute("cell.set", json!({"cell": "B1", "input": "N"})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "B1:B3", "replace": true})).unwrap(); + assert_eq!(r["created"], 1); + assert!(names(&s).contains(&named("N", "Sheet1!$B$2:$B$3"))); + assert!(names(&s).contains(&named("North", "Sheet1!$B$2:$C$2"))); +} + +#[test] +fn names_that_look_like_references_are_refused() { + let mut s = s(); + for bad in ["R1C1", "r2", "C3", "RC", "R", "c", "rc12", "A1", "XFD1048576", "1st"] { + assert!(s.execute("formulas.defineName", json!({"name": bad, "refersTo": "=1"})).is_err(), "{bad}"); + } + for good in ["R1C1X", "Rate", "RCx", "Rx", "_R1", "Cost"] { + s.execute("formulas.defineName", json!({"name": good, "refersTo": "=1"})).unwrap(); + } +} + +#[test] +fn lambda_names_keep_their_case_in_calls() { + let mut s = s(); + s.execute("formulas.defineName", json!({"name": "Double", "refersTo": "=LAMBDA(x,x*2)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Quad", "refersTo": "=LAMBDA(x,Double(Double(x)))"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "=double(4)+Quad(1)+sum(1)"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)"); + assert_eq!(v(&s, "A1"), Value::Number(13.0)); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "=Double(4)+Quad(1)+SUM(1)"); + let quad = s.doc().unwrap().wb.names.iter().find(|n| n.name == "Quad").unwrap().formula.clone(); + assert_eq!(quad, "LAMBDA(x,Double(Double(x)))"); + assert_eq!(v(&s, "A1"), Value::Number(13.0)); +} + #[test] fn sheets() { let mut s = s(); @@ -134,6 +518,51 @@ fn sheets() { assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["formula"], "='My Data'!A1*2"); } +#[test] +fn sheet_operations_keep_names_on_their_sheets() { + let names = |s: &Session| -> Vec<(String, Option, String)> { + let wb = &s.doc().unwrap().wb; + let mut v: Vec<_> = + wb.names.iter().map(|n| (n.name.clone(), n.scope.and_then(|i| wb.sheet(i)).map(|s| s.name.clone()), n.formula.clone())).collect(); + v.sort(); + v + }; + let named = |n: &str, scope: Option<&str>, f: &str| (n.to_string(), scope.map(str::to_string), f.to_string()); + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Data!$A$1", "scope": "Data"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=Notes!$B$1", "scope": "Notes"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Total", "refersTo": "=Data!$C$1"})).unwrap(); + // Moving Data (index 1) to the end: its names go with it. + s.execute("sheet.move", json!({"sheet": "Data", "to": 2})).unwrap(); + assert_eq!(names(&s), [named("Rate", Some("Data"), "Data!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "Data!$C$1")]); + // Copying Data to the front copies its sheet-level names, referring to the copy. + s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets[0].name, "Data (2)"); + assert_eq!( + names(&s), + [ + named("Rate", Some("Data"), "Data!$A$1"), + named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), + named("Rate", Some("Notes"), "Notes!$B$1"), + named("Total", None, "Data!$C$1") + ] + ); + // Deleting Data removes its names; names referring to it become #REF!. + s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap(); + assert_eq!( + names(&s), + [named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Rate", Some("Notes"), "Notes!$B$1"), named("Total", None, "#REF!")] + ); + // Deleting a name in one scope keeps the others. + s.execute("formulas.deleteName", json!({"name": "rate", "scope": "Notes"})).unwrap(); + assert_eq!(names(&s), [named("Rate", Some("Data (2)"), "'Data (2)'!$A$1"), named("Total", None, "#REF!")]); + assert!(s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Notes"})).is_err()); + s.execute("formulas.deleteName", json!({"name": "Total", "scope": "Workbook"})).unwrap(); + assert_eq!(names(&s).len(), 1); +} + #[test] fn xlsx_roundtrip_through_engine() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 734ff5c..ae082a6 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -56,6 +56,12 @@ pub enum Edit { Delete { axis: Axis, at: u32, count: u32 }, /// Cut-paste of a block: references into `from` now point into the block at `to` (top-left). Move { from: RangeRef, to_row: u32, to_col: u32 }, + /// Insert Cells shifting down (`Rows`) or right (`Cols`): the cells of `range` and those + /// below (right of) it move by its height (width), within its columns (rows) only. + InsertCells { axis: Axis, range: RangeRef }, + /// Delete Cells shifting up (`Rows`) or left (`Cols`): `range` goes and the cells below + /// (right of) it move back by its height (width), within its columns (rows) only. + DeleteCells { axis: Axis, range: RangeRef }, } /// Adjusts a formula living on sheet `host` after `edit` on sheet `target`. @@ -158,6 +164,21 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { }; Some(Reference { sheet: r.sheet.clone(), kind }) } + Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => { + // Like inserting/deleting lines, for references that lie within the shifted + // columns (rows); Excel leaves references reaching outside them as they are. + let span = r.range(); + let (inside, at, count) = match axis { + Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()), + Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()), + }; + if !inside { + return Some(r.clone()); + } + let axis = *axis; + let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + adjust_ref(r, &lines) + } Edit::Move { from, to_row, to_col } => { let dr = *to_row as i64 - from.start.row as i64; let dc = *to_col as i64 - from.start.col as i64; diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 51e5068..39f2511 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -88,6 +88,9 @@ pub enum UnOp { Percent, /// Implicit intersection `@`. At, + /// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the + /// cell (`ANCHORARRAY` in files). + Spill, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index b6143ba..14b45d9 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result, LexError> { push(&mut out, Tok::Sheet(a, b), j + 1); i = j + 1; } + '#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => { + // Spill range operator right after a reference: `A1#`. + push(&mut out, Tok::Op("#"), i + 1); + i += 1; + } '#' => { // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). let rest = src.get(start..).unwrap_or(""); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index f501402..b92e2e6 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -223,7 +223,14 @@ impl Parser { Tok::LBrace => self.array(), Tok::Func(name) => { let name = normalize_function_name(&name); - let args = self.args()?; + let mut args = self.args()?; + // Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`. + if name == "ANCHORARRAY" + && let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice() + && let Some(r) = args.pop() + { + return Ok(Expr::Unary(UnOp::Spill, Box::new(r))); + } Ok(Expr::Call(name, args)) } Tok::Sheet(a, b) => { @@ -263,7 +270,12 @@ impl Parser { self.next(); return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) })); } - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) })); + let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }); + if *self.peek() == Tok::Op("#") { + self.next(); + return Ok(Expr::Unary(UnOp::Spill, Box::new(cell))); + } + return Ok(cell); } let second = match self.peek_at(1) { Tok::Word(w2) => Some(w2.clone()), diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 0aa223e..12e785f 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { write_expr(s, x, r1c1); s.push('%'); } + UnOp::Spill => { + write_expr(s, x, r1c1); + // A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`. + if !matches!(**x, Expr::Error(_)) { + s.push('#'); + } + } }, Expr::Binary(op, a, b) => { write_expr(s, a, r1c1); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index ff84a5b..ee89070 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -52,6 +52,22 @@ fn normalises() { assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } +#[test] +fn spill_operator() { + for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] { + assert_eq!(rt(f), f); + } + assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _))); + // Files spell it ANCHORARRAY. + assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#"); + assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)"); + // Only a cell can spill. + assert!(parse("A1:B2#").is_err()); + assert!(parse("Name#").is_err()); + // Deleting the anchor's sheet leaves #REF!. + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data")), "SUM(#REF!)"); +} + #[test] fn precedence() { let e = parse("1+2*3").unwrap(); @@ -120,6 +136,24 @@ fn insert_delete() { assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); } +#[test] +fn insert_delete_cells() { + let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap(); + let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e)); + // Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't. + let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); + // Insert B2 shifting right. + let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") }; + assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)"); + // Delete A3:A4 shifting up: references into it become #REF!, those below move up. + let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); + // Delete B1:C2 shifting left. + let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") }; + assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); +} + #[test] fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index f1f1bec..258aea6 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64); + let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64); num_val(ymd_serial(sys, y, mo, dd)? as f64) } @@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) + num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64) } fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -652,6 +652,36 @@ mod tests { is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month + // overflow through it. + assert_eq!(d(1900.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 3.0, 0.0), 60.0); + assert_eq!(d(1900.0, 2.0, 28.0), 59.0); + assert_eq!(d(1900.0, 3.0, 1.0), 61.0); + assert_eq!(d(1900.0, 2.0, 30.0), 61.0); + assert_eq!(d(1900.0, 1.0, 60.0), 60.0); + assert_eq!(d(1900.0, 4.0, -31.0), 60.0); + assert_eq!(d(1901.0, -10.0, 29.0), 60.0); + assert_eq!(d(0.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 1.0, 0.0), 0.0); + close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0); + close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0); + close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0); + close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0); + is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value); + // EDATE and EOMONTH land on it as the end of February 1900. + close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0); + close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0); + close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0); + close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0)); + close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + } + #[test] fn datedif_units() { let s = n(d(2001.0, 6.0, 1.0)); @@ -755,5 +785,16 @@ mod tests { close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0); let spec = crate::lookup("YEAR").unwrap(); close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0); + // No fictitious day here: 1904 is a real leap year and 1900 is before the epoch. + let spec = crate::lookup("DATE").unwrap(); + close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0); + is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num); + let spec = crate::lookup("EOMONTH").unwrap(); + close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0); + let spec = crate::lookup("DATEVALUE").unwrap(); + close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0); + is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value); } } diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab3..8196f5a 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1006,8 +1006,9 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } -/// SUMIF / AVERAGEIF: matched numbers from the sum range, which takes the criteria range's -/// shape anchored at its own top-left. +/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. +/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; +/// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index fca5dc4..5296c48 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -235,7 +235,9 @@ pub(crate) fn round_half_away(x: f64, digits: i32) -> f64 { // value = 0.d1d2…d15 × 10^(e+1); keep the first `keep` digits. let keep = e + 1 + digits; if keep >= d.len() as i64 { - return x; + // Nothing to round off, but Excel still keeps only 15 significant digits, which drops binary noise + // such as 0.1 + 0.2 = 0.30000000000000004. + return to_sig_digits(x, d.len()); } if keep < 0 { return 0.0; @@ -416,6 +418,8 @@ mod tests { assert_eq!(round_half_away(1234.5678, -2), 1200.0); assert_eq!(round_half_away(0.285, 2), 0.29); assert_eq!(round_half_away(1.005, 2), 1.01); + assert_eq!(round_half_away(0.1 + 0.2, 15), 0.3); + assert_eq!(round_half_away(0.1 + 0.2, 20), 0.3); } #[test] diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index 1652501..d6a9c45 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -120,6 +120,40 @@ impl CellStore { pub fn has(&self, c: CellRef) -> bool { self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some()) } + /// Positions whose cells differ between `self` and `other` (stored on one side only, or not + /// equal). Bands both stores share are skipped, so comparing an edited copy with the store + /// it was cloned from only visits the bands the edit touched. + pub fn diff(&self, other: &CellStore) -> Vec { + let empty = Band::default(); + let mut keys: Vec = self.bands.keys().chain(other.bands.keys()).copied().collect(); + keys.sort_unstable(); + keys.dedup(); + let mut out = Vec::new(); + for k in keys { + let (a, b) = (self.bands.get(&k), other.bands.get(&k)); + if let (Some(a), Some(b)) = (a, b) + && Arc::ptr_eq(a, b) + { + continue; + } + let (a, b) = (a.map_or(&empty, |x| x), b.map_or(&empty, |x| x)); + for (row, cols) in &a.rows { + for (col, cell) in cols { + if b.rows.get(row).and_then(|r| r.get(col)) != Some(cell) { + out.push(CellRef::new(*row, *col)); + } + } + } + for (row, cols) in &b.rows { + for col in cols.keys() { + if !a.rows.get(row).is_some_and(|r| r.contains_key(col)) { + out.push(CellRef::new(*row, *col)); + } + } + } + } + out + } /// Removes every cell in `r` and returns them. pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> { let keys: Vec = self.iter_range(r).map(|(c, _)| c).collect(); @@ -210,6 +244,20 @@ mod tests { assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0)); } + #[test] + fn diff_visits_changed_bands() { + let mut s = CellStore::new(); + for r in 0..1000 { + s.set(c(r, 0), Cell::value((r as f64).into())); + } + let snap = s.clone(); + assert!(s.diff(&snap).is_empty()); + s.set(c(5, 0), Cell::value(99.0.into())); + s.set(c(700, 3), Cell::value("new".into())); + s.remove(c(900, 0)); + assert_eq!(snap.diff(&s), [c(5, 0), c(700, 3), c(900, 0)]); + } + #[test] fn range_iteration_order() { let mut s = CellStore::new(); diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 8652b20..6ccef9e 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -3,8 +3,10 @@ use std::sync::Arc; use gridcraft_core::DateSystem; +use gridcraft_formula::Expr; use serde::{Deserialize, Serialize}; +use crate::cell::Formula; use crate::sheet::Sheet; use crate::style::{StyleTable, Theme}; @@ -143,6 +145,53 @@ impl Workbook { .find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name)) .or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name))) } + /// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the + /// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown). + pub fn name_call_case(&self, e: Expr) -> Expr { + spell_calls(e, &self.lambda_names()) + } + /// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a + /// file). + pub fn apply_name_call_case(&mut self) { + let lambdas = self.lambda_names(); + if lambdas.is_empty() { + return; + } + for sheet in self.sheets.iter_mut() { + let fixed: Vec<_> = sheet + .cells + .iter() + .filter_map(|(c, cell)| { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + let ne = spell_calls(e.clone(), &lambdas); + (ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) })) + }) + .collect(); + if fixed.is_empty() { + continue; + } + let sheet = Arc::make_mut(sheet); + for (c, f) in fixed { + if let Some(cell) = sheet.cells.get_mut(c) { + cell.formula = Some(Arc::new(f)); + } + } + } + for n in self.names.iter_mut() { + if let Ok(e) = gridcraft_formula::parse(&n.formula) { + let ne = spell_calls(e.clone(), &lambdas); + if ne != e { + n.formula = gridcraft_formula::print(&ne); + } + } + } + } + /// Names of the defined names holding a LAMBDA (callable like functions). + fn lambda_names(&self) -> Vec { + let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA(")); + self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect() + } /// Table by name across sheets: (sheet index, table index). pub fn table(&self, name: &str) -> Option<(usize, usize)> { for (si, s) in self.sheets.iter().enumerate() { @@ -172,6 +221,20 @@ impl Workbook { } } +/// Calls of the names in `lambdas` spelled as the names are. +fn spell_calls(e: Expr, lambdas: &[String]) -> Expr { + if lambdas.is_empty() { + return e; + } + e.map(&mut |x| match x { + Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) { + Some(l) => Expr::Call(l.clone(), args), + None => Expr::Call(n, args), + }, + other => other, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/border_preview.rs b/crates/ui-egui/src/border_preview.rs new file mode 100644 index 0000000..11ff351 --- /dev/null +++ b/crates/ui-egui/src/border_preview.rs @@ -0,0 +1,82 @@ +//! Original border diagrams, painted with the same strokes as worksheet cells. + +use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use gridcraft_engine::model::{BorderStyle, Borders, Theme}; + +use crate::grid::paint_border; +use crate::theme::{self, Tokens}; + +/// A labelled menu button with a two-by-two diagram to distinguish inside edges +/// from the selection outline. Labels stay available to accessibility tools. +pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response { + let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let t = Tokens::get(ui.ctx()); + let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0)); + let edges = [ + [rect.left_top(), rect.right_top()], + [rect.left_bottom(), rect.right_bottom()], + [rect.left_top(), rect.left_bottom()], + [rect.right_top(), rect.right_bottom()], + [rect.left_center(), rect.right_center()], + [rect.center_top(), rect.center_bottom()], + ]; + use BorderStyle::{Double, None, Thick, Thin}; + let styles = match preset { + "bottom" => [None, Thin, None, None, None, None], + "top" => [Thin, None, None, None, None, None], + "left" => [None, None, Thin, None, None, None], + "right" => [None, None, None, Thin, None, None], + "all" => [Thin; 6], + "outside" => [Thin, Thin, Thin, Thin, None, None], + "thickOutside" => [Thick, Thick, Thick, Thick, None, None], + "doubleBottom" => [None, Double, None, None, None, None], + "thickBottom" => [None, Thick, None, None, None, None], + "topBottom" => [Thin, Thin, None, None, None, None], + "topThickBottom" => [Thin, Thick, None, None, None, None], + "topDoubleBottom" => [Thin, Double, None, None, None, None], + "inside" => [None, None, None, None, Thin, Thin], + _ => [None; 6], + }; + for (points, style) in edges.into_iter().zip(styles) { + if style == None { + ui.painter().extend(egui::Shape::dotted_line(&points, t.text_disabled, 3.0, 0.45)); + } else { + paint_border(ui.painter(), points, style, t.text); + } + } + ui.painter().text(pos2(response.rect.left() + 36.0, response.rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(12.5), t.text); + response +} + +/// Preview only reads the dialog draft. A white worksheet surface keeps automatic +/// and explicit border colors faithful in both light and dark application themes. +pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) { + ui.add_space(8.0); + ui.label("Preview"); + let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview")); + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(area, 4.0, t.grid_bg); + ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let rect = area.shrink2(vec2(30.0, 20.0)); + let edges = [ + (borders.top, [rect.left_top(), rect.right_top()]), + (borders.bottom, [rect.left_bottom(), rect.right_bottom()]), + (borders.left, [rect.left_top(), rect.left_bottom()]), + (borders.right, [rect.right_top(), rect.right_bottom()]), + (borders.diag_down, [rect.left_top(), rect.right_bottom()]), + (borders.diag_up, [rect.left_bottom(), rect.right_top()]), + ]; + for (line, points) in edges.iter().take(4) { + if line.is_none() { + ui.painter().extend(egui::Shape::dotted_line(points, Color32::from_gray(190), 4.0, 0.5)); + } + } + for (line, points) in edges { + if !line.is_none() { + let color = line.color.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK); + paint_border(ui.painter(), points, line.style, color); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a80448a..902ec25 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -160,6 +160,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.formulaBar", "Formula Bar"), ("view.collapseRibbon", "Collapse Ribbon"), ("view.darkMode", "Dark Mode"), + ("view.theme", "Display Theme"), ("view.zoom100", "100%"), ("ui.dialog", "Open Dialog"), ] { @@ -178,6 +179,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "ui.set" => { if let Some(v) = p.get("dark").and_then(Json::as_bool) { app.ui.dark = v; + app.ui.system_theme = false; } if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) { app.ui.formula_bar = v; @@ -403,7 +405,7 @@ pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) { if now < *deadline { return true; } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use `gridcraft-cli snapshot`"})); + let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"})); false }); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6c186d..7753a48 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1087,12 +1087,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: (BorderStyle::Dotted, "dotted"), (BorderStyle::Double, "double"), ] { - if ui.selectable_label(slot.style == s, label).clicked() { + let response = ui.selectable_label(slot.style == s, label); + response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); + if response.clicked() { slot.style = s; } } }); } + let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); + crate::border_preview::sample(ui, &st.borders, &theme); } "Fill" => { let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); @@ -1368,7 +1373,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(n["scope"].as_str().unwrap_or("")); if ui.small_button("Delete").clicked() { - app.run_or_alert("formulas.deleteName", json!({"name": n["name"]})); + app.run_or_alert("formulas.deleteName", json!({"name": n["name"], "scope": n["scope"]})); } }); ui.end_row(); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f..dace534 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -65,7 +65,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; - let a = d.selection.active; + let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); if let Some(c) = sh.cell(a) { if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() { return String::new(); @@ -77,6 +77,9 @@ fn active_input(app: &SheetApp) -> String { if r.contains(a) && let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref()) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) { + return String::new(); + } return format!("={}", f.text); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..9fa1ca1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -312,7 +312,8 @@ fn display_text( pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let rect = ui.available_rect_before_wrap(); + let available = ui.available_rect_before_wrap(); + let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom())); let resp = ui.allocate_rect(rect, Sense::click_and_drag()); app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; @@ -373,7 +374,6 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); let mut cf = CfCache::new(); - let char_w = ui.fonts_mut(|f| f.glyph_width(&FontId::new(11.0 * 96.0 / 72.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), '0')).max(1.0); // Quadrants: (rows, cols, clip). let (sr0, sr1) = geo.scroll_rows(sh); let (sc0, sc1) = geo.scroll_cols(sh); @@ -395,7 +395,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { continue; } let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, char_w, &mut cf, &t); + paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); paint_selection(&p, &geo, sh, &sel, &t, app); } // Freeze lines. @@ -416,33 +416,73 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { paint_headers(&painter, &geo, sh, &sel, &t, &quads); } // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); interact(app, ui, &resp, &geo, sh, &wb); if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { in_cell_editor(app, ui, &geo, sh, &wb); } - filter_menu(app, ui, &geo); - list_picker(app, ui, &geo); - header_menu(app, ui); + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); + vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); if app.session.clipboard.is_some() { app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); } } +/// The worksheet is painted virtually, so it needs its own scrollbar rather than a +/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, right: f32, limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32; + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.id().with("vscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); + let travel = track.height() - thumb_h; + let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + #[allow(clippy::too_many_arguments)] -fn paint_quadrant( - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - rows: (u32, u32), - cols: (u32, u32), - char_w: f32, - cf: &mut CfCache, - t: &Tokens, -) { +fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { let z = geo.z; let (r0, r1) = rows; let (c0, c1) = cols; @@ -560,6 +600,8 @@ fn paint_quadrant( let size = st.font.size * 96.0 / 72.0 * z; let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); let font = FontId::new(size, fam); + // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). + let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); let avail_w = rect.right() - text_left; let (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); if text.is_empty() { @@ -577,7 +619,9 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -622,9 +666,9 @@ fn paint_quadrant( galley = p.layout_no_wrap(s, font.clone(), color); w = galley.size().x; } - // Overflow into empty neighbours for text. + // Overflow into empty neighbours for text (not upright text, whose width is its line height). let mut clip = *rect; - if !numeric && !matches!(v, Value::Number(_)) && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { + if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { let mut right = rect.right(); let mut left = rect.left(); if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { @@ -667,9 +711,23 @@ fn paint_quadrant( VAlign::Bottom => rect.bottom() - gh - 0.5 * z, }; let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if st.align.rotation != 0 && st.align.rotation != 255 { + if rotated { let angle = -(st.align.rotation as f32).to_radians(); - let mut shape = egui::epaint::TextShape::new(pos2(rect.left() + pad, rect.bottom() - pad), galley.clone(), color); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + // Upward text grows up and right from the bottom-left corner; downward text grows down + // and left from its anchor, so that anchor sits one block-height in from the top-left. + let pos = if angle > 0.0 { + pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad) + } else { + pos2(rect.left() + pad, rect.bottom() - pad) + }; + let mut shape = egui::epaint::TextShape::new(pos, galley, color); shape.angle = angle; cp.add(shape); } else { @@ -769,7 +827,7 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s)) } -fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { +pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { let w = style.width(); let (a, b) = (pts[0], pts[1]); match style { @@ -983,6 +1041,8 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { let ctx = ui.ctx().clone(); + // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. + let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); let pointer = ctx.input(|i| i.pointer.clone()); let mods = ctx.input(|i| i.modifiers); let pos = pointer.interact_pos().or(pointer.hover_pos()); @@ -1006,6 +1066,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ctx.set_cursor_icon(CursorIcon::ResizeRow); } else if handle.contains(p) && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } } else if in_cells && app.editor.is_none() && on_border(cur_rect, p) { ctx.set_cursor_icon(CursorIcon::Move); } else if in_cells { @@ -1048,7 +1111,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } return; } - if app.session.draw_tool == "eraser" && (resp.clicked() || resp.dragged()) { + if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { if let Some(p) = pos && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) @@ -1059,7 +1122,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } // Objects (charts, pictures, shapes) take clicks first. - if resp.drag_started() || resp.clicked() { + if resp.drag_started() || primary_clicked { if let Some(p) = pos && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) { @@ -1075,13 +1138,39 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } return; - } else if resp.clicked() || resp.drag_started() { + } else if primary_clicked || resp.drag_started() { app.selected_chart = None; } } + // A double-click is also a click: handle header AutoFit before resize setup returns. + if resp.double_clicked() + && let Some(p) = pos + { + let autofit = if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return; + } + } + // Press: decide the drag mode. - if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) { + if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { // Where the press began (a drag is only recognised after the pointer has moved). let Some(p) = pointer.press_origin().or(pos) else { return }; if in_corner { @@ -1140,6 +1229,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ed.point_cell = Some(c); app.grid.drag = Drag::Select; app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } return; } if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { @@ -1177,12 +1271,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()})); } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); - if resp.clicked() && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { + if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); } // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. if let Some(h) = sh.hyperlinks.get(&c).cloned() - && resp.clicked() + && primary_clicked && !mods.any() { let r = geo.cell_rect(sh, c); @@ -1193,6 +1287,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } app.grid.drag = Drag::Select; app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + // The first recognised drag already includes movement away from the press. + select_to_pointer(app, geo, sh, wb, p); + } return; } @@ -1221,26 +1321,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & ); match app.grid.drag.clone() { Drag::Select => { - if let Some(ed) = app.editor.as_mut() - && ed.point.is_some() - { - if let Some(start) = ed.point_cell { - let r = RangeRef::new(start, pc); - let text = if r.is_single() { r.start.a1() } else { r.a1() }; - let keep = ed.point_cell; - ed.insert_ref(&text); - ed.point_cell = keep; - } - } else { - let anchor = sel.anchor; - let mut ranges: Vec = sel.ranges.iter().map(|r| r.a1()).collect(); - ranges.pop(); - ranges.push(RangeRef::new(anchor, pc).a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": sel.active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = anchor; - } - } + select_to_pointer(app, geo, sh, wb, p); scroll_by(app, auto); } Drag::Rows(anchor) => { @@ -1313,6 +1394,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // Release. if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { match std::mem::take(&mut app.grid.drag) { + Drag::Select if resp.drag_stopped() => { + if let Some(p) = pos { + select_to_pointer(app, geo, sh, wb, p); + } + } Drag::Fill { target: Some(t) } => { let src = sel.current(); if t != src { @@ -1334,29 +1420,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.drag_select = false; } - // Double-click on header edges autofits. + // Double-click the fill handle. if resp.double_clicked() && let Some(p) = pos + && handle.contains(p) { - if in_col_header && let Some((c, _)) = col_edge(geo, sh, p.x) { - let _ = app.run("home.autofitColumnWidth", json!({"cols": RangeRef::cols(c, c).a1()})); - } else if in_row_header && let Some((r, _)) = row_edge(geo, sh, p.y) { - let _ = app.run("home.autofitRowHeight", json!({"rows": format!("{}:{}", r + 1, r + 1)})); - } else if handle.contains(p) { - // Double-click the fill handle: fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let mut last = src.end.row; - while last + 1 < MAX_ROWS - && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) - && last - src.end.row < 1_000_000 - { - last += 1; - } - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let mut last = src.end.row; + while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000 + { + last += 1; + } + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); } } @@ -1386,12 +1465,41 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } } - if resp.clicked() || resp.drag_started() { + if primary_clicked || resp.drag_started() { resp.request_focus(); } keyboard(app, &ctx, resp, geo, sh); } +fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { + let cell = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + if let Some(ed) = app.editor.as_mut() + && ed.point.is_some() + { + if let Some(start) = ed.point_cell { + let range = RangeRef::new(start, cell); + let text = if range.is_single() { range.start.a1() } else { range.a1() }; + let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + ed.insert_ref(&text); + ed.point_cell = Some(start); + } + } else if let Some(d) = app.session.active() { + // Read the current anchor: starting a drag may have just replaced the selection. + let anchor = d.selection.anchor; + let active = d.selection.active; + let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + ranges.pop(); + ranges.push(RangeRef::new(anchor, cell).a1()); + let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = anchor; + } + } +} + fn coalesce_last_undo(app: &mut SheetApp) { // Merge consecutive drag steps into one undo entry. if let Some(d) = app.session.active_mut() @@ -1643,7 +1751,13 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); } -fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { +/// Test the popup itself, not whether the pointer is over any part of the app. +/// A trigger can sit outside its popup, so its opening click must be ignored. +fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { + response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) +} + +fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some((col, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); @@ -1676,9 +1790,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { } let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); + let key = egui::Id::new(("filter_sel", col)); let mut close = false; let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); - area.show(ui.ctx(), |ui| { + let response = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(230.0); if ui.button("↑ Sort Ascending").clicked() { @@ -1691,7 +1806,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { } ui.separator(); ui.label(egui::RichText::new("Filter").strong()); - let key = egui::Id::new(("filter_sel", col)); let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); let all = checks.iter().all(|(_, b)| *b); let mut all_new = all; @@ -1710,7 +1824,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { ui.horizontal(|ui| { if ui.button("Clear Filter").clicked() { let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); - ui.ctx().data_mut(|d| d.remove::>(key)); close = true; } if ui.button("Apply").clicked() { @@ -1722,13 +1835,13 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) }; let _ = app.run("data.filterBy", p); - ui.ctx().data_mut(|d| d.remove::>(key)); close = true; } }); }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { + ui.ctx().data_mut(|d| d.remove::>(key)); app.grid.filter_menu = None; } } @@ -1939,7 +2052,7 @@ fn paint_overlays( let _ = (wb, si); } -fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { +fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); @@ -1952,7 +2065,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); let r = geo.cell_rect(sh, c); let mut close = false; - egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { @@ -1965,15 +2078,15 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { }); }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { app.grid.list_picker = None; } } -fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) { +fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((at, rows)) = app.grid.header_menu else { return }; let mut close = false; - egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); let items: Vec<(&str, &str)> = if rows { @@ -2033,7 +2146,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); - if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) { + if close || popup_dismissed(&response.response, was_open) { app.grid.header_menu = None; } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..9901906 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod border_preview; pub mod chartview; pub mod control; pub mod credits; @@ -34,7 +35,9 @@ pub use control::{ControlRequest, ControlResponse}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// The manual preference, retained when following the system appearance. pub dark: bool, + pub system_theme: bool, pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar: bool, @@ -47,6 +50,7 @@ impl Default for UiState { fn default() -> Self { UiState { dark: false, + system_theme: false, ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar: true, @@ -57,6 +61,28 @@ impl Default for UiState { } } +impl UiState { + pub fn theme_preference(&self) -> egui::ThemePreference { + if self.system_theme { + egui::ThemePreference::System + } else if self.dark { + egui::ThemePreference::Dark + } else { + egui::ThemePreference::Light + } + } + + pub fn theme_mode(&self) -> &'static str { + if self.system_theme { + "system" + } else if self.dark { + "dark" + } else { + "light" + } + } +} + /// Per-sheet view state (scroll position in sheet points). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct SheetView { @@ -104,6 +130,7 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + effective_dark: bool, pub name_box: Option, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). @@ -133,6 +160,7 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + effective_dark: false, name_box: None, shots: control::Shots::default(), selected_chart: None, @@ -183,9 +211,22 @@ impl SheetApp { Ok(Json::Null) } "view.darkMode" => { - self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.dark); + let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; + self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); + self.ui.system_theme = false; Ok(json!({"on": self.ui.dark})) } + "view.theme" => { + match p.get("mode").and_then(Json::as_str) { + Some("system") => self.ui.system_theme = true, + Some(mode @ ("light" | "dark")) => { + self.ui.dark = mode == "dark"; + self.ui.system_theme = false; + } + _ => return Some(Err("theme mode must be system, light, or dark".into())), + } + Ok(json!({"mode": self.ui.theme_mode()})) + } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); @@ -276,7 +317,7 @@ impl SheetApp { pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { let Some(d) = self.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; - let at = d.selection.active; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); let current = sh.cell(at).map(|c| c.input_text()).unwrap_or_default(); let (text, replace) = match text { Some(t) => (t, true), @@ -362,9 +403,12 @@ impl SheetApp { ctx.request_repaint(); } } - if ctx.global_style().visuals.dark_mode != self.ui.dark { - theme::apply(ctx, self.ui.dark); + let preference = self.ui.theme_preference(); + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); } + self.effective_dark = ctx.theme() == egui::Theme::Dark; control::poll(self, ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..69fc880 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -132,6 +132,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add_space(8.0); + file_menu(app, ui); let mut tabs: Vec<&str> = TABS.to_vec(); let ctx_tabs = contextual_tabs(app); tabs.extend(ctx_tabs.iter()); @@ -202,6 +203,32 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +fn file_menu(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); + egui::Popup::menu(&file).show(|ui| { + for (label, id, needs_document) in [ + ("New Workbook", "file.new", false), + ("Open…", "file.open", false), + ("Save", "file.save", true), + ("Save As…", "file.saveAs", true), + ("Close", "file.close", true), + ] { + if id == "file.save" || id == "file.close" { + ui.separator(); + } + if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { + // File operations must include the cell the user is still editing. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert(id, json!({})); + } + ui.close(); + } + } + }); +} + fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { let mut v = Vec::new(); if let Some(d) = app.session.active() @@ -352,6 +379,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if b { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } + ba.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Border presets")); egui::Popup::menu(&ba).show(|ui| { for (label, preset) in [ ("Bottom Border", "bottom"), @@ -369,7 +397,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ("Top and Double Bottom Border", "topDoubleBottom"), ("Inside Borders", "inside"), ] { - if ui.add(egui::Button::new(label).frame(false).min_size(vec2(220.0, 20.0))).clicked() { + if crate::border_preview::preset_button(ui, label, preset).clicked() { app.grid.last_border = preset.to_string(); act(app, "home.borders", json!({"preset": preset})); } @@ -1565,11 +1593,21 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - let mut dark = app.ui.dark; ui.vertical(|ui| { - if ui.checkbox(&mut dark, "Dark Mode").changed() { - app.ui.dark = dark; - } + ui.label("Display theme"); + let mode = app.ui.theme_mode(); + let label = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("display_theme").selected_text(label).width(88.0).show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, label).clicked() { + act(app, "view.theme", json!({"mode": value})); + } + } + }); }); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2dbd8df..290e5b3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -317,25 +317,31 @@ pub fn font_definitions() -> FontDefinitions { } pub fn apply(ctx: &egui::Context, dark: bool) { - let t = if dark { Tokens::dark() } else { Tokens::light() }; - let mut v = if dark { Visuals::dark() } else { Visuals::light() }; - v.panel_fill = t.window; - v.window_fill = t.menu_bg; - v.extreme_bg_color = t.input_bg; - v.selection.bg_fill = t.accent_soft; - v.selection.stroke = Stroke::new(1.0, t.accent); - v.hyperlink_color = t.accent; - v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); - v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); - v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - v.widgets.hovered.weak_bg_fill = t.hover; - v.widgets.active.weak_bg_fill = t.pressed; - v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); - v.window_corner_radius = egui::CornerRadius::same(10); - v.menu_corner_radius = egui::CornerRadius::same(8); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // Install both palettes before System can switch between the style slots. + for theme in [egui::Theme::Light, egui::Theme::Dark] { + let dark = theme == egui::Theme::Dark; + let t = if dark { Tokens::dark() } else { Tokens::light() }; + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.window; + v.window_fill = t.menu_bg; + v.extreme_bg_color = t.input_bg; + v.selection.bg_fill = t.accent_soft; + v.selection.stroke = Stroke::new(1.0, t.accent); + v.hyperlink_color = t.accent; + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + v.widgets.hovered.weak_bg_fill = t.hover; + v.widgets.active.weak_bg_fill = t.pressed; + v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + v.window_corner_radius = egui::CornerRadius::same(10); + v.menu_corner_radius = egui::CornerRadius::same(8); + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; + ctx.set_visuals_of(theme, v); + } + ctx.options_mut(|o| o.fallback_theme = egui::Theme::Light); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 139256f..f1b39fc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -38,8 +38,10 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(11.5); - let lines: Vec<&str> = label.split('\n').collect(); - let text_w = lines.iter().map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x).fold(0.0, f32::max); + let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect(); + let arrow_size = 10.0; + let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 }; + let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max); let w = (text_w + 12.0).max(44.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click()); if resp.is_pointer_button_down_on() { @@ -48,12 +50,20 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ui.painter().rect_filled(rect, 5.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); - for (i, l) in lines.iter().enumerate() { - let mut s = l.to_string(); - if dropdown && i + 1 == lines.len() { - s.push_str(" ▾"); + for (i, line) in lines.iter().enumerate() { + let has_arrow = dropdown && i + 1 == lines.len(); + let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 }; + let left = rect.center().x - line_w / 2.0; + let y = rect.top() + 46.0 + i as f32 * 13.0; + ui.painter().galley(pos2(left, y - line.size().y / 2.0), line.clone(), t.text); + if has_arrow { + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)), + Icon::Chevron, + t.text_dim, + ); } - ui.painter().text(pos2(rect.center().x, rect.top() + 46.0 + i as f32 * 13.0), Align2::CENTER_CENTER, s, font.clone(), t.text); } resp.on_hover_text(tip) } diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/borders.rs new file mode 100644 index 0000000..af8a1a9 --- /dev/null +++ b/crates/ui-egui/tests/borders.rs @@ -0,0 +1,129 @@ +//! Border menu and dialog behavior through real egui controls. + +use egui::{Color32, Shape, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{BorderLine, BorderStyle, Borders, Color}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.run("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + session.run("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn borders(h: &egui_kittest::Harness<'static, SheetApp>, cell: &str) -> Borders { + let doc = h.state().session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + doc.wb.styles.get(sheet.style_id(CellRef::parse(cell).unwrap())).borders +} + +#[test] +fn diagram_menu_applies_left_right_inside_and_outside_then_undoes() { + for label in ["Left Border", "Right Border", "Inside Borders", "Outside Borders"] { + let mut h = harness(); + h.get_by_label("Border presets").click(); + h.run_steps(6); + h.get_by_label(label).click(); + h.run_steps(6); + let a = borders(&h, "A1"); + let b = borders(&h, "B2"); + match label { + "Left Border" => { + assert_eq!(a.left.style, BorderStyle::Thin); + assert!(a.right.is_none() && b.left.is_none() && b.right.is_none()); + } + "Right Border" => { + assert_eq!(b.right.style, BorderStyle::Thin); + assert!(a.left.is_none() && a.right.is_none() && b.left.is_none()); + } + "Inside Borders" => { + assert_eq!(a.right.style, BorderStyle::Thin); + assert_eq!(a.bottom.style, BorderStyle::Thin); + assert_eq!(b.left.style, BorderStyle::Thin); + assert_eq!(b.top.style, BorderStyle::Thin); + assert!(a.left.is_none() && a.top.is_none() && b.right.is_none() && b.bottom.is_none()); + } + _ => { + assert_eq!(a.left.style, BorderStyle::Thin); + assert_eq!(a.top.style, BorderStyle::Thin); + assert_eq!(b.right.style, BorderStyle::Thin); + assert_eq!(b.bottom.style, BorderStyle::Thin); + assert!(a.right.is_none() && a.bottom.is_none() && b.left.is_none() && b.top.is_none()); + } + } + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sheet.value(CellRef::new(0, 0)), Value::Number(1.0)); + assert_eq!(sheet.value(CellRef::new(1, 1)), Value::Number(4.0)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(borders(&h, "A1"), Borders::default()); + assert_eq!(borders(&h, "B2"), Borders::default()); + } +} + +fn colored_borders() -> Borders { + let line = |color| BorderLine { style: BorderStyle::Thin, color: Color::Rgb(color) }; + Borders { + top: line(0xab192d), + bottom: line(0x174faa), + left: line(0x16864d), + right: line(0x985213), + diag_down: line(0x7c2ba2), + diag_up: line(0x248088), + } +} + +fn preview_lines(h: &egui_kittest::Harness<'static, SheetApp>, color: Color32) -> usize { + let preview = h.get_by_label("Border preview").rect(); + h.output() + .shapes + .iter() + .filter(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } => stroke.color == color && points.iter().all(|p| preview.contains(*p)), + _ => false, + }) + .count() +} + +#[test] +fn border_preview_uses_the_colored_draft_until_ok_and_cancel_discards_it() { + let mut h = harness(); + let original = colored_borders(); + h.state_mut().run("home.formatCells", json!({"style": {"borders": original}})).unwrap(); + h.state_mut().open_dialog("formatCells", json!({"tab": "Border"})); + h.run_steps(6); + for line in [original.top, original.bottom, original.left, original.right, original.diag_down, original.diag_up] { + let [r, g, b] = line.color.resolve(&Default::default()).unwrap(); + assert_eq!(preview_lines(&h, Color32::from_rgb(r, g, b)), 1); + } + h.get_by_label("Top double").click(); + h.run_steps(6); + assert_eq!(borders(&h, "A1"), original); + assert_eq!(preview_lines(&h, Color32::from_rgb(0xab, 0x19, 0x2d)), 2); + h.get_by_label(" Cancel ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert_eq!(borders(&h, "A1"), original); + h.state_mut().open_dialog("formatCells", json!({"tab": "Border"})); + h.run_steps(6); + h.get_by_label("Top double").click(); + h.run_steps(6); + h.get_by_label(" OK ").click(); + h.run_steps(6); + assert!(h.state().dialog.is_none()); + assert_eq!(borders(&h, "A1").top.style, BorderStyle::Double); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(borders(&h, "A1"), original); +} diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs new file mode 100644 index 0000000..5f6ca7c --- /dev/null +++ b/crates/ui-egui/tests/file_menu.rs @@ -0,0 +1,76 @@ +//! The shared ribbon exposes basic file operations without requiring a native menu. + +use std::sync::{Arc, Mutex}; + +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::{Services, SheetApp}; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::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(session, services), + ); + h.run_steps(4); + h +} + +fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_label("File").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { + let opened = Arc::new(Mutex::new(0)); + let capture = opened.clone(); + let mut h = harness(Services { + pick_open: Some(Box::new(move || { + *capture.lock().unwrap() += 1; + None + })), + ..Default::default() + }); + h.state_mut().ui.ribbon_tab = "Data".into(); + h.state_mut().ui.ribbon_collapsed = true; + h.run_steps(2); + choose(&mut h, "New Workbook"); + assert_eq!(h.state().session.documents().len(), 2); + choose(&mut h, "Open…"); + assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert_eq!(h.state().ui.ribbon_tab, "Data"); + assert!(h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); +} + +#[test] +fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { + let downloads = Arc::new(Mutex::new(Vec::>::new())); + let capture = downloads.clone(); + let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() }); + h.input_mut().events.push(egui::Event::Text("123".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + choose(&mut h, "Save"); + choose(&mut h, "Save As…"); + let saved = downloads.lock().unwrap(); + assert_eq!(saved.len(), 2); + for bytes in saved.iter() { + let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap(); + assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); + } + drop(saved); + assert!(h.state().editor.is_none()); + choose(&mut h, "Close"); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); +} diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/header_autofit.rs new file mode 100644 index 0000000..8a66738 --- /dev/null +++ b/crates/ui-egui/tests/header_autofit.rs @@ -0,0 +1,116 @@ +//! Header AutoFit and resize gestures, driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness(selection: &str) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session + .execute( + "range.setValues", + json!({"range": "A1", "values": [ + ["Short", "This column has considerably longer content", "Third", "Unchanged"], + ["Two\nlines", "B", "C", "D"], + ["Three\ntext\nlines", "B", "C", "D"] + ]}), + ) + .unwrap(); + session.execute("home.columnWidth", json!({"cols": "A:D", "width": 100})).unwrap(); + session.execute("home.rowHeight", json!({"rows": "1:4", "height": 60})).unwrap(); + session.execute("selection.set", json!({"range": selection})).unwrap(); + 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.state_mut().session.active_mut().unwrap().undo.clear(); + h.state_mut().session.journal.clear(); + h +} + +fn edge(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool, index: u32) -> (Pos2, f32) { + 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 pos = if rows { + pos2(geo.rect.left() + geo.header_w / 2.0, geo.y(sheet, index + 1)) + } else { + pos2(geo.x(sheet, index + 1), geo.rect.top() + geo.header_h / 2.0) + }; + (pos, geo.z) +} + +fn button(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn sizes(h: &egui_kittest::Harness<'_, SheetApp>, rows: bool) -> Vec { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + (0..4).map(|index| if rows { sheet.row_height(index) } else { sheet.col_width(index) }).collect() +} + +#[test] +fn header_double_click_autofits_selected_areas_or_only_the_clicked_line() { + for (rows, selection, clicked, changed) in [ + (false, "B:B", 1, [false, true, false, false]), + (false, "A:C", 1, [true, true, true, false]), + (false, "A:A,C:C", 0, [true, false, true, false]), + (false, "A:B", 2, [false, false, true, false]), + (true, "1:3", 1, [true, true, true, false]), + (true, "1:1,3:3", 0, [true, false, true, false]), + (true, "1:2", 2, [false, false, true, false]), + ] { + let mut h = harness(selection); + let before = sizes(&h, rows); + let selection_before = h.state().session.active().unwrap().selection.clone(); + let (pos, _) = edge(&h, rows, clicked); + button(&mut h, pos, true); + button(&mut h, pos, false); + h.step(); // Let the first click's resize state settle before the second click. + button(&mut h, pos, true); + button(&mut h, pos, false); + h.run_steps(2); + + for (index, ((old, new), changed)) in before.iter().zip(sizes(&h, rows)).zip(changed).enumerate() { + assert_eq!(*old != new, changed, "selection {selection}, clicked {clicked}, line {index}: {old} → {new}"); + } + assert_eq!(h.state().session.active().unwrap().selection, selection_before); + let command = if rows { "home.autofitRowHeight" } else { "home.autofitColumnWidth" }; + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == command).count(), 1); + assert_eq!(h.state().session.active().unwrap().undo.len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&h, rows), before, "AutoFit should undo as a single operation"); + } +} + +#[test] +fn dragging_a_header_boundary_still_resizes_normally() { + for rows in [false, true] { + let mut h = harness(if rows { "2:2" } else { "B:B" }); + let before = sizes(&h, rows); + let (start, zoom) = edge(&h, rows, 1); + let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) }; + button(&mut h, start, true); + h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(start + delta)); + h.step(); + button(&mut h, start + delta, false); + h.run_steps(2); + let after = sizes(&h, rows); + assert_eq!(after[1], (before[1] + 30.0 / zoom).round()); + for index in [0, 2, 3] { + assert_eq!(before[index], after[index]); + } + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit"))); + } +} diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..1cd0a8a 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -1,9 +1,9 @@ //! UI interaction tests: real egui input through the whole app (no GPU needed). -use egui::{Event, Key, Modifiers}; +use egui::{Event, Key, Modifiers, PointerButton}; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; -use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> { @@ -25,6 +25,38 @@ fn blank() -> Session { s } +#[test] +fn vertical_scrollbar_pages_and_drags_without_changing_selection() { + let mut h = harness(blank()); + let grid = h.state().grid.rect.unwrap(); + let cells = h.state().grid.cells_rect.unwrap(); + let p = egui::pos2(grid.right() + 7.0, cells.bottom() - 20.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().view().scroll.y > 0.0, "clicking the scrollbar track scrolls down"); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); + + h.state_mut().view_mut().unwrap().scroll.y = 0.0; + h.run_steps(2); + let start = egui::pos2(grid.right() + 7.0, cells.top() + 8.0); + h.input_mut().events.push(Event::PointerMoved(start)); + h.input_mut().events.push(Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = start + egui::vec2(0.0, 80.0); + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + let dragged = h.state().view().scroll.y; + assert!(dragged > 0.0, "dragging the thumb scrolls down"); + h.run_steps(3); + assert_eq!(h.state().view().scroll.y, dragged, "holding the thumb still must not keep scrolling"); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); +} + fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) { h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); @@ -40,6 +72,59 @@ fn value(h: &egui_kittest::Harness<'static, SheetApp>, a: &str) -> Value { h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() } +fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) { + 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 pos = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center(); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +#[test] +fn keyboard_navigation_does_not_replay_the_last_pointer_click() { + let mut h = harness(blank()); + click_cell(&mut h, "B2"); + for (key_code, modifiers, expected) in [ + (Key::Enter, Modifiers::NONE, "B3"), + (Key::Enter, Modifiers::NONE, "B4"), + (Key::Enter, Modifiers::NONE, "B5"), + (Key::Enter, Modifiers::SHIFT, "B4"), + (Key::Tab, Modifiers::NONE, "C4"), + (Key::Enter, Modifiers::NONE, "C5"), + ] { + key(&mut h, key_code, modifiers); + let selection = &h.state().session.active().unwrap().selection; + assert_eq!(selection.active.a1(), expected); + assert_eq!(selection.anchor, selection.active); + assert!(h.state().editor.is_none(), "navigation must not start editing at the old pointer location"); + } + // A subsequent real pointer click must still select the clicked cell. + click_cell(&mut h, "D6"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D6"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D7"); +} + +#[test] +fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() { + let mut session = blank(); + session.execute("insert.checkbox", json!({"range": "A1:B1"})).unwrap(); + let mut h = harness(session); + click_cell(&mut h, "A1"); + assert_eq!(value(&h, "A1"), Value::Bool(true)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1"); + assert_eq!(value(&h, "A1"), Value::Bool(true), "the old pointer location must remain unchanged"); + assert_eq!(value(&h, "B1"), Value::Bool(true)); + assert!(h.state().editor.is_none()); +} + #[test] fn typing_enters_values_and_moves_down() { let mut h = harness(blank()); @@ -146,3 +231,65 @@ fn column_autocomplete_completes_on_enter() { key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A5"), Value::from("Ea")); } + +#[test] +fn general_numbers_fit_by_the_cells_own_font() { + // 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[100, 123456789]]})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1:B1", "size": 9})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "width": 28})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let h = harness(s); + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t == "100"), "100 drawn in full: {texts:?}"); + // A number that really doesn't fit still shows #### (once: only B1). + assert_eq!(texts.iter().filter(|t| t.starts_with('#')).count(), 1, "{texts:?}"); +} + +fn text_shapes(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::epaint::TextShape, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => out.push((t.clone(), clip)), + egui::Shape::Vec(v) => v.iter().for_each(|s| text_shapes(s, clip, out)), + _ => {} + } +} + +#[test] +fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() { + // Narrow columns with tall, upright headers (mark sheets): rotation and Wrap Text together. + let header = "Term 1 : Drama : Continuous Assessment"; + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let h = harness(s); + let mut found = Vec::new(); + for c in &h.output().shapes { + text_shapes(&c.shape, c.clip_rect, &mut found); + } + let rotated: Vec<_> = found.iter().filter(|(t, _)| t.galley.job.text == header).collect(); + assert_eq!(rotated.len(), 2, "both headers drawn as one text block each"); + for (t, clip) in rotated { + assert!((t.angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3, "drawn at ±90°, got {}", t.angle); + // Wrapped along the row height into a couple of long lines, not across the narrow column. + assert!(t.galley.rows.len() <= 3, "{} lines", t.galley.rows.len()); + let bb = t.visual_bounding_rect(); + assert!(bb.height() > bb.width(), "upright, not flat: {bb:?}"); + assert!(clip.expand(1.0).contains_rect(bb), "inside its cell: {bb:?} not in {clip:?}"); + // Upright text never spills into the neighbouring columns: the clip is the cell itself. + assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}"); + } +} diff --git a/crates/ui-egui/tests/merged_cells.rs b/crates/ui-egui/tests/merged_cells.rs new file mode 100644 index 0000000..c28e080 --- /dev/null +++ b/crates/ui-egui/tests/merged_cells.rs @@ -0,0 +1,118 @@ +//! Merged-cell edits must read and write the visible, top-left cell. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::Queryable as _; +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(input: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.mergeCells", json!({"range": "A1:B2"})).unwrap(); + session.execute("cell.set", json!({"cell": "A1", "input": input})).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 cell_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.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn click_cell(h: &mut Harness, cell: &str) { + let pos = cell_center(h, cell); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); +} + +fn text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(2); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +#[test] +fn typing_into_a_covered_cell_remains_visible_after_clicking_away() { + let mut h = harness(""); + click_cell(&mut h, "B2"); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B2"); + text(&mut h, "hello"); + click_cell(&mut h, "D4"); + + assert!(h.state().editor.is_none()); + assert_eq!(value(&h, "A1"), Value::from("hello"), "the cell painted for the merge must contain the committed text"); + assert_eq!(value(&h, "B2"), Value::Empty, "editing a merge must not hide text in a covered cell"); + click_cell(&mut h, "B2"); + assert!(h.query_by_label("hello").is_some(), "the formula bar must show the merged content when a covered cell is selected"); +} + +#[test] +fn f2_and_double_click_edit_the_existing_merged_content() { + let mut h = harness("before"); + click_cell(&mut h, "B2"); + key(&mut h, Key::F2); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "before"); + text(&mut h, "!"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("before!")); + + click_cell(&mut h, "D4"); + click_cell(&mut h, "B2"); + click_cell(&mut h, "B2"); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "before!"); + text(&mut h, " again"); + click_cell(&mut h, "D4"); + assert_eq!(value(&h, "A1"), Value::from("before! again")); + assert_eq!(value(&h, "B2"), Value::Empty); +} + +#[test] +fn formula_bar_reads_and_edits_the_merge_anchor_from_a_covered_cell() { + let mut h = harness("formula bar text"); + click_cell(&mut h, "B2"); + h.get_by_label("formula bar text").click(); + h.run_steps(2); + + let editor = h.state().editor.as_ref().unwrap(); + assert!(editor.from_formula_bar); + assert_eq!(editor.cell.a1(), "A1"); + assert_eq!(editor.text, "formula bar text"); + text(&mut h, " edited"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "A1"), Value::from("formula bar text edited")); + assert_eq!(value(&h, "B2"), Value::Empty); + click_cell(&mut h, "D4"); + click_cell(&mut h, "B2"); + assert!(h.query_by_label("formula bar text edited").is_some()); +} diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs new file mode 100644 index 0000000..391ccd6 --- /dev/null +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -0,0 +1,176 @@ +//! Custom worksheet popups must keep inside interactions and dismiss outside clicks. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +#[derive(Clone, Copy, Debug)] +enum Menu { + Header, + Filter, + Validation, +} + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["Red", 1], ["Blue", 2]]})).unwrap(); + session.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + session.execute("insert.table", json!({"range": "A1:B3"})).unwrap(); + session.execute("range.setValues", json!({"range": "H1", "values": [["Other", "Qty"], ["Green", 3]]})).unwrap(); + session.execute("insert.table", json!({"range": "H1:I2"})).unwrap(); + session.execute("data.validation", json!({"range": "E3", "type": "list", "formula1": "\"First,Second\""})).unwrap(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).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 geo(h: &Harness) -> grid::Geo { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll) +} + +fn cell_pos(h: &Harness, cell: &str) -> Pos2 { + geo(h).cell_rect(h.state().session.active().unwrap().wb.active().unwrap(), CellRef::parse(cell).unwrap()).center() +} + +fn click(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn open(h: &mut Harness, menu: Menu) { + let geometry = geo(h); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + let (pos, button) = match menu { + Menu::Header => (pos2(cell_pos(h, "A1").x, (geometry.rect.top() + geometry.cells.top()) / 2.0), PointerButton::Secondary), + Menu::Filter => (grid::filter_button_rect(&geometry, sh, CellRef::parse("A1").unwrap()).center(), PointerButton::Primary), + Menu::Validation => { + let cell = CellRef::parse("E3").unwrap(); + let pos = grid::validation_arrow(sh, &geometry, cell).unwrap().center(); + h.state_mut().run("selection.set", json!({"cell": "E3"})).unwrap(); + h.run_steps(2); + (pos, PointerButton::Primary) + } + }; + click(h, pos, button); + assert!(is_open(h, menu), "{menu:?} must survive its opening click"); +} + +fn is_open(h: &Harness, menu: Menu) -> bool { + match menu { + Menu::Header => h.state().grid.header_menu.is_some(), + Menu::Filter => h.state().grid.filter_menu.is_some(), + Menu::Validation => h.state().grid.list_picker.is_some(), + } +} + +#[test] +fn worksheet_popups_close_on_grid_and_ribbon_clicks() { + for menu in [Menu::Header, Menu::Filter, Menu::Validation] { + for ribbon in [false, true] { + let mut h = harness(); + open(&mut h, menu); + let outside = if ribbon { pos2(1050.0, 52.0) } else { cell_pos(&h, "H7") }; + click(&mut h, outside, PointerButton::Primary); + assert!(!is_open(&h, menu), "{menu:?} stayed open after outside click (ribbon={ribbon})"); + if !ribbon { + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H7"); + } + } + } +} + +#[test] +fn filter_closes_when_clicking_another_table_or_sheet() { + for sheet in [false, true] { + let mut h = harness(); + open(&mut h, Menu::Filter); + let outside = if sheet { pos2(44.0, h.state().grid.rect.unwrap().bottom() + 16.0) } else { cell_pos(&h, "H2") }; + click(&mut h, outside, PointerButton::Primary); + assert!(!is_open(&h, Menu::Filter), "filter stayed open after switching table/sheet"); + if sheet { + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 1); + } else { + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H2"); + } + } +} + +#[test] +fn popup_actions_still_execute_and_filter_checkboxes_stay_open() { + let mut h = harness(); + open(&mut h, Menu::Header); + h.get_by_label("Copy").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Header)); + assert!(h.state().session.clipboard.is_some()); + + open(&mut h, Menu::Filter); + h.get_by_label("(Select All)").click(); + h.run_steps(2); + assert!(is_open(&h, Menu::Filter), "clicking a checkbox must keep the filter open"); + let outside = cell_pos(&h, "H7"); + click(&mut h, outside, PointerButton::Primary); + open(&mut h, Menu::Filter); + h.get_by_label("Apply").click(); + h.run_steps(2); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert!(!sh.is_row_hidden(1) && !sh.is_row_hidden(2), "cancelled checkbox edits must not be applied after reopening"); + + open(&mut h, Menu::Filter); + h.get_by_label("(Select All)").click(); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Filter)); + assert!(h.state().session.active().unwrap().wb.active().unwrap().is_row_hidden(1)); + h.state_mut().run("data.clearFilter", json!({})).unwrap(); + h.run_steps(2); + + open(&mut h, Menu::Validation); + h.get_by_label("Second").click(); + h.run_steps(2); + assert!(!is_open(&h, Menu::Validation)); + assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("E3").unwrap()), Value::from("Second")); +} + +#[test] +fn worksheet_popups_close_on_escape_or_window_focus_loss() { + for menu in [Menu::Header, Menu::Filter, Menu::Validation] { + for focus_loss in [false, true] { + let mut h = harness(); + open(&mut h, menu); + if focus_loss { + h.input_mut().focused = false; + h.input_mut().events.push(Event::WindowFocused(false)); + } else { + h.input_mut().events.push(Event::Key { + key: Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + } + h.run_steps(2); + assert!(!is_open(&h, menu), "{menu:?} stayed open (focus_loss={focus_loss})"); + } + } +} diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs new file mode 100644 index 0000000..faebebf --- /dev/null +++ b/crates/ui-egui/tests/selection.rs @@ -0,0 +1,161 @@ +//! Cell selection consumes every pointer position without editing cell contents. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(initial: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["sentinel", 2], [3, "untouched"]]})).unwrap(); + session.execute("cell.set", json!({"cell": "C3", "input": "123"})).unwrap(); + session.execute("cell.set", json!({"cell": "F7", "input": "=C3*2"})).unwrap(); + session.execute("selection.set", json!({"cell": initial})).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(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn cells(h: &Harness) -> Value { + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + json!(sheet.cells.iter().map(|(pos, cell)| (pos.a1(), cell)).collect::>()) +} + +fn frame(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(1); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn press(h: &mut Harness, cell: &str) { + let from = point(h, cell); + frame(h, Event::PointerMoved(from)); + frame(h, button(from, true)); +} + +fn assert_selection(h: &Harness, start: &str, end: &str) { + let selection = &h.state().session.active().unwrap().selection; + let start = CellRef::parse(start).unwrap(); + assert_eq!(selection.current(), RangeRef::new(start, CellRef::parse(end).unwrap())); + assert_eq!(selection.anchor, start); + assert_eq!(selection.active, start); +} + +#[test] +fn single_movement_selects_from_the_pressed_cell_in_both_directions() { + for (initial, start, end) in [("A1", "C3", "F7"), ("C3", "C3", "F7"), ("A1", "F7", "C3"), ("F7", "F7", "C3")] { + let mut h = harness(initial); + let before = cells(&h); + press(&mut h, start); + let to = point(&h, end); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, start, end); + frame(&mut h, button(to, false)); + h.run_steps(1); + assert_selection(&h, start, end); + assert_eq!(cells(&h), before); + } +} + +#[test] +fn release_consumes_a_new_endpoint_without_an_intermediate_movement_frame() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + assert_selection(&h, "C3", "F7"); + assert_eq!(cells(&h), before); +} + +#[test] +fn multiple_movement_frames_keep_the_anchor_and_cell_contents() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + for end in ["D4", "F7", "B2"] { + let to = point(&h, end); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, "C3", end); + } + let to = point(&h, "B2"); + frame(&mut h, button(to, false)); + assert_selection(&h, "C3", "B2"); + assert_eq!(cells(&h), before); +} + +#[test] +fn clicking_then_hovering_does_not_extend_selection() { + let mut h = harness("A1"); + let before = cells(&h); + press(&mut h, "C3"); + let from = point(&h, "C3"); + frame(&mut h, button(from, false)); + let to = point(&h, "F7"); + frame(&mut h, Event::PointerMoved(to)); + assert_selection(&h, "C3", "C3"); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_point_mode_consumes_first_movement_and_release() { + let mut h = harness("A1"); + let before = cells(&h); + h.state_mut().begin_edit(Some("=".into()), false); + h.run_steps(2); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "=C3:D4"); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + h.run_steps(1); + let editor = h.state().editor.as_ref().unwrap(); + assert_eq!(editor.text, "=C3:F7"); + assert_eq!(editor.point_cell, CellRef::parse("C3")); + assert_eq!(cells(&h), before); +} + +#[test] +fn formula_point_drag_keeps_the_sheet_qualifier() { + let mut h = harness("A1"); + let before = cells(&h); + h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap(); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + h.state_mut().begin_edit(Some("=".into()), false); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap(); + h.run_steps(2); + let other_before = cells(&h); + press(&mut h, "C3"); + let mid = point(&h, "D4"); + frame(&mut h, Event::PointerMoved(mid)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:D4"); + let to = point(&h, "F7"); + frame(&mut h, button(to, false)); + assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:F7"); + assert_eq!(cells(&h), other_before); + h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert_eq!(cells(&h), before); +} diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs new file mode 100644 index 0000000..2a5365a --- /dev/null +++ b/crates/ui-egui/tests/system_theme.rs @@ -0,0 +1,163 @@ +//! Saved appearance preferences and live OS changes through the whole UI. + +use egui::{Theme, ThemePreference}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, UiState, control, theme::Tokens}; +use serde_json::{Value, json}; + +fn harness(ui: UiState) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn preferences(h: &egui_kittest::Harness<'static, SheetApp>) -> Value { + serde_json::to_value(&h.state().ui).unwrap() +} + +fn system_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, theme: Option) { + h.input_mut().system_theme = theme; + h.run_steps(2); +} + +fn set_mode(h: &mut egui_kittest::Harness<'static, SheetApp>, mode: &str) { + let ctx = h.ctx.clone(); + let control::Outcome::Done(response) = + control::handle(h.state_mut(), &ctx, "engine.execute", &json!({"command": "view.theme", "params": {"mode": mode}})) + else { + panic!("engine.execute should return a response"); + }; + assert_eq!(response["ok"], true, "{response}"); + h.run_steps(2); +} + +fn select_ribbon_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(2); +} + +fn assert_appearance(h: &egui_kittest::Harness<'static, SheetApp>, dark: bool) { + let visuals = &h.ctx.global_style().visuals; + let tokens = if dark { Tokens::dark() } else { Tokens::light() }; + assert_eq!(visuals.dark_mode, dark); + assert_eq!(visuals.panel_fill, tokens.window); + assert_eq!(visuals.window_fill, tokens.menu_bg); + assert_eq!(visuals.selection.bg_fill, tokens.accent_soft); +} + +#[test] +fn old_preferences_stay_manual_and_system_preference_survives_reload() { + for dark in [false, true] { + let ui: UiState = serde_json::from_value(json!({"dark": dark})).unwrap(); + assert_eq!(ui.dark, dark); + let mut h = harness(ui); + assert_eq!(preferences(&h)["systemTheme"], false, "old saved settings remain manual"); + system_theme(&mut h, Some(if dark { Theme::Light } else { Theme::Dark })); + assert_appearance(&h, dark); + } + + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let saved = serde_json::to_string(&h.state().ui).unwrap(); + let mut restored = harness(serde_json::from_str(&saved).unwrap()); + assert_eq!(preferences(&restored)["systemTheme"], true); + system_theme(&mut restored, Some(Theme::Light)); + assert_appearance(&restored, false); + system_theme(&mut restored, Some(Theme::Dark)); + assert_appearance(&restored, true); +} + +#[test] +fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback() { + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + // egui switches between these two style slots when the OS appearance changes. + for (theme, tokens) in [(Theme::Light, Tokens::light()), (Theme::Dark, Tokens::dark())] { + let style = h.ctx.style_of(theme); + assert_eq!(style.visuals.panel_fill, tokens.window); + assert_eq!(style.visuals.window_fill, tokens.menu_bg); + assert_eq!(style.visuals.selection.bg_fill, tokens.accent_soft); + } + for (reported, dark) in [(Some(Theme::Dark), true), (Some(Theme::Light), false), (Some(Theme::Dark), true), (None, false)] { + system_theme(&mut h, reported); + assert_appearance(&h, dark); + assert_eq!(preferences(&h)["systemTheme"], true); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + } +} + +#[test] +fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { + let mut h = harness(UiState::default()); + system_theme(&mut h, Some(Theme::Dark)); + assert_appearance(&h, false); + h.state_mut().run("ui.ribbonTab", json!({"tab": "View"})).unwrap(); + h.run_steps(2); + select_ribbon_theme(&mut h, "System"); + assert_eq!(preferences(&h)["systemTheme"], true); + assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_appearance(&h, true); + for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { + select_ribbon_theme(&mut h, label); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_eq!(h.ctx.options(|o| o.theme_preference), preference); + for os in [Some(Theme::Dark), Some(Theme::Light), None] { + system_theme(&mut h, os); + assert_appearance(&h, dark); + } + } +} + +#[test] +fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() { + let mut h = harness(UiState::default()); + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let before = preferences(&h); + assert!(h.state_mut().run("view.theme", json!({"mode": "sepia"})).is_err()); + h.run_steps(2); + assert_eq!(preferences(&h), before); + assert_appearance(&h, true); + + // The old toggle flips the displayed appearance, even when System chose it. + h.state_mut().run("view.darkMode", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, false); + + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Light)); + h.state_mut().run("view.darkMode", json!({"on": true})).unwrap(); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, true); + + set_mode(&mut h, "system"); + system_theme(&mut h, Some(Theme::Dark)); + let ctx = h.ctx.clone(); + let control::Outcome::Done(response) = control::handle(h.state_mut(), &ctx, "ui.set", &json!({"dark": false})) else { + panic!("ui.set should return a response"); + }; + assert_eq!(response["ok"], true); + h.run_steps(2); + assert_eq!(preferences(&h)["systemTheme"], false); + assert_appearance(&h, false); + system_theme(&mut h, Some(Theme::Dark)); + assert_appearance(&h, false); +} diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/widgets.rs new file mode 100644 index 0000000..54c8f6e --- /dev/null +++ b/crates/ui-egui/tests/widgets.rs @@ -0,0 +1,62 @@ +//! Ribbon widget paint tests, using egui's bundled fonts on every platform. + +use egui::{FontDefinitions, FontFamily, Rect, Shape, pos2, vec2}; +use gridcraft_ui_egui::{icons::Icon, theme, widgets}; + +fn render_big_button(label: &str, dropdown: bool) -> (Rect, Vec) { + let ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional); + ctx.set_fonts(fonts); + let mut rect = Rect::NOTHING; + let mut output = ctx + .run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() }, |ui| { + rect = widgets::big_button(ui, Icon::More, label, "test button", dropdown).rect + }); + output.textures_delta.clear(); + (rect, output.shapes.into_iter().map(|shape| shape.shape).collect()) +} + +#[test] +fn big_dropdown_paints_chevron_beside_final_label_line() { + for label in ["Paste", "Conditional\nFormatting", "Longer first line\nGo"] { + let (rect, shapes) = render_big_button(label, true); + let texts: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Text(text) = shape { Some(text) } else { None }).collect(); + assert_eq!(texts.iter().map(|text| text.galley.job.text.as_str()).collect::>(), label.split('\n').collect::>()); + let paths: Vec<_> = shapes.iter().filter_map(|shape| if let Shape::Path(path) = shape { Some(path) } else { None }).collect(); + assert_eq!(paths.len(), 1, "dropdown indicator must be painted independently of font glyphs"); + let chevron = paths[0]; + assert_eq!(chevron.points.len(), 3); + assert!(chevron.points[0].x < chevron.points[1].x && chevron.points[1].x < chevron.points[2].x); + assert!(chevron.points[1].y > chevron.points[0].y && chevron.points[1].y > chevron.points[2].y); + let last = texts.last().unwrap(); + let last_rect = last.galley.rect.translate(last.pos.to_vec2()); + assert!(chevron.points[0].x > last_rect.right(), "arrow must not overlap the label"); + assert!((chevron.points[1].y - last_rect.center().y).abs() < 3.0, "arrow must align with the final line"); + assert!(rect.right() - chevron.points[2].x >= 6.0, "button must reserve space for the arrow and padding"); + assert!(last_rect.left() - rect.left() >= 6.0); + assert!(((last_rect.left() + chevron.points[2].x) / 2.0 - rect.center().x).abs() < 2.0, "label and arrow must be centered together"); + for text in texts.iter().take(texts.len() - 1) { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01, "earlier lines remain centered"); + } + } +} + +#[test] +fn big_button_without_dropdown_keeps_centered_labels_and_width() { + for label in ["Paste", "Conditional\nFormatting"] { + let (rect, shapes) = render_big_button(label, false); + assert!(!shapes.iter().any(|shape| matches!(shape, Shape::Path(_))), "plain button must not paint an arrow"); + let mut widest: f32 = 0.0; + for shape in shapes { + if let Shape::Text(text) = shape { + let text_rect = text.galley.rect.translate(text.pos.to_vec2()); + assert!((text_rect.center().x - rect.center().x).abs() < 0.01); + widest = widest.max(text_rect.width()); + } + } + assert!((rect.width() - (widest + 12.0).max(44.0)).abs() < 0.01); + } +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index eee516b..018cbf2 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -21,22 +21,85 @@ pub fn text_to_file(text: &str) -> String { } } +const PARAM_PREFIX: &str = "_xlpm."; + +/// Adds Excel's `_xlpm.` prefix to LET/LAMBDA parameter names, at the declaration and at every use in scope. +fn prefix_params(e: Expr, scope: &[String]) -> Expr { + let sub = |x: Box, sc: &[String]| Box::new(prefix_params(*x, sc)); + match e { + Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")), + Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)), + Expr::Paren(x) => Expr::Paren(sub(x, scope)), + Expr::Binary(op, a, b) => Expr::Binary(op, sub(a, scope), sub(b, scope)), + Expr::Invoke(c, args) => Expr::Invoke(sub(c, scope), args.into_iter().map(|a| prefix_params(a, scope)).collect()), + Expr::Call(name, args) if name == "LET" || name == "LAMBDA" => { + let last = args.len().saturating_sub(1); + let is_let = name == "LET"; + let mut sc = scope.to_vec(); + let mut pending: Option = None; + let mut out = Vec::with_capacity(args.len()); + for (i, a) in args.into_iter().enumerate() { + let declares = i < last && (!is_let || i % 2 == 0); + match a { + Expr::Name(n) if declares => { + out.push(Expr::Name(format!("{PARAM_PREFIX}{n}"))); + if is_let { + pending = Some(n); + } else { + sc.push(n); + } + } + other => { + out.push(prefix_params(other, &sc)); + // A LET name is in scope only after its value expression. + if let Some(n) = pending.take() { + sc.push(n); + } + } + } + } + Expr::Call(name, out) + } + // A LET-bound LAMBDA called by name, e.g. `f(3)`. + Expr::Call(name, args) if scope.iter().any(|s| s.eq_ignore_ascii_case(&name)) => { + Expr::Call(format!("{PARAM_PREFIX}{name}"), args.into_iter().map(|a| prefix_params(a, scope)).collect()) + } + Expr::Call(name, args) => Expr::Call(name, args.into_iter().map(|a| prefix_params(a, scope)).collect()), + other => other, + } +} + fn expr_to_file(e: Expr) -> String { + let e = prefix_params(e, &[]); let e = e.map(&mut |x| match x { Expr::Call(name, args) => match crate::tables::file_function_name(&name) { Some(n) => Expr::Call(n, args), None => Expr::Call(name, args), }, + // Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`. + Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), other => other, }); gridcraft_formula::print(&e) } +/// `_xlpm.x` → `x` (any case, as the parser upper-cases function names). +fn strip_param_prefix(n: String) -> String { + match (n.get(..PARAM_PREFIX.len()), n.get(PARAM_PREFIX.len()..)) { + (Some(p), Some(rest)) if p.eq_ignore_ascii_case(PARAM_PREFIX) => rest.to_string(), + _ => n, + } +} + /// Formula text read from a file → model text (strips prefixes when it parses). pub fn from_file(text: &str) -> String { let body = text.strip_prefix('=').unwrap_or(text); match gridcraft_formula::parse(body) { - Ok(e) => gridcraft_formula::print(&e), + Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x { + Expr::Name(n) => Expr::Name(strip_param_prefix(n)), + Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args), + other => other, + })), Err(_) => body.to_string(), } } @@ -45,6 +108,13 @@ pub fn from_file(text: &str) -> String { mod tests { use super::*; + #[test] + fn spill_references() { + assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"); + assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)"); + assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#"); + } + #[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)"); @@ -52,4 +122,18 @@ mod tests { assert_eq!(from_file("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); assert_eq!(text_to_file("=IFS(A1>0,1)"), "_xlfn.IFS(A1>0,1)"); } + + #[test] + fn let_lambda_params() { + assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"); + assert_eq!(to_file(&Formula::new("LET(x,A1*2,y,x+1,x*y)")), "_xlfn.LET(_xlpm.x,A1*2,_xlpm.y,_xlpm.x+1,_xlpm.x*_xlpm.y)"); + assert_eq!(to_file(&Formula::new("LAMBDA(a,b,a^2+b)(3,4)")), "_xlfn.LAMBDA(_xlpm.a,_xlpm.b,_xlpm.a^2+_xlpm.b)(3,4)"); + // A name outside the scope stays a plain defined name. + assert_eq!(to_file(&Formula::new("LET(x,1,x)+x")), "_xlfn.LET(_xlpm.x,1,_xlpm.x)+x"); + assert_eq!(from_file("_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"), "LET(x,1,x+1)"); + assert_eq!(from_file("_xlfn.LAMBDA(_xlpm.a,_xlpm.a*2)(3)"), "LAMBDA(a,a*2)(3)"); + // A LET-bound LAMBDA called by name. + assert_eq!(to_file(&Formula::new("LET(f,LAMBDA(x,x*2),f(3))")), "_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.F(3))"); + assert_eq!(from_file("_xlfn.LET(_xlpm.f,_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2),_xlpm.f(3))"), "LET(f,LAMBDA(x,x*2),F(3))"); + } } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4..bf45e91 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -279,6 +279,8 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb_rels.iter().any(|r| r.kind == "externalLink") { cx.warn("links to external workbooks are not supported"); } + // Calls of LAMBDA names are spelled like the names (the parser upper-cases them). + wb.apply_name_call_case(); wb.styles = cx.styles; Ok((wb, cx.report)) } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 94d9b18..86999d1 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -555,7 +555,13 @@ fn write_cell( }; let value = match value { Value::Array(a) => a.get(0, 0).cloned().unwrap_or(Value::Empty), + v => v, + }; + let value = match value { Value::Error(CellError::Circ) => Value::Number(0.0), + // #SPILL! and #CALC! are not error codes of the file format (Excel refuses a file that + // caches them); Excel itself caches them as #VALUE!. + Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), v => v, }; if formula.is_some() && dynamic.is_some() { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b9..c70942f 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -563,3 +563,27 @@ fn writing_repairs_invalid_models() { assert_eq!(s.tables[0].name, "Table1"); assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT); } + +fn sheet1_xml(bytes: &[u8]) -> (String, bool) { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut s).unwrap(); + let metadata = z.by_name("xl/metadata.xml").is_ok(); + (s, metadata) +} + +/// Excel refuses to open a file whose cached values include `#SPILL!` or `#CALC!`. +#[test] +fn spill_and_calc_errors_are_cached_as_value_errors() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.set_value(at("A1"), Value::Number(1.0)); + s.cells.set(at("C1"), Cell { value: Value::Error(CellError::Spill), ..Cell::formula(Formula::new("A1:A3*2")) }); + s.cells.set(at("C2"), Cell { value: Value::Error(CellError::Calc), ..Cell::formula(Formula::new("FILTER(A1:A3,A1:A3>5)")) }); + s.cells.set(at("C3"), Cell { value: Value::Error(CellError::NA), ..Cell::formula(Formula::new("NA()")) }); + let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap()); + assert!(!xml.contains("#SPILL!") && !xml.contains("#CALC!"), "{xml}"); + assert!(xml.contains(r#"A1:A3*2#VALUE!"#), "{xml}"); + assert!(xml.contains(r#"_xlfn._xlws.FILTER(A1:A3,A1:A3>5)#VALUE!"#), "{xml}"); + assert!(xml.contains(r#"NA()#N/A"#), "{xml}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 638ec21..9c6c31b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,5 +39,11 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. Any other method name that is a command id (e.g. `home.bold`) runs that command. +Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` +to choose the display theme; `mode` accepts `"system"`, `"light"`, or `"dark"`. +System follows live OS appearance changes and falls back to Light when the platform supplies no preference. +The saved choice remains System; new users still start in Light. The legacy +`view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. + For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md).