Merge codex/xlsb-import (with origin/main) into codex/ods-import

Resolves the shared integration points for both formats: combined sniff,
file.open spec, file filters, MCP description, docs. open_bytes now lets
sniffed content (ODS or XLSB) win over the extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-10 12:20:30 -04:00
co-authored by Claude Opus 5.5
75 changed files with 5092 additions and 328 deletions
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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
+25 -29
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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`.
+30 -1
View File
@@ -84,6 +84,7 @@
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
PivotTables and print settings round-trip. CSV and TSV too.
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
@@ -116,6 +117,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
@@ -142,7 +156,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|---|---|
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV and ODS data import) |
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
| L3 | `chart` (toolkit-free chart layout and rendering) |
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
@@ -175,6 +189,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 |
+3 -1
View File
@@ -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.
@@ -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");
}
+1 -1
View File
@@ -98,7 +98,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) =
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"]).pick_file().await
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]).pick_file().await
else {
return;
};
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
})?;
eprintln!("GridCraft control channel listening on 127.0.0.1:{port}");
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
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>) -> 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);
}
}
}
+579
View File
@@ -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:
//! `<settings dir>/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.<KEEP>.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<LevelFilter> {
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
pending: Vec<String>,
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(&note);
}
}
/// 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<Sink>,
}
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<PathBuf, String> {
// 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(&timestamp(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<AppLogger> = 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);
}
}
+62 -6
View File
@@ -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<std::path::PathBuf> {
}
}
/// 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<std::path::PathBuf> {
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 };
@@ -96,8 +110,9 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
.add_filter("CSV", &["csv"])
.pick_file()
@@ -134,6 +149,26 @@ fn icon() -> Option<egui::IconData> {
}
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<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
@@ -158,15 +193,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 +238,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 +253,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(
+52 -3
View File
@@ -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<Entry>)> {
("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<Entry>)> {
pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
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");
}
}
+4
View File
@@ -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
+82 -19
View File
@@ -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<RangeRef> {
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<Value> {
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<Area>, 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<Area>, 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") =>
{
+20
View File
@@ -225,6 +225,26 @@ impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
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) {
+125
View File
@@ -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);
}
+69 -13
View File
@@ -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<f64> {
// 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));
}
+16 -1
View File
@@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
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));
}
}
+9 -4
View File
@@ -662,8 +662,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
}
}
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(&quote) {
cur.push(quote);
chars.next();
} else {
in_q = !in_q;
}
continue;
}
if !in_q && delims.contains(&c) {
+20 -18
View File
@@ -119,9 +119,10 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------------------------------------------------------------- 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<Option<Cell>> {
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<Option<Cell>> {
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
if input.is_empty() {
let c = Cell { value: Value::Empty, formula: None, style };
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<Json> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
.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<Json> {
} 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),
+3 -2
View File
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
@@ -48,7 +48,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let imported =
crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Ods) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Ods;
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
+26 -1
View File
@@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
}
});
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)
+50 -21
View File
@@ -36,14 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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<Json> {
};
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 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)))));
}
}
}
}
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)))));
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<String> = 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<Json> {
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}))
})
}
+45 -2
View File
@@ -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<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
@@ -220,9 +223,12 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
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<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
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) {
+43 -6
View File
@@ -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<String> = 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<String> = 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:?}");
}
}
+161 -40
View File
@@ -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<RangeRef> {
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<CellRef> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
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<Json> {
@@ -265,27 +270,24 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
"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<Json> {
@@ -296,9 +298,10 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
"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<Json> {
} 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<Json> {
@@ -359,6 +358,39 @@ fn edit_doc<R>(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -
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<Json> {
let i = target_sheet(s, p)?;
let d = s.doc()?;
@@ -392,6 +424,13 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
{
*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<Json> {
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<gridcraft_model::DefinedName> = 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<Json> {
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<Json> {
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<RangeRef> {
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);
}
}
+1 -2
View File
@@ -489,8 +489,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
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),
+17
View File
@@ -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);
}
+14 -3
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, ODS data import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -11,6 +11,7 @@ use crate::{EngineError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
Xlsb,
Ods,
Csv,
Tsv,
@@ -23,6 +24,7 @@ impl FileKind {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
"tsv" | "tab" | "txt" => FileKind::Tsv,
@@ -38,8 +40,16 @@ impl FileKind {
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Ods || kind == Some(FileKind::Ods) {
let (wb, report) = gridcraft_xlsx::read_ods(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
// whatever it is called; the extension only decides when the bytes don't say.
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb };
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
@@ -87,6 +97,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
+555
View File
@@ -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>, 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();
@@ -223,6 +652,71 @@ fn samples_build() {
}
}
/// Original minimal BIFF12 records, built in source rather than a vendor workbook fixture.
fn xlsb_fixture() -> Vec<u8> {
use std::io::{Cursor, Write};
fn wide(s: &str) -> Vec<u8> {
let units: Vec<_> = s.encode_utf16().collect();
(units.len() as u32).to_le_bytes().into_iter().chain(units.into_iter().flat_map(u16::to_le_bytes)).collect()
}
fn record(out: &mut Vec<u8>, id: u16, payload: &[u8]) {
if id < 128 {
out.push(id as u8);
} else {
out.extend([(id as u8 & 127) | 128, (id >> 7) as u8]);
}
let mut size = payload.len();
while size >= 128 {
out.push((size as u8 & 127) | 128);
size >>= 7;
}
out.push(size as u8);
out.extend(payload);
}
let mut book = vec![];
record(&mut book, 131, &[]); // BeginBook
record(&mut book, 143, &[]); // BeginBundleShs
let mut bundle = vec![0; 4]; // Visible
bundle.extend(1u32.to_le_bytes());
bundle.extend(wide("rId1"));
bundle.extend(wide("Imported"));
record(&mut book, 156, &bundle);
record(&mut book, 144, &[]);
record(&mut book, 132, &[]);
let mut sheet = vec![];
record(&mut sheet, 129, &[]); // BeginSheet
record(&mut sheet, 145, &[]); // BeginSheetData
record(&mut sheet, 0, &[0; 17]); // Row 0, no spans
let mut numeric = vec![0; 8]; // A1, default style
numeric.extend(42.0f64.to_le_bytes());
numeric.extend([0; 2]); // Formula flags
numeric.extend(3u32.to_le_bytes());
numeric.extend([0x1e, 42, 0]); // PtgInt(42)
numeric.extend(0u32.to_le_bytes());
record(&mut sheet, 9, &numeric); // FmlaNum
let mut text = 1u32.to_le_bytes().to_vec(); // B1
text.extend(0u32.to_le_bytes());
text.extend(wide("Original data"));
record(&mut sheet, 6, &text);
record(&mut sheet, 146, &[]);
record(&mut sheet, 130, &[]);
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let types = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/xl/workbook.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/><Override PartName="/xl/worksheets/sheet1.bin" ContentType="application/vnd.ms-excel.worksheet"/></Types>"#;
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.bin"/></Relationships>"#;
let book_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.bin"/></Relationships>"#;
for (name, data) in [
("[Content_Types].xml", types.as_slice()),
("_rels/.rels", rels.as_slice()),
("xl/_rels/workbook.bin.rels", book_rels.as_slice()),
("xl/workbook.bin", book.as_slice()),
("xl/worksheets/sheet1.bin", sheet.as_slice()),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn every_command_survives_empty_params() {
let mut s = Session::new();
@@ -260,3 +754,64 @@ fn ink_strokes_and_ink_to_shape() {
assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line");
assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err());
}
#[test]
fn xlsb_import_keeps_cached_values_and_reports_limits() {
let bytes = xlsb_fixture();
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsb);
for name in ["source.xlsb", "source.xlsx"] {
let mut s = s();
let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap();
assert!(!r["warnings"].as_array().unwrap().is_empty());
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_))));
assert!(s.doc().unwrap().path.is_none());
assert!(s.doc().unwrap().display_title().ends_with(".xlsx"));
assert_eq!(v(&s, "A1"), Value::Number(42.0));
assert_eq!(v(&s, "B1"), Value::from("Original data"));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none());
s.execute("cell.set", json!({"cell": "B1", "input": "Changed"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(42.0)); // Imported formula caches are constants.
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(42.0));
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn xlsb_import_never_reuses_source_as_save_target() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-xlsb-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let bytes = xlsb_fixture();
for name in ["source.xlsb", "disguised.xlsx"] {
let path = dir.join(name);
std::fs::write(&path, &bytes).unwrap();
let mut s = s();
s.execute("file.open", json!({"path": path})).unwrap();
s.take_ui_requests();
s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap();
s.execute("file.save", json!({})).unwrap();
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs")));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(s.execute("file.saveAs", json!({"path": dir.join("source.xlsb")})).is_err());
assert!(s.execute("file.saveBytes", json!({"format": "xlsb"})).is_err());
let output = dir.join("converted.xlsx");
s.execute("file.saveAs", json!({"path": output})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(43.0));
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn malformed_xlsb_keeps_the_current_workbook() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "Keep me"})).unwrap();
assert!(s.execute("file.open", json!({"name": "bad.xlsb", "base64": crate::io::base64_encode(b"not a workbook")})).is_err());
assert_eq!(s.documents().len(), 1);
assert_eq!(v(&s, "A1"), Value::from("Keep me"));
assert!(s.doc().unwrap().is_dirty());
}
+21
View File
@@ -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<Reference> {
};
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;
+3
View File
@@ -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)]
+5
View File
@@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, 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("");
+14 -2
View File
@@ -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()),
+7
View File
@@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
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);
+34
View File
@@ -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 };
+44 -3
View File
@@ -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<Value> {
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<Value> {
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<Value> {
@@ -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);
}
}
+3 -2
View File
@@ -1006,8 +1006,9 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
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<Vec<f64>> {
let range = as_array(&crate::util::arg(a, 0)?.value);
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
+5 -1
View File
@@ -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]
+1 -1
View File
@@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_workbook",
"Open workbook",
"Open a workbook file (.xlsx, .xlsm, .ods, .csv, .tsv, .txt, .json) and make it active. ODS imports worksheet data and cached formula values only; check the returned warnings.",
"Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
obj(json!({"path": string("File path")}), &["path"]),
false,
),
+48
View File
@@ -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<CellRef> {
let empty = Band::default();
let mut keys: Vec<u32> = 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<CellRef> = 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();
+63
View File
@@ -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<String> {
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::*;
+82
View File
@@ -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);
}
}
}
+3 -1
View File
@@ -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
});
}
+7 -2
View File
@@ -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();
+4 -1
View File
@@ -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);
}
}
+196 -83
View File
@@ -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::<f32>(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<String> = 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<String> = 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::<Vec<(String, bool)>>(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::<Vec<(String, bool)>>(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::<Vec<(String, bool)>>(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::<Vec<(String, bool)>>(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;
}
}
+48 -4
View File
@@ -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<String>,
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<String>, 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<u8>)> = self
+43 -5
View File
@@ -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}));
}
}
});
});
}
+25 -19
View File
@@ -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;
+17 -7
View File
@@ -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)
}
+129
View File
@@ -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);
}
+76
View File
@@ -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::<Vec<u8>>::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));
}
+116
View File
@@ -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<f32> {
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")));
}
}
+149 -2
View File
@@ -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<String> = 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:?}");
}
}
+118
View File
@@ -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());
}
+176
View File
@@ -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})");
}
}
}
+161
View File
@@ -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::<Vec<_>>())
}
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);
}
+163
View File
@@ -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<Theme>) {
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);
}
+62
View File
@@ -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<Shape>) {
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::<Vec<_>>(), label.split('\n').collect::<Vec<_>>());
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);
}
}
+85 -1
View File
@@ -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<Expr>, 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<String> = 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))");
}
}
+12 -3
View File
@@ -1,4 +1,4 @@
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus ODS data import.
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import.
//!
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
@@ -24,6 +24,7 @@ mod styles;
mod tables;
mod theme;
mod write;
mod xlsb;
mod xml;
#[cfg(test)]
@@ -33,6 +34,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use csv::{read_csv, write_csv};
pub use xlsb::read_xlsb;
pub use ods::read_ods;
/// Errors from reading or writing files.
@@ -94,15 +96,22 @@ impl Default for CsvOptions {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Xlsx,
Xlsb,
Ods,
Csv,
Unknown,
}
/// Guesses the format from content: ODS mimetype, other ZIP packages as XLSX, text as CSV.
/// Guesses the format from content: ODS mimetype, XLSB package content types, other ZIP packages as XLSX, text as CSV.
pub fn sniff(bytes: &[u8]) -> Format {
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
return if ods::has_mimetype(bytes) { Format::Ods } else { Format::Xlsx };
return if ods::has_mimetype(bytes) {
Format::Ods
} else if xlsb::is_xlsb(bytes) {
Format::Xlsb
} else {
Format::Xlsx
};
}
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Format::Csv;
+10 -4
View File
@@ -58,22 +58,28 @@ impl<'a> Package<'a> {
/// Reads a part's bytes; `Ok(None)` when it doesn't exist.
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
self.read_limited(part, MAX_PART)
}
/// Reads a small metadata part without allowing it to expand to the general part limit.
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
let maximum = maximum.min(MAX_PART);
let Some(&idx) = self.names.get(&normalize(part)) else {
return Ok(None);
};
let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
if f.size() > MAX_PART {
if f.size() > maximum {
return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
}
let remaining = MAX_TOTAL.saturating_sub(self.total);
let limit = MAX_PART.min(remaining);
let limit = maximum.min(remaining);
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
(&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
if out.len() as u64 > limit {
return Err(IoError::TooLarge(if limit < MAX_PART {
return Err(IoError::TooLarge(if remaining < maximum {
"package expands to more than 1 GB".into()
} else {
format!("{part} expands to more than 512 MB")
format!("{part} expands to more than {maximum} bytes")
}));
}
self.total += out.len() as u64;
+2
View File
@@ -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))
}
+6
View File
@@ -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() {
+1
View File
@@ -6,6 +6,7 @@ mod malformed;
mod ods_tests;
mod pivot_tests;
mod roundtrip;
mod xlsb_tests;
use std::io::Write;
+24
View File
@@ -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#"<f>A1:A3*2</f><v>#VALUE!</v></c>"#), "{xml}");
assert!(xml.contains(r#"<f>_xlfn._xlws.FILTER(A1:A3,A1:A3&gt;5)</f><v>#VALUE!</v></c>"#), "{xml}");
assert!(xml.contains(r#"<c r="C3" t="e"><f>NA()</f><v>#N/A</v></c>"#), "{xml}");
}
+326
View File
@@ -0,0 +1,326 @@
//! Original BIFF12 records and ZIP packages assembled in Rust from the public format schema.
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
use gridcraft_model::Visibility;
use super::make_zip;
use crate::{Format, read_xlsb, read_xlsx, sniff, write_xlsx};
const ROOT: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="book" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="records/book.bin"/></Relationships>"#;
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="one" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/one.bin"/><Relationship Id="two" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/two.bin"/><Relationship Id="strings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="../text/strings.bin"/></Relationships>"#;
const TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/records/book.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/><Override PartName="/data/one.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/data/two.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/text/strings.bin" ContentType="application/vnd.ms-excel.sharedStrings"/></Types>"#;
fn record(id: u16, bytes: &[u8]) -> Vec<u8> {
let mut out = vec![];
if id < 128 {
out.push(id as u8);
} else {
out.extend([(id as u8 & 0x7f) | 0x80, (id >> 7) as u8]);
}
let mut len = bytes.len();
loop {
let byte = (len & 0x7f) as u8;
len >>= 7;
out.push(if len == 0 { byte } else { byte | 0x80 });
if len == 0 {
break;
}
}
out.extend(bytes);
out
}
fn wide(text: &str) -> Vec<u8> {
let units: Vec<_> = text.encode_utf16().collect();
let mut out = (units.len() as u32).to_le_bytes().to_vec();
for unit in units {
out.extend(unit.to_le_bytes());
}
out
}
fn rich(text: &str, extras: bool) -> Vec<u8> {
let mut out = vec![if extras { 3 } else { 0 }];
out.extend(wide(text));
if extras {
out.extend(1u32.to_le_bytes());
out.extend([0, 0, 0, 0]); // One original formatting run.
out.extend(wide("sound"));
out.extend(1u32.to_le_bytes());
out.extend([0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); // One ten-byte phonetic run.
}
out
}
fn row(r: u32) -> Vec<u8> {
let mut out = r.to_le_bytes().to_vec();
out.extend([0; 13]);
record(0, &out)
}
fn cell(id: u16, col: u32, value: &[u8]) -> Vec<u8> {
let mut data = col.to_le_bytes().to_vec();
data.extend([0; 4]);
data.extend(value);
if (8..=11).contains(&id) {
data.extend([0; 2]); // Formula flags.
data.extend(3u32.to_le_bytes());
data.extend([0x1e, 1, 0]); // Original PtgInt(1); ignored because we import the cache.
data.extend(0u32.to_le_bytes());
}
record(id, &data)
}
fn worksheet(data: &[u8], tail: &[u8]) -> Vec<u8> {
let mut out = record(129, &[]);
out.extend(record(148, &[0xff; 16])); // A bogus dimension must never allocate a dense grid.
out.extend(record(145, &[]));
out.extend(data);
out.extend(record(146, &[]));
out.extend(tail);
out.extend(record(130, &[]));
out
}
fn book(date1904: bool) -> Vec<u8> {
let mut out = record(131, &[]);
let mut props = u32::from(date1904).to_le_bytes().to_vec();
props.extend(0u32.to_le_bytes());
props.extend(wide(""));
out.extend(record(153, &props));
out.extend(record(143, &[]));
for (id, rel, name, state) in [(1u32, "one", "Data", 0u32), (2, "two", "Hidden", 1)] {
let mut data = state.to_le_bytes().to_vec();
data.extend(id.to_le_bytes());
data.extend(wide(rel));
data.extend(wide(name));
out.extend(record(156, &data));
}
out.extend(record(144, &[]));
out.extend(record(132, &[]));
out
}
fn sst() -> Vec<u8> {
let mut out = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]);
out.extend(record(19, &rich("shared 🦀 café", true)));
out.extend(record(160, &[]));
out
}
fn package(book: &[u8], sheet: &[u8], strings: &[u8], roots: &str, rels: &str, types: &str) -> Vec<u8> {
make_zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", roots.as_bytes()),
("records/book.bin", book),
("records/_rels/book.bin.rels", rels.as_bytes()),
("data/one.bin", sheet),
("data/two.bin", &worksheet(&[], &[])),
("text/strings.bin", strings),
])
}
fn file(sheet: &[u8]) -> Vec<u8> {
package(&book(false), sheet, &sst(), ROOT, RELS, TYPES)
}
fn text(s: &str) -> Value {
Value::Text(s.into())
}
#[test]
fn xlsb_scalar_caches_rk_unicode_visibility_and_epochs_survive_xlsx_save() {
let mut data = row(2);
let real_rk = (3.5f64.to_bits() >> 32) as u32;
let encoded = [
cell(1, 0, &[]),
cell(2, 1, &((-123i32 << 2) | 2).to_le_bytes()),
cell(2, 2, &((123i32 << 2) | 3).to_le_bytes()),
cell(2, 3, &real_rk.to_le_bytes()),
cell(2, 4, &(real_rk | 1).to_le_bytes()),
cell(3, 5, &[0x17]),
cell(4, 6, &[1]),
cell(5, 7, &123.5f64.to_le_bytes()),
cell(6, 8, &wide("inline λ")),
cell(7, 9, &0u32.to_le_bytes()),
cell(8, 10, &wide("cached text")),
cell(9, 11, &2.0f64.to_le_bytes()),
cell(10, 12, &[0]),
cell(11, 13, &[7]),
cell(62, 14, &rich("rich text", true)),
];
for cell in encoded {
data.extend(cell);
}
let mut merge = vec![];
for n in [5u32, 6, 0, 1] {
merge.extend(n.to_le_bytes());
}
let sheet = worksheet(&data, &record(176, &merge));
let expected = [
Value::Empty,
Value::Number(-123.0),
Value::Number(1.23),
Value::Number(3.5),
Value::Number(0.035),
Value::Error(CellError::Ref),
Value::Bool(true),
Value::Number(123.5),
text("inline λ"),
text("shared 🦀 café"),
text("cached text"),
Value::Number(2.0),
Value::Bool(false),
Value::Error(CellError::Div0),
text("rich text"),
];
for epoch in [false, true] {
let bytes = package(&book(epoch), &sheet, &sst(), ROOT, RELS, TYPES);
assert_eq!(sniff(&bytes), Format::Xlsb);
let (wb, report) = read_xlsb(&bytes).unwrap();
assert_eq!(wb.date_system, if epoch { DateSystem::D1904 } else { DateSystem::D1900 });
assert_eq!(wb.sheets.len(), 2);
assert_eq!(wb.sheets[1].visibility, Visibility::Hidden);
assert_eq!(wb.sheets[0].cells.len(), 14);
assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(5, 0), CellRef::new(6, 1))]);
assert!(report.warnings.iter().any(|w| w.contains("will not recalculate") && w.contains("may display as numbers")));
for (col, expected) in expected.iter().enumerate() {
assert_eq!(&wb.sheets[0].value(CellRef::new(2, col as u32)), expected);
}
assert!(wb.sheets[0].cells.iter().all(|(_, c)| c.formula.is_none()));
let (roundtrip, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(roundtrip.date_system, wb.date_system);
for (col, expected) in expected.iter().enumerate() {
assert_eq!(&roundtrip.sheets[0].value(CellRef::new(2, col as u32)), expected);
}
}
}
#[test]
fn xlsb_extension_blocks_are_skipped_with_strict_boundaries_and_record_framing() {
let mut data = row(0);
data.extend(cell(5, 0, &1.0f64.to_le_bytes()));
data.extend(record(35, &[0; 4]));
data.extend(record(37, &[1, 0, 0, 0, 0, 0]));
data.extend(cell(5, 1, &99.0f64.to_le_bytes()));
data.extend(record(130, &[])); // Looks like EndSheet, but is inside an ignored extension.
data.extend(record(38, &[]));
data.extend(record(36, &[]));
data.extend([0xe8, 0x87, 0x80, 0x80, 0x80, 0x80]); // ID1000: ignored high second-ID/fourth-length bits.
data.extend(cell(5, 1, &2.0f64.to_le_bytes()));
let (wb, report) = read_xlsb(&file(&worksheet(&data, &[]))).unwrap();
assert_eq!(wb.sheets[0].cells.len(), 2);
assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(2.0));
assert!(report.warnings.iter().any(|w| w.contains("blocks were skipped")));
for bad in [
vec![0x81],
vec![0x81, 0],
vec![1, 0x80],
vec![1, 10, 0],
record(36, &[]),
[record(35, &[0; 4]), record(38, &[])].concat(),
record(35, &[0; 4]),
record(37, &[0, 0]),
] {
assert!(read_xlsb(&file(&worksheet(&bad, &[]))).is_err(), "{bad:?}");
}
let nested = record(35, &[0; 4]).repeat(65);
assert!(read_xlsb(&file(&worksheet(&nested, &[]))).unwrap_err().to_string().contains("nested too deeply"));
let mut missing_end = worksheet(&[], &[]);
missing_end.truncate(missing_end.len() - record(130, &[]).len());
assert!(read_xlsb(&file(&missing_end)).is_err());
}
#[test]
fn xlsb_bad_values_strings_and_coordinates_fail_without_silent_data_loss() {
let malformed = [
cell(4, 0, &[2]),
cell(3, 0, &[0xff]),
cell(5, 0, &f64::INFINITY.to_le_bytes()),
cell(2, 0, &0x7ff00000u32.to_le_bytes()),
cell(7, 0, &1u32.to_le_bytes()),
cell(6, 0, &[1, 0, 0, 0, 0, 0xd8]),
cell(6, 0, &u32::MAX.to_le_bytes()),
cell(5, 16384, &1.0f64.to_le_bytes()),
cell(5, 0, &[0; 3]),
cell(62, 0, &[1, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]),
];
for bad in malformed {
assert!(read_xlsb(&file(&worksheet(&[row(0), bad].concat(), &[]))).is_err());
}
assert!(read_xlsb(&file(&worksheet(&cell(5, 0, &1.0f64.to_le_bytes()), &[]))).is_err());
assert!(read_xlsb(&file(&worksheet(&row(1048576), &[]))).is_err());
let repeated = [row(0), cell(5, 0, &1.0f64.to_le_bytes()), cell(5, 0, &2.0f64.to_le_bytes())].concat();
assert!(read_xlsb(&file(&worksheet(&repeated, &[]))).is_err());
let mut formula_payload = [0u8; 18].to_vec(); // Cell header + f64 cache + flags, but no parsed-formula structure.
assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err());
formula_payload.extend(0u32.to_le_bytes()); // Invalid zero token count.
assert!(read_xlsb(&file(&worksheet(&[row(0), record(9, &formula_payload)].concat(), &[]))).is_err());
let bad_sst = [record(159, &[1, 0, 0, 0, 2, 0, 0, 0]), record(160, &[])].concat();
assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err());
}
#[test]
fn xlsb_requires_internal_typed_relationships_and_sniffs_only_root_workbook() {
let sheet = worksheet(&[], &[]);
for (roots, rels, types) in [
(ROOT.replace("officeDocument\"", "wrong\""), RELS.to_owned(), TYPES.to_owned()),
(ROOT.replace("Target=", "TargetMode=\"External\" Target="), RELS.to_owned(), TYPES.to_owned()),
(ROOT.to_owned(), RELS.replace("Target=\"../data/one.bin\"", "Target=\"missing.bin\""), TYPES.to_owned()),
(
ROOT.to_owned(),
RELS.replace("Target=\"../data/one.bin\"", "TargetMode=\"External\" Target=\"https://example.com/data.bin\""),
TYPES.to_owned(),
),
(
ROOT.to_owned(),
RELS.replace("Target=\"../text/strings.bin\"", "TargetMode=\"External\" Target=\"https://example.com/strings.bin\""),
TYPES.to_owned(),
),
(ROOT.to_owned(), RELS.to_owned(), TYPES.replace("application/vnd.ms-excel.worksheet", "application/xml")),
(ROOT.to_owned(), RELS.trim_end_matches("</Relationships>").to_owned(), TYPES.to_owned()),
(ROOT.to_owned(), RELS.to_owned(), TYPES.replace("http://schemas.openxmlformats.org/package/2006/content-types", "wrong")),
] {
assert!(read_xlsb(&package(&book(false), &sheet, &sst(), &roots, &rels, &types)).is_err());
}
let chartsheet = RELS.replace(
"Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
"Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet",
);
let (wb, report) = read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, &chartsheet, TYPES)).unwrap();
assert_eq!(wb.sheets.len(), 1);
assert!(report.warnings.iter().any(|w| w.contains("non-worksheet")));
// An unrelated binary part must not change the format of an XML workbook package.
let xml_book = TYPES.replace(
"/records/book.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main",
"/elsewhere.bin\" ContentType=\"application/vnd.ms-excel.sheet.binary.macroEnabled.main",
);
assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &xml_book)), Format::Xlsx);
let spec_type = TYPES.replace("application/vnd.ms-excel.sheet.binary.macroEnabled.main", "application/vnd.ms-excel.main");
assert_eq!(sniff(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)), Format::Xlsb);
assert!(read_xlsb(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)).is_ok());
}
#[test]
fn xlsb_declared_counts_and_decoded_text_are_bounded() {
let bad_sst = record(159, &[0xff; 8]);
assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err());
let mut count_mismatch = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]);
count_mismatch.extend(record(160, &[]));
assert!(read_xlsb(&package(&book(false), &worksheet(&[], &[]), &count_mismatch, ROOT, RELS, TYPES)).is_err());
let mut data = row(0);
let text = wide(&"x".repeat(32767));
for col in 0..2050 {
data.extend(cell(6, col, &text));
}
assert!(read_xlsb(&file(&worksheet(&data, &[]))).unwrap_err().to_string().contains("64 MiB"));
}
#[test]
fn xlsb_phonetic_runs_use_documented_size_and_tolerate_padding() {
// [MS-XLSB] 2.5.103: a PhRun is 10 bytes. Phonetic data is not imported, so
// padding after the documented runs (e.g. 12-byte runs) is ignored, while
// a run shorter than documented is still rejected.
let phonetic = |runs: u32, bytes: usize| {
let mut out = vec![2];
out.extend(wide("ruby"));
out.extend(wide("ルビ"));
out.extend(runs.to_le_bytes());
out.extend(vec![0; bytes]);
out
};
for padded in [phonetic(1, 10), phonetic(1, 12), phonetic(2, 24)] {
let (wb, _) = read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &padded)].concat(), &[]))).unwrap();
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("ruby"));
}
assert!(read_xlsb(&file(&worksheet(&[row(0), cell(62, 0, &phonetic(2, 12))].concat(), &[]))).is_err());
}
+673
View File
@@ -0,0 +1,673 @@
//! Data-only XLSB import, implemented from Microsoft's public MS-XLSB specification:
//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xlsb/acc8aa92-1f02-4167-99f5-84f9f676b95a>.
//! Formula caches become constants. Styles, macros and other workbook features are not run or imported.
use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Sheet, Visibility, Workbook};
use quick_xml::NsReader;
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
use crate::package::{Package, Rel, normalize, resolve, split_dir};
use crate::{IoError, ReadReport};
const MAIN: &str = "application/vnd.ms-excel.main";
const BINARY_MAIN: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.main";
const WORKSHEET: &str = "application/vnd.ms-excel.worksheet";
const STRINGS: &str = "application/vnd.ms-excel.sharedStrings";
const CT_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/content-types";
const REL_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/relationships";
const REL_TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
const STRICT_REL_TYPE: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/";
const MS_REL_TYPE: &str = "http://schemas.microsoft.com/office/2006/relationships/";
const MAX_META: u64 = 1024 * 1024;
const MAX_TEXT: usize = 64 * 1024 * 1024;
const MAX_CELLS: usize = 1_000_000;
const MAX_STRINGS: usize = 1_000_000;
const MAX_SHEETS: usize = 1024;
const MAX_RECORDS: usize = 5_000_000;
fn invalid(msg: impl Into<String>) -> IoError {
IoError::Format(format!("XLSB: {}", msg.into()))
}
#[derive(Default)]
struct Budget {
cells: usize,
text: usize,
}
impl Budget {
fn text(&mut self, len: usize) -> Result<(), IoError> {
self.text = self.text.saturating_add(len);
if self.text > MAX_TEXT {
return Err(IoError::TooLarge("XLSB decoded text exceeds 64 MiB".into()));
}
Ok(())
}
fn cell(&mut self) -> Result<(), IoError> {
self.cells += 1;
if self.cells > MAX_CELLS {
return Err(IoError::TooLarge("XLSB exceeds 1 million imported cells".into()));
}
Ok(())
}
}
/// Imports worksheet scalar values and saved formula results. Number formats are omitted;
/// dates remain serial numbers with the workbook's original date system. Inspect warnings.
pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
let mut package = Package::open(bytes)?;
let types = content_types(&mut package)?;
let roots = relationships(&mut package, "")?;
let roots: Vec<_> = roots.iter().filter(|r| r.kind == "officeDocument").collect();
if roots.len() != 1 {
return Err(invalid("expected one workbook relationship"));
}
let root = roots.first().ok_or_else(|| invalid("missing workbook relationship"))?;
if root.external {
return Err(invalid("external workbook relationship"));
}
check_type(&types, &root.target, MAIN)?;
let book = required(&mut package, &root.target)?;
let mut wb = Workbook::new();
wb.sheets.clear();
let mut report = ReadReport::default();
report.warn("XLSB data-only import: formulas become their saved results and will not recalculate. Formatting, charts, images, macros and other features are not imported; dates, currency and percentages may display as numbers. Save a new .xlsx file; XLSB saving is not supported.");
let sheets = workbook(&book, &mut wb, &mut report)?;
let rels = relationships(&mut package, &root.target)?;
let mut budget = Budget::default();
let sst_rels: Vec<_> = rels.iter().filter(|r| r.kind == "sharedStrings").collect();
if sst_rels.len() > 1 {
return Err(invalid("multiple shared-string relationships"));
}
let strings = if let Some(rel) = sst_rels.first() {
if rel.external {
return Err(invalid("external shared-string relationship"));
}
check_type(&types, &rel.target, STRINGS)?;
shared_strings(&required(&mut package, &rel.target)?, &mut budget, &mut report)?
} else {
vec![]
};
for info in sheets {
let Some(id) = info.rel else {
if info.visibility != Visibility::VeryHidden {
return Err(invalid("worksheet relationship is null"));
}
report.warn("XLSB module sheets were skipped.");
continue;
};
let rel = rels.iter().find(|r| r.id == id).ok_or_else(|| invalid("worksheet relationship is missing"))?;
if rel.external {
return Err(invalid("external sheet relationship"));
}
if rel.kind != "worksheet" {
if !matches!(rel.kind.as_str(), "chartsheet" | "dialogsheet" | "xlMacrosheet" | "xlIntlMacrosheet") {
return Err(invalid("unknown sheet relationship type"));
}
report.warn("XLSB non-worksheet sheets were skipped.");
continue;
}
check_type(&types, &rel.target, WORKSHEET)?;
wb.check_sheet_name(&info.name, None).map_err(invalid)?;
let mut sheet = worksheet(&required(&mut package, &rel.target)?, &info.name, &strings, &mut budget, &mut report)?;
sheet.visibility = info.visibility;
wb.sheets.push(Arc::new(sheet));
}
if wb.sheets.is_empty() {
return Err(invalid("no supported worksheets"));
}
wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
Ok((wb, report))
}
pub(crate) fn is_xlsb(bytes: &[u8]) -> bool {
let inspect = || -> Result<bool, IoError> {
let mut package = Package::open(bytes)?;
let types = content_types(&mut package)?;
let roots = relationships(&mut package, "")?;
let mut books = roots.iter().filter(|r| r.kind == "officeDocument");
let Some(root) = books.next() else { return Ok(false) };
Ok(books.next().is_none() && !root.external && check_type(&types, &root.target, MAIN).is_ok())
};
inspect().unwrap_or(false)
}
fn required(package: &mut Package<'_>, path: &str) -> Result<Vec<u8>, IoError> {
package.read(path)?.ok_or_else(|| invalid(format!("missing part {path}")))
}
struct Entry {
name: String,
attrs: Vec<(String, String)>,
}
impl Entry {
fn attr(&self, name: &str) -> Option<&str> {
self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
}
/// The two package metadata parts are flat, namespace-qualified XML documents. Keep only
/// direct child entries, and reject truncated XML instead of guessing a partial relationship.
fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoError> {
let mut reader = NsReader::from_reader(bytes);
let mut depth = 0usize;
let mut seen = false;
let mut entries = vec![];
loop {
let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?;
match &event {
Event::Start(e) | Event::Empty(e) => {
let (namespace, local) = reader.resolve_element(e.name());
let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns);
if depth == 0 {
if seen || !valid_ns || local.as_ref() != root_name {
return Err(invalid("invalid package metadata root"));
}
seen = true;
} else if depth == 1 && valid_ns {
let mut attrs = vec![];
for a in e.attributes() {
let a = a.map_err(|e| IoError::Xml(e.to_string()))?;
if !a.key.as_ref().contains(&b':') {
attrs.push((
String::from_utf8_lossy(a.key.as_ref()).into_owned(),
a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(),
));
}
}
entries.push(Entry { name: String::from_utf8_lossy(local.as_ref()).into_owned(), attrs });
}
if matches!(event, Event::Start(_)) {
depth += 1;
if depth > crate::xml::MAX_DEPTH {
return Err(invalid("package metadata is nested too deeply"));
}
}
}
Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?,
Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => {
return Err(invalid("text outside package metadata"));
}
Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")),
Event::Eof => break,
_ => {}
}
}
if !seen || depth != 0 {
return Err(invalid("incomplete package metadata"));
}
Ok(entries)
}
fn content_types(package: &mut Package<'_>) -> Result<Vec<Entry>, IoError> {
let data = package.read_limited("[Content_Types].xml", MAX_META)?.ok_or_else(|| invalid("missing content types"))?;
metadata(&data, CT_NS, b"Types")
}
fn check_type(types: &[Entry], path: &str, expected: &str) -> Result<(), IoError> {
let found = types.iter().find(|e| e.name == "Override" && e.attr("PartName").is_some_and(|p| normalize(p) == normalize(path))).or_else(|| {
types.iter().find(|e| {
e.name == "Default" && e.attr("Extension").is_some_and(|ext| path.rsplit('.').next().is_some_and(|p| p.eq_ignore_ascii_case(ext)))
})
});
let actual = found.and_then(|e| e.attr("ContentType"));
if actual != Some(expected) && !(expected == MAIN && actual == Some(BINARY_MAIN)) {
return Err(invalid(format!("wrong or missing content type for {path}")));
}
Ok(())
}
fn relationships(package: &mut Package<'_>, path: &str) -> Result<Vec<Rel>, IoError> {
let (dir, file) = split_dir(path);
let relpath = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
let data = package.read_limited(&relpath, MAX_META)?.ok_or_else(|| invalid(format!("missing relationships for {path}")))?;
let mut out: Vec<Rel> = vec![];
for e in metadata(&data, REL_NS, b"Relationships")? {
if e.name != "Relationship" {
continue;
}
let id = e.attr("Id").filter(|s| !s.is_empty()).ok_or_else(|| invalid("relationship has no ID"))?;
if out.iter().any(|r| r.id == id) {
return Err(invalid("duplicate relationship ID"));
}
let ty = e.attr("Type").ok_or_else(|| invalid("relationship has no type"))?;
let kind = ty
.strip_prefix(REL_TYPE)
.or_else(|| ty.strip_prefix(STRICT_REL_TYPE))
.or_else(|| ty.strip_prefix(MS_REL_TYPE))
.map(str::to_owned)
.unwrap_or_else(|| format!("unsupported:{ty}"));
let target = e.attr("Target").ok_or_else(|| invalid("relationship has no target"))?;
let external = e.attr("TargetMode") == Some("External");
out.push(Rel { id: id.into(), kind, target: if external { target.into() } else { resolve(dir, target) }, external });
}
Ok(out)
}
struct Bytes<'a> {
rest: &'a [u8],
}
impl<'a> Bytes<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { rest: bytes }
}
fn take(&mut self, n: usize) -> Result<&'a [u8], IoError> {
let out = self.rest.get(..n).ok_or_else(|| invalid("truncated record or string"))?;
self.rest = self.rest.get(n..).ok_or_else(|| invalid("truncated record"))?;
Ok(out)
}
fn u8(&mut self) -> Result<u8, IoError> {
self.take(1)?.first().copied().ok_or_else(|| invalid("truncated byte"))
}
fn u16(&mut self) -> Result<u16, IoError> {
Ok(u16::from_le_bytes(self.take(2)?.try_into().map_err(|_| invalid("truncated u16"))?))
}
fn u32(&mut self) -> Result<u32, IoError> {
Ok(u32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("truncated u32"))?))
}
fn f64(&mut self) -> Result<f64, IoError> {
let n = f64::from_le_bytes(self.take(8)?.try_into().map_err(|_| invalid("truncated number"))?);
if !n.is_finite() {
return Err(invalid("nonfinite numeric value"));
}
Ok(n)
}
fn string(&mut self, max: u32, nullable: bool) -> Result<Option<String>, IoError> {
let count = self.u32()?;
if nullable && count == u32::MAX {
return Ok(None);
}
if count > max {
return Err(IoError::TooLarge("XLSB string length exceeds its limit".into()));
}
let size = (count as usize).checked_mul(2).ok_or_else(|| invalid("string length overflow"))?;
let bytes = self.take(size)?;
let mut text = String::new();
for ch in char::decode_utf16(bytes.as_chunks::<2>().0.iter().copied().map(u16::from_le_bytes)) {
text.push(ch.map_err(|_| invalid("invalid UTF-16 string"))?);
}
Ok(Some(text))
}
fn text(&mut self, max: u32) -> Result<String, IoError> {
self.string(max, false)?.ok_or_else(|| invalid("null string"))
}
fn finish(self) -> Result<(), IoError> {
if !self.rest.is_empty() {
return Err(invalid("unexpected bytes after record payload"));
}
Ok(())
}
}
struct Records<'a> {
bytes: Bytes<'a>,
count: usize,
blocks: Vec<u16>,
}
impl<'a> Records<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes: Bytes::new(bytes), count: 0, blocks: vec![] }
}
fn next(&mut self, report: &mut ReadReport) -> Result<Option<(u16, &'a [u8])>, IoError> {
while !self.bytes.rest.is_empty() {
self.count += 1;
if self.count > MAX_RECORDS {
return Err(IoError::TooLarge("XLSB part contains too many records".into()));
}
let first = self.bytes.u8()?;
let id = if first & 0x80 == 0 {
first as u16
} else {
let id = u16::from(first & 0x7f) | (u16::from(self.bytes.u8()? & 0x7f) << 7);
if id < 128 {
return Err(invalid("invalid two-byte record type"));
}
id
};
let mut size = 0usize;
for shift in [0, 7, 14, 21] {
let b = self.bytes.u8()?;
size |= usize::from(b & 0x7f) << shift;
// The high bit of the fourth length byte is ignored by MS-XLSB.
if b & 0x80 == 0 || shift == 21 {
break;
}
}
let payload = self.bytes.take(size)?;
match id {
35 | 37 => {
let mut b = Bytes::new(payload);
if id == 35 {
b.take(4)?;
} else {
let count = b.u16()?;
if count == 0 {
return Err(invalid("empty alternate-content version list"));
}
b.take(usize::from(count) * 4)?;
}
b.finish()?;
if self.blocks.len() >= 64 {
return Err(invalid("extension blocks nested too deeply"));
}
self.blocks.push(id + 1);
report.warn("Unsupported XLSB future-record and alternate-content blocks were skipped.");
}
36 | 38 => {
if self.blocks.pop() != Some(id) || !payload.is_empty() {
return Err(invalid("mismatched extension block end"));
}
}
_ if !self.blocks.is_empty() => {}
_ => return Ok(Some((id, payload))),
}
}
if !self.blocks.is_empty() {
return Err(invalid("unterminated extension block"));
}
Ok(None)
}
fn first(&mut self, id: u16, report: &mut ReadReport) -> Result<(), IoError> {
if self.next(report)? != Some((id, &[])) {
return Err(invalid("missing part begin record"));
}
Ok(())
}
fn end(&mut self, payload: &[u8], report: &mut ReadReport) -> Result<(), IoError> {
if !payload.is_empty() || self.next(report)?.is_some() {
return Err(invalid("records after part end"));
}
Ok(())
}
}
struct SheetInfo {
name: String,
rel: Option<String>,
visibility: Visibility,
}
fn workbook(bytes: &[u8], wb: &mut Workbook, report: &mut ReadReport) -> Result<Vec<SheetInfo>, IoError> {
let mut records = Records::new(bytes);
records.first(131, report)?;
let mut sheets = vec![];
let mut bundle = false;
let mut bundle_seen = false;
while let Some((id, payload)) = records.next(report)? {
let mut b = Bytes::new(payload);
match id {
132 => {
if bundle || !bundle_seen {
return Err(invalid("incomplete worksheet list"));
}
records.end(payload, report)?;
return Ok(sheets);
}
143 => {
if bundle_seen {
return Err(invalid("duplicate worksheet list"));
}
bundle_seen = true;
bundle = true;
b.finish()?;
}
144 => {
if !bundle {
return Err(invalid("unexpected worksheet list end"));
}
bundle = false;
b.finish()?;
}
153 => {
wb.date_system = if b.u32()? & 1 != 0 { DateSystem::D1904 } else { DateSystem::D1900 };
b.u32()?;
b.text(31)?;
b.finish()?;
}
156 => {
if !bundle {
return Err(invalid("worksheet outside the worksheet list"));
}
if sheets.len() >= MAX_SHEETS {
return Err(IoError::TooLarge("XLSB exceeds 1024 sheets".into()));
}
let visibility = match b.u32()? {
0 => Visibility::Visible,
1 => Visibility::Hidden,
2 => Visibility::VeryHidden,
_ => return Err(invalid("invalid sheet visibility")),
};
let sheet_id = b.u32()?;
if !(1..=65535).contains(&sheet_id) {
return Err(invalid("invalid worksheet ID"));
}
let rel = b.string(255, true)?;
let name = b.text(31)?;
b.finish()?;
sheets.push(SheetInfo { name, rel, visibility });
}
_ => {}
}
}
Err(invalid("missing workbook end record"))
}
fn rich_string(b: &mut Bytes<'_>) -> Result<String, IoError> {
let flags = b.u8()?;
let text = b.text(32767)?;
if flags & 1 != 0 {
let count = b.u32()?;
if count > 32767 {
return Err(invalid("too many rich-text runs"));
}
b.take(count as usize * 4)?;
}
if flags & 2 != 0 {
b.text(32767)?;
let count = b.u32()?;
if count > 32767 {
return Err(invalid("too many phonetic runs"));
}
// [MS-XLSB] 2.5.103 PhRun: ichFirst, ichMom, cchMom, ifnt (u16 each)
// then phType/alcH/unused1 (16 bits) = 10 bytes. A RichStr is always
// the last field of its record (BrtSSTItem, BrtCellRichSt) and
// phonetic data is not imported, so tolerate writers that pad runs:
// require the documented size, then drop whatever follows.
b.take(count as usize * 10)?;
b.rest = &[];
}
Ok(text)
}
fn shared_strings(bytes: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result<Vec<Arc<str>>, IoError> {
let mut records = Records::new(bytes);
let Some((159, payload)) = records.next(report)? else {
return Err(invalid("missing shared-string begin record"));
};
let mut header = Bytes::new(payload);
let total = header.u32()?;
let unique = header.u32()?;
header.finish()?;
if unique > total || unique as usize > MAX_STRINGS {
return Err(IoError::TooLarge("invalid or oversized shared-string count".into()));
}
let mut strings = vec![];
while let Some((id, payload)) = records.next(report)? {
match id {
160 => {
records.end(payload, report)?;
if strings.len() != unique as usize {
return Err(invalid("shared-string count does not match records"));
}
return Ok(strings);
}
19 => {
if strings.len() >= unique as usize {
return Err(invalid("too many shared-string items"));
}
let mut b = Bytes::new(payload);
let text = rich_string(&mut b)?;
b.finish()?;
budget.text(text.len())?;
strings.push(Arc::from(text));
}
_ => return Err(invalid("unexpected shared-string record")),
}
}
Err(invalid("missing shared-string end record"))
}
fn error(code: u8) -> Result<CellError, IoError> {
Ok(match code {
0x00 => CellError::Null,
0x07 => CellError::Div0,
0x0f => CellError::Value,
0x17 => CellError::Ref,
0x1d => CellError::Name,
0x24 => CellError::Num,
0x2a => CellError::NA,
0x2b => CellError::GettingData,
_ => return Err(invalid("unknown cell error code")),
})
}
fn boolean(b: &mut Bytes<'_>) -> Result<bool, IoError> {
match b.u8()? {
0 => Ok(false),
1 => Ok(true),
_ => Err(invalid("invalid boolean value")),
}
}
fn cached_formula(b: &mut Bytes<'_>) -> Result<(), IoError> {
b.take(2)?;
let cce = b.u32()?;
if !(1..=16384).contains(&cce) {
return Err(invalid("invalid formula token length"));
}
b.take(cce as usize)?;
let extra = b.u32()?;
b.take(extra as usize)?;
Ok(())
}
fn value(id: u16, b: &mut Bytes<'_>, strings: &[Arc<str>], budget: &mut Budget) -> Result<Value, IoError> {
let value = match id {
1 => Value::Empty,
2 => {
let rk = b.u32()?;
let n = if rk & 2 != 0 { ((rk as i32) >> 2) as f64 } else { f64::from_bits(u64::from(rk & !3) << 32) };
let n = if rk & 1 != 0 { n / 100.0 } else { n };
if !n.is_finite() {
return Err(invalid("nonfinite RK number"));
}
Value::Number(n)
}
3 | 11 => Value::Error(error(b.u8()?)?),
4 | 10 => Value::Bool(boolean(b)?),
5 | 9 => Value::Number(b.f64()?),
6 | 8 | 62 => {
let s = if id == 62 { rich_string(b)? } else { b.text(32767)? };
budget.text(s.len())?;
Value::Text(Arc::from(s))
}
7 => Value::Text(strings.get(b.u32()? as usize).cloned().ok_or_else(|| invalid("shared-string index is out of bounds"))?),
_ => return Err(invalid("unsupported cell record")),
};
if (8..=11).contains(&id) {
cached_formula(b)?;
}
Ok(value)
}
fn worksheet(bytes: &[u8], name: &str, strings: &[Arc<str>], budget: &mut Budget, report: &mut ReadReport) -> Result<Sheet, IoError> {
let mut records = Records::new(bytes);
records.first(129, report)?;
let mut sheet = Sheet::new(name);
let mut data = false;
let mut data_seen = false;
let mut row = None;
let mut col = None;
while let Some((id, payload)) = records.next(report)? {
let mut b = Bytes::new(payload);
match id {
130 => {
if data || !data_seen {
return Err(invalid("incomplete worksheet data"));
}
records.end(payload, report)?;
return Ok(sheet);
}
145 => {
if data_seen {
return Err(invalid("duplicate worksheet data"));
}
data_seen = true;
data = true;
b.finish()?;
}
146 => {
if !data {
return Err(invalid("unexpected worksheet data end"));
}
data = false;
b.finish()?;
}
0 => {
if !data {
return Err(invalid("row outside worksheet data"));
}
let r = b.u32()?;
if r >= MAX_ROWS || row.is_some_and(|previous| r <= previous) {
return Err(invalid("row is out of bounds or unordered"));
}
b.take(9)?;
let spans = b.u32()?;
if spans > 16 {
return Err(invalid("too many row spans"));
}
b.take(spans as usize * 8)?;
b.finish()?;
row = Some(r);
col = None;
}
1..=11 | 62 => {
if !data {
return Err(invalid("cell outside worksheet data"));
}
let r = row.ok_or_else(|| invalid("cell before row header"))?;
let c = b.u32()?;
if c >= MAX_COLS || col.is_some_and(|previous| c <= previous) {
return Err(invalid("column is out of bounds or unordered"));
}
b.u32()?; // Style index and phonetic flag; formatting is not imported.
let value = value(id, &mut b, strings, budget)?;
b.finish()?;
if !value.is_empty() {
budget.cell()?;
sheet.cells.set(CellRef::new(r, c), Cell::value(value));
}
col = Some(c);
}
176 => {
if data {
return Err(invalid("merge inside worksheet data"));
}
let r0 = b.u32()?;
let r1 = b.u32()?;
let c0 = b.u32()?;
let c1 = b.u32()?;
b.finish()?;
if r0 > r1 || c0 > c1 || r1 >= MAX_ROWS || c1 >= MAX_COLS {
report.warn("Invalid XLSB merged ranges were skipped.");
continue;
}
if sheet.merges.len() >= 100_000 {
return Err(IoError::TooLarge("XLSB exceeds 100000 merges".into()));
}
sheet.merges.push(RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)));
}
_ => {}
}
}
Err(invalid("missing worksheet end record"))
}
+4
View File
@@ -3,6 +3,10 @@
Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a
non-zero exit code.
`.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved
values; source formatting and other workbook features are omitted. Import warnings go to stderr.
Convert to `.xlsx` to save the imported workbook; XLSB output is not supported.
`.ods` files support [worksheet data import](ods-import.md). Formula cells use their last saved
values; source formatting and other workbook features are omitted. Import warnings go to stderr.
Convert to `.xlsx` to save the imported workbook; ODS output is not supported.
+6
View File
@@ -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).
+22
View File
@@ -0,0 +1,22 @@
# Importing Excel binary workbooks
Open an `.xlsb` file with File → Open, drag it onto GridCraft, or pass it to the CLI.
GridCraft imports worksheet data into a new workbook and shows the conversion limits.
The import reads worksheet names, visibility, numbers, text, booleans, errors and merged
cells. Formula cells become their **last saved results**, not live formulas. Those results
may be out of date if the source application did not recalculate before saving.
Source formatting is omitted: dates, times, percentages and currencies initially display as
their underlying numbers. The workbook's date system is retained. Charts, images, names,
macros and other workbook features are not imported. GridCraft does not write XLSB files.
Save opens Save As and suggests a new `.xlsx` file. The source XLSB file is not used as the
workbook's save destination; requests to save to `.xlsb` return an error.
```sh
gridcraft-cli info source.xlsb
gridcraft-cli convert source.xlsb imported.xlsx
```
CLI import warnings go to stderr, leaving structured output on stdout available for scripts.