Review fix-up: merge fixed #104; Portuguese as an i18n/pt.rs module

Merge the fixed #104 branch (which carries #31 and #38) and restructure
the Brazilian Portuguese language onto its per-language layout:

- The PORTUGUESE table moves, unchanged, to crates/ui-egui/src/i18n/pt.rs
  as pt::translate, like zh/ko/ru. Language::PtBr gets code() "pt"
  (parse also accepts pt-BR/pt_BR via the tag reduction); it is still
  saved as "pt".
- No per-language command: the View combo and the control channel switch
  with app.language.set {"language": "pt"} (app.language.portuguese is
  dropped); error text, engine.commands params and the snapshot example
  list every code.
- pt.rs covers the keys added since (Page Layout|Orientation, the display
  theme combo, and the Sheet Options View/Print checkboxes, Exibir /
  Imprimir); the coverage test checks pt like zh/ko/ru.
- people.toml keeps both new contributor entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-10 12:54:33 -04:00
commit ca4011dc5e
83 changed files with 5780 additions and 753 deletions

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

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

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

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

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`.

View File

@ -18,6 +18,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg |
| `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render |
| `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon |

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`.

33
Cargo.lock generated
View File

@ -1392,7 +1392,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@ -1408,7 +1408,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@ -1420,7 +1420,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@ -1441,7 +1441,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@ -1450,7 +1450,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@ -1470,7 +1470,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
"serde",
@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
"regex",
@ -1487,7 +1487,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-engine",
"log",
@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@ -1507,18 +1507,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.3.0"
version = "0.4.0"
[[package]]
name = "gridcraft-ui-egui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"egui",
"egui_kittest",
@ -1530,11 +1530,12 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"web-sys",
]
[[package]]
name = "gridcraft-web"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@ -1551,7 +1552,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@ -4297,7 +4298,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"gridcraft-engine",
"serde_json",

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"

View File

@ -115,6 +115,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
@ -174,6 +187,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 |

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.

View File

@ -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");
}

View File

@ -37,8 +37,17 @@ pub fn start() {
} else if q.contains("sample") {
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
}
let app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
SheetApp::setup_context(&cc.egui_ctx, false);
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
// Follow the browser's language, except a CJK one: the web build has no system
// CJK fonts (none are bundled), so that interface would render as tofu.
let browser = gridcraft_ui_egui::i18n::Language::system();
if !matches!(
browser,
gridcraft_ui_egui::i18n::Language::Zh | gridcraft_ui_egui::i18n::Language::Ja | gridcraft_ui_egui::i18n::Language::Ko
) {
app.ui.language = browser;
}
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
Ok(Box::new(WebShell { app, inbox }))
}),
)

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)
}

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);
}
}
}

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);
}
}

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,15 +64,31 @@ 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;
}
// First run (or a ui.json without a usable language): follow the desktop's language.
app.ui.language = gridcraft_ui_egui::i18n::Language::system();
let Some(dir) = config_dir() else { return };
if let Ok(b) = std::fs::read(dir.join("ui.json"))
&& let Ok(ui) = serde_json::from_slice(&b)
&& let Ok(ui) = serde_json::from_slice::<gridcraft_ui_egui::UiState>(&b)
{
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
let language = saved.unwrap_or(app.ui.language);
app.ui = ui;
app.ui.language = language;
}
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
&& let Ok(p) = serde_json::from_slice(&b)
@ -79,7 +98,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 };
@ -133,6 +152,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"));
@ -157,15 +196,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() {
@ -187,15 +240,22 @@ 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,
Box::new(move |cc| {
SheetApp::setup_context(&cc.egui_ctx, app.ui.dark);
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
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(

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");
}
}

View File

@ -2,23 +2,49 @@
"schema": 1,
"repo": "storytold/gridcraft",
"branch": "main",
"head": "a60bd2f40edae13197965ded9433dcf276caca45",
"head_date": "2026-10-07T18:45:37Z",
"commits": 16,
"head": "51b531be3e8b3f5a19d2b7714a4780a79569ca92",
"head_date": "2026-10-08T13:12:32Z",
"commits": 24,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 16,
"lines_added": 78054,
"lines_deleted": 3954,
"prs": 4,
"commits": 21,
"lines_added": 79088,
"lines_deleted": 3997,
"binary_added": 35,
"binary_deleted": 12,
"first_commit": "2026-10-06T02:09:42Z",
"last_commit": "2026-10-07T18:45:37Z"
"last_commit": "2026-10-08T13:50:17Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 34,
"lines_deleted": 34,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:30Z",
"last_commit": "2026-10-08T14:02:29Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 244,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
}
],
"models": [
@ -27,9 +53,9 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 15,
"lines_added": 78047,
"lines_deleted": 3954
"commits": 23,
"lines_added": 79359,
"lines_deleted": 4032
}
]
}

View File

@ -22,5 +22,9 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.nihilist-narwhal]
display_name = "nihilist-narwhal"
sync_github_name = false
[people.edurbs]
sync_github_name = true

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") =>
{

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) {

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);
}

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));
}

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));
}
}

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) {

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),

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)

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 left && r.width() > 1 {
for row in r.start.row + top as u32..=r.end.row {
let name = clean(sh.value(CellRef::new(row, r.start.col)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col)))));
if r0 <= r1 && c0 <= c1 {
for (on, row) in [(top, r.start.row), (bottom, r.end.row)] {
for c in (c0..=c1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, c)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c)))));
}
}
}
for (on, col) in [(left, r.start.col), (right, r.end.col)] {
for row in (r0..=r1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, col)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1)))));
}
}
}
}
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
let skipped: Vec<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}))
})
}

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) {

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:?}");
}
}

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);
}
}

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),

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);
}

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();

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;

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)]

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("");

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()),

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);

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 };

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);
}
}

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());

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]

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();

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::*;

View File

@ -21,10 +21,13 @@ image = { workspace = true }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The desktop's preferred language, for the first-run interface language. The web build has no
# system locale, so the crate is native-only (see i18n::Language::system).
# The desktop's preferred language for the first-run interface language.
sys-locale = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Read the browser's preferred language for the first-run web interface language.
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"

View File

@ -1,7 +1,7 @@
//! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG.
//!
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|ja|pt]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt]
//! [--dialog 'name={json}'] out.png`
use gridcraft_engine::Session;

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);
}
}
}

View File

@ -155,15 +155,19 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
}
"engine.commands" => {
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label) in [
("ui.ribbonTab", "Ribbon Tab"),
("view.formulaBar", "Formula Bar"),
("view.collapseRibbon", "Collapse Ribbon"),
("view.darkMode", "Dark Mode"),
("view.zoom100", "100%"),
("ui.dialog", "Open Dialog"),
for (id, label, params) in [
("ui.ribbonTab", "Ribbon Tab", ""),
("view.formulaBar", "Formula Bar", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.zoom100", "100%", ""),
("ui.dialog", "Open Dialog", ""),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
v.push(json!({"id": id, "label": label, "enabled": true, "ui": true}));
v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true}));
}
ok(Json::Array(v))
}
@ -178,6 +182,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 +408,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
});
}

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();

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);
}
}

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 {
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();
@ -1651,7 +1765,12 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range);
let table_range = sh
.tables
.iter()
.find(|t| t.range.start.row <= at.y as u32 || true)
.filter(|t| col >= t.range.start.col && col <= t.range.end.col)
.map(|t| t.range);
if sh.autofilter.is_none()
&& let Some(r) = table_range
{
@ -1671,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() {
@ -1686,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;
@ -1705,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() {
@ -1717,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;
}
}
@ -1934,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();
@ -1947,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| {
@ -1960,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 {
@ -2028,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;
}
}

View File

@ -4,47 +4,70 @@
//! The interface language is a preference ([`crate::UiState::language`]): on first run it defaults
//! to the system's language ([`Language::system`]), and the View tab switches it by hand.
//!
//! No Japanese font is bundled and none is installed here: the CJK faces [`crate::theme`] appends
//! to every family already cover kana and kanji, so Japanese interface text renders without help.
//! CJK fonts stay system-provided: [`crate::theme`] appends available CJK faces to every family so
//! Japanese, Chinese and Korean labels and workbook text can render without bundling font files.
use serde::{Deserialize, Serialize};
mod ko;
mod pt;
mod ru;
mod zh;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
En,
Zh,
Ja,
Ko,
Ru,
#[serde(rename = "pt")]
PtBr,
}
impl Language {
pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::PtBr];
pub const ALL: [Self; 6] = [Self::En, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Zh => "简体中文",
Self::Ja => "日本語",
Self::Ko => "한국어",
Self::Ru => "Русский",
Self::PtBr => "Português (Brasil)",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
"pt" | "pt-br" => Some(Self::PtBr),
_ => None,
/// The language's code, as saved in `ui.json` and taken by `app.language.set`.
pub const fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::Zh => "zh",
Self::Ja => "ja",
Self::Ko => "ko",
Self::Ru => "ru",
Self::PtBr => "pt",
}
}
/// A language tag (`ja`, `ja-JP`, `en_US`) reduced to a supported language. Native only: the web
/// build has no system locale to read and tests exercise it on the host.
#[cfg(not(target_arch = "wasm32"))]
pub fn parse(code: &str) -> Option<Self> {
Self::from_tag(code)
}
fn from_tag(tag: &str) -> Option<Self> {
Self::parse(&tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase())
match tag.trim().split(['-', '_']).next()?.to_ascii_lowercase().as_str() {
"en" => Some(Self::En),
"zh" => Some(Self::Zh),
"ja" => Some(Self::Ja),
"ko" => Some(Self::Ko),
"ru" => Some(Self::Ru),
"pt" => Some(Self::PtBr),
_ => None,
}
}
/// The system's preferred interface language, best effort; English when it is unknown or
@ -56,20 +79,50 @@ impl Language {
}
#[cfg(target_arch = "wasm32")]
{
Self::En
web_sys::window().and_then(|window| window.navigator().language()).and_then(|tag| Self::from_tag(&tag)).unwrap_or(Self::En)
}
}
/// Translates an interface label; untranslated labels fall back to English.
///
/// A label that needs two translations (Home's text "Orientation" vs Page Layout's print
/// "Orientation") is keyed `context|label`: English shows only the part after the `|`.
pub fn tr(self, text: &str) -> &str {
let table: &[(&str, &str)] = match self {
Self::En => &[],
Self::Ja => JAPANESE,
Self::PtBr => PORTUGUESE,
let translated = match self {
Self::En => None,
Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated),
Self::Zh => zh::translate(text),
Self::Ko => ko::translate(text),
Self::Ru => ru::translate(text),
Self::PtBr => pt::translate(text),
};
table.iter().find(|(english, _)| *english == text).map_or(text, |(_, translated)| translated)
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
}
/// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a
/// later version, a hand-edited `ui.json`) reads as English instead of failing the whole `UiState`,
/// which would reset every other preference with it.
pub fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Language, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Code(String),
Other(serde::de::IgnoredAny),
}
Ok(match Raw::deserialize(d)? {
Raw::Code(code) => Language::parse(&code).unwrap_or_default(),
Raw::Other(_) => Language::default(),
})
}
/// The language a saved `ui.json` asks for, if it names a supported one. The desktop app uses it
/// to tell "saved English" from "nothing usable saved" (first run, an older file, an unknown
/// value), which starts from [`Language::system`] instead.
pub fn saved_language(ui_json: &serde_json::Value) -> Option<Language> {
ui_json.get("language").and_then(serde_json::Value::as_str).and_then(Language::parse)
}
/// egui context-data key holding the language of the frame being drawn. Widgets that only get a
/// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame.
fn lang_key() -> egui::Id {
@ -125,7 +178,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Vertical Text", "縦書き"),
("Decrease Indent", "インデントを減らす"),
("Increase Indent", "インデントを増やす"),
("Orientation", "印刷の向き"),
("Orientation", "方向"),
("Page Layout|Orientation", "印刷の向き"),
("Angle Counterclockwise", "左回りに回転"),
("Angle Clockwise", "右回りに回転"),
("Rotate Text Up", "文字列を上に回転"),
@ -157,7 +211,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Column Width…", "列の幅…"),
("AutoFit Column Width", "列の幅を自動調整"),
("Default Width…", "標準の幅…"),
("Hide Rows", "行の表示/非表示"),
("Hide Rows", "行を表示しない"),
("Unhide Rows", "再表示"),
("Hide Columns", "列の表示/非表示"),
("Unhide Columns", "再表示"),
@ -222,7 +276,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Use in Formula", "数式で使用"),
("Create from Selection", "選択範囲から作成"),
("Trace Precedents", "参照元のトレース"),
("Trace Dependents", "依存元のトレース"),
("Trace Dependents", "参照先のトレース"),
("Remove Arrows", "矢印の解除"),
("Show Formulas", "数式の表示"),
("Error Checking", "エラーチェック"),
@ -278,6 +332,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Freeze First Column", "最左列の固定"),
("Unfreeze Panes", "ウィンドウ枠固定の解除"),
("Dark Mode", "ダーク モード"),
("Display theme", "表示テーマ"),
("System", "システム"),
("Light", "ライト"),
("Dark", "ダーク"),
("Interface language", "表示言語"),
("Command\nPalette", "コマンド パレット"),
("Agent\nControl", "エージェント制御"),
@ -348,271 +406,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Lookup &\nReference", "検索/行列"),
("Math &\nTrig", "数学/三角"),
("More\nFunctions", "その他の関数"),
];
/// English label → Brazilian Portuguese, keyed like [`JAPANESE`] so the tables stay in step (a test
/// enforces it) and the diff reads against the Japanese column. Labels follow Excel's pt-BR wording;
/// anything absent falls back to English.
const PORTUGUESE: &[(&str, &str)] = &[
("Paste", "Colar"),
("Paste Values", "Colar Valores"),
("Paste Formulas", "Colar Fórmulas"),
("Paste Formatting", "Colar Formatação"),
("Transpose", "Transpor"),
("Paste Link", "Colar Vínculo"),
("Paste Special…", "Colar Especial…"),
("Cut (⌘X)", "Recortar (⌘X)"),
("Copy (⌘C)", "Copiar (⌘C)"),
("Format Painter (double-click to keep it on)", "Pincel de Formatação (clique duas vezes para mantê-lo ativo)"),
("Bold (⌘B)", "Negrito (⌘B)"),
("Italic (⌘I)", "Itálico (⌘I)"),
("Underline (⌘U)", "Sublinhado (⌘U)"),
("Double Underline", "Sublinhado Duplo"),
("Strikethrough", "Riscado"),
("Underline", "Sublinhado"),
("Increase Font Size", "Aumentar Tamanho da Fonte"),
("Decrease Font Size", "Diminuir Tamanho da Fonte"),
("Wrap Text", "Quebrar Texto Automaticamente"),
("Merge & Center", "Mesclar e Centralizar"),
("Merge Cells", "Mesclar Células"),
("Merge Across", "Mesclar Através"),
("Unmerge Cells", "Desmesclar Células"),
("Fill Color", "Cor de Preenchimento"),
("Font Color", "Cor da Fonte"),
("Borders", "Bordas"),
("Top\nAlign", "Alinhar\nno Topo"),
("Middle\nAlign", "Alinhar\nno Meio"),
("Bottom\nAlign", "Alinhar\nem Baixo"),
("Left", "Esquerda"),
("Right", "Direita"),
("Center", "Centro"),
("Horizontal", "Horizontal"),
("Vertical Text", "Texto Vertical"),
("Decrease Indent", "Diminuir Recuo"),
("Increase Indent", "Aumentar Recuo"),
("Orientation", "Orientação"),
("Angle Counterclockwise", "Ângulo Anti-Horário"),
("Angle Clockwise", "Ângulo Horário"),
("Rotate Text Up", "Girar Texto para Cima"),
("Rotate Text Down", "Girar Texto para Baixo"),
("Accounting Number Format", "Formato de Número Contábil"),
("Percent Style", "Estilo de Porcentagem"),
("Comma Style", "Estilo de Vírgula"),
("Increase Decimal", "Aumentar Casas Decimais"),
("Decrease Decimal", "Diminuir Casas Decimais"),
("More Accounting Formats…", "Mais Formatos Contábeis…"),
("Conditional\nFormatting", "Formatação\nCondicional"),
("Conditional Formatting", "Formatação Condicional"),
("Format\nas Table", "Formatar\ncomo Tabela"),
("Cell\nStyles", "Estilos\nde Célula"),
("Format Cells…", "Formatar Células…"),
("Format Cell Alignment…", "Formatar Alinhamento de Células…"),
("Format\nPane", "Painel de\nFormatação"),
("Format", "Formatar"),
("Insert", "Inserir"),
("Insert Cells…", "Inserir Células…"),
("Delete", "Excluir"),
("Delete Cells…", "Excluir Células…"),
("Insert Sheet Rows", "Inserir Linhas de Planilha"),
("Insert Sheet Columns", "Inserir Colunas de Planilha"),
("Delete Sheet Rows", "Excluir Linhas de Planilha"),
("Delete Sheet Columns", "Excluir Colunas de Planilha"),
("Row Height…", "Altura da Linha…"),
("AutoFit Row Height", "Ajustar Altura da Linha"),
("Column Width…", "Largura da Coluna…"),
("AutoFit Column Width", "Ajustar Largura da Coluna"),
("Default Width…", "Largura Padrão…"),
("Hide Rows", "Ocultar Linhas"),
("Unhide Rows", "Reexibir Linhas"),
("Hide Columns", "Ocultar Colunas"),
("Unhide Columns", "Reexibir Colunas"),
("Lock Cell", "Bloquear Célula"),
("Clear", "Limpar"),
("Clear All", "Limpar Tudo"),
("Clear Formats", "Limpar Formatos"),
("Clear Contents", "Limpar Conteúdo"),
("Clear Comments and Notes", "Limpar Comentários e Notas"),
("Clear Hyperlinks", "Limpar Hiperlinks"),
("AutoSum", "Soma Automática"),
("AutoSum (⌘⇧T)", "Soma Automática (⌘⇧T)"),
("Flash\nFill", "Preenchimento\nRelâmpago"),
("Flash Fill", "Preenchimento Relâmpago"),
("Fill", "Preencher"),
("Clear Print Area", "Limpar Área de Impressão"),
("Find &\nSelect", "Localizar e\nSelecionar"),
("Find…", "Localizar…"),
("Replace…", "Substituir…"),
("Go To…", "Ir Para…"),
("Go To Special…", "Ir Para Especial…"),
("PivotTable", "Tabela Dinâmica"),
("Recommended\nCharts", "Gráficos\nRecomendados"),
("Table", "Tabela"),
("Pictures", "Imagens"),
("Shapes", "Formas"),
("Icons", "Ícones"),
("Text\nBox", "Caixa\nde Texto"),
("Comment", "Comentário"),
("New\nComment", "Novo\nComentário"),
("New Note", "Nova Nota"),
("Show/Hide Note", "Mostrar/Ocultar Nota"),
("Header &\nFooter", "Cabeçalho e\nRodapé"),
("Text to\nColumns", "Texto para\nColunas"),
("Link", "Vínculo"),
("Symbol", "Símbolo"),
("Sparklines", "Minigráficos"),
("Header & Footer", "Cabeçalho e Rodapé"),
("Draw with ink", "Desenhar com Tinta"),
("Eraser", "Borracha"),
("Ink to\nShape", "Tinta para\nForma"),
("Select\nObjects", "Selecionar\nObjetos"),
("Themes", "Temas"),
("Margins", "Margens"),
("Size", "Tamanho"),
("Print\nArea", "Área de\nImpressão"),
("Set Print Area", "Definir Área de Impressão"),
("Breaks", "Quebras"),
("Insert Page Break", "Inserir Quebra de Página"),
("Remove Page Break", "Remover Quebra de Página"),
("Reset All Page Breaks", "Redefinir Todas as Quebras de Página"),
("Print\nTitles", "Imprimir\nTítulos"),
("Narrow", "Estreita"),
("Wide", "Ampla"),
("Normal", "Normal"),
("Portrait", "Retrato"),
("Landscape", "Paisagem"),
("Custom Margins…", "Margens Personalizadas…"),
("Insert\nFunction", "Inserir\nFunção"),
("Name\nManager", "Gerenciador\nde Nomes"),
("Define Name", "Definir Nome"),
("Use in Formula", "Usar na Fórmula"),
("Create from Selection", "Criar a Partir da Seleção"),
("Trace Precedents", "Rastrear Precedentes"),
("Trace Dependents", "Rastrear Dependentes"),
("Remove Arrows", "Remover Setas"),
("Show Formulas", "Exibir Fórmulas"),
("Error Checking", "Verificação de Erros"),
("Evaluate Formula", "Avaliar Fórmula"),
("Watch Window", "Janela de Inspeção"),
("Calculation\nOptions", "Opções de\nCálculo"),
("Calculate Now", "Calcular Agora"),
("Calculate Sheet", "Calcular Planilha"),
("Automatic", "Automático"),
("Automatic Except for Data Tables", "Automático Exceto Tabelas de Dados"),
("Manual", "Manual"),
("Sort &\nFilter", "Classificar e\nFiltrar"),
("Sort", "Classificar"),
("Sort A to Z", "Classificar de A a Z"),
("Sort Z to A", "Classificar de Z a A"),
("Custom Sort…", "Classificação Personalizada…"),
("Filter", "Filtrar"),
("Reapply", "Aplicar Novamente"),
("Get Data\n(Text/CSV)", "Obter Dados\n(Texto/CSV)"),
("Remove\nDuplicates", "Remover\nDuplicatas"),
("Data\nValidation", "Validação\nde Dados"),
("Data Validation", "Validação de Dados"),
("Data Validation…", "Validação de Dados…"),
("Circle Invalid Data", "Circundar Dados Inválidos"),
("Clear Validation", "Limpar Validação"),
("Group", "Agrupar"),
("Ungroup", "Desagrupar"),
("Subtotal", "Subtotal"),
("What-If\nAnalysis", "Análise de\nHipóteses"),
("Data Table…", "Tabela de Dados…"),
("Scenario Manager…", "Gerenciador de Cenários…"),
("Goal Seek…", "Buscar Objetivo…"),
("Spelling", "Ortografia"),
("Check\nAccessibility", "Verificar\nAcessibilidade"),
("Threaded Comments", "Comentários Encadeados"),
("Show\nComments", "Mostrar\nComentários"),
("Protect\nWorkbook", "Proteger\nPasta de Trabalho"),
("Unprotect\nSheet", "Desproteger\nPlanilha"),
("Protect Sheet…", "Proteger Planilha…"),
("Comments", "Comentários"),
("Notes", "Notas"),
("Page Break\nPreview", "Visualização\nde Quebras de Página"),
("Page\nLayout", "Layout\nde Página"),
("Formula Bar", "Barra de Fórmulas"),
("Gridlines", "Linhas de Grade"),
("Headings", "Títulos"),
("Zoom", "Zoom"),
("100%", "100%"),
("Zoom to\nSelection", "Zoom na\nSeleção"),
("Freeze\nPanes", "Congelar\nPainéis"),
("Freeze Panes", "Congelar Painéis"),
("Freeze Top Row", "Congelar Linha Superior"),
("Freeze First Column", "Congelar Primeira Coluna"),
("Unfreeze Panes", "Descongelar Painéis"),
("Dark Mode", "Modo Escuro"),
("Interface language", "Idioma da interface"),
("Command\nPalette", "Paleta de\nComandos"),
("Agent\nControl", "Controle\ndo Agente"),
("Action\nJournal", "Registro\nde Ações"),
("About\nGridCraft", "Sobre o\nGridCraft"),
("Share", "Compartilhar"),
("Add Chart\nElement", "Adicionar\nElemento de Gráfico"),
("Change\nChart Type", "Alterar\nTipo de Gráfico"),
("Switch\nRow/Column", "Alternar\nLinha/Coluna"),
("Data Labels: None", "Rótulos de Dados: Nenhum"),
("Data Labels: Show", "Rótulos de Dados: Exibir"),
("Legend: None", "Legenda: Nenhuma"),
("Legend: Bottom", "Legenda: Abaixo"),
("Legend: Top", "Legenda: Acima"),
("Legend: Right", "Legenda: À Direita"),
("Gridlines: None", "Linhas de Grade: Nenhuma"),
("Gridlines: Show", "Linhas de Grade: Exibir"),
("Chart\nTitle", "Título\ndo Gráfico"),
("Delete\nChart", "Excluir\nGráfico"),
("Convert\nto Range", "Converter\nem Intervalo"),
("Table\nStyles", "Estilos\nde Tabela"),
("Top 10 Items", "10 Primeiros Itens"),
("Top 10%", "10% Primeiros"),
("Bottom 10 Items", "10 Últimos Itens"),
("Bottom 10%", "10% Últimos"),
("Above Average", "Acima da Média"),
("Below Average", "Abaixo da Média"),
("Clear Rules from Entire Sheet", "Limpar Regras de Toda a Planilha"),
("Clear Rules from Selected Cells", "Limpar Regras das Células Selecionadas"),
("Constants", "Constantes"),
("Data Bars", "Barras de Dados"),
("Color Scales", "Escalas de Cores"),
("Icon Sets", "Conjuntos de Ícones"),
("Home", "Página Inicial"),
("Draw", "Desenhar"),
("Page Layout", "Layout da Página"),
("Formulas", "Fórmulas"),
("Data", "Dados"),
("Review", "Revisão"),
("View", "Exibir"),
("Automate", "Automatizar"),
("Table Design", "Design de Tabela"),
("Chart Design", "Design de Gráfico"),
("Ready", "Pronto"),
("Enter", "Inserir"),
("Edit", "Editar"),
("Point", "Apontar"),
("Calculate", "Calcular"),
("Select destination and press Enter or choose Paste", "Selecione o destino e pressione Enter ou escolha Colar"),
("General", "Geral"),
("Number", "Número"),
("Currency", "Moeda"),
("Accounting", "Contábil"),
("Date", "Data"),
("Time", "Hora"),
("Percentage", "Porcentagem"),
("Fraction", "Fração"),
("Scientific", "Científico"),
("Text", "Texto"),
("Special", "Especial"),
("Custom", "Personalizado"),
("Short Date", "Data Abreviada"),
("Long Date", "Data Por Extenso"),
("More Number Formats…", "Mais Formatos de Número…"),
("Financial", "Financeira"),
("Logical", "Lógica"),
("Date &\nTime", "Data e\nHora"),
("Lookup &\nReference", "Pesquisa e\nReferência"),
("Math &\nTrig", "Matemática e\nTrigonometria"),
("More\nFunctions", "Mais\nFunções"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
];
#[cfg(test)]
@ -624,46 +419,85 @@ mod tests {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label));
}
for (i, (en, pt)) in PORTUGUESE.iter().enumerate() {
assert!(!pt.is_empty());
assert!(PORTUGUESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
for (english, _) in JAPANESE {
assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}");
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
}
// The tables cover the same interface strings, so per-language coverage stays even.
for (en, _) in PORTUGUESE {
assert!(JAPANESE.iter().any(|(other, _)| en == other), "{en:?} missing from JAPANESE");
}
assert_eq!(JAPANESE.len(), PORTUGUESE.len());
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
assert_eq!(Language::Ko.tr("Data"), "데이터");
assert_eq!(Language::Ru.tr("Data"), "Данные");
assert_eq!(Language::PtBr.tr("Data"), "Dados");
assert_eq!(Language::PtBr.tr("Date"), "Data");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
// A `context|label` key shows only its label in English and splits the translation.
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
assert_eq!(Language::Ja.tr("Orientation"), "方向");
assert_eq!(Language::parse("xx"), None);
}
#[test]
fn unknown_saved_language_keeps_the_other_preferences() {
let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": "xx"}"#).unwrap();
assert!(ui.dark);
assert_eq!(ui.language, Language::En);
let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": 7}"#).unwrap();
assert!(ui.dark);
let ui: crate::UiState = serde_json::from_str(r#"{"language": "ja"}"#).unwrap();
assert_eq!(ui.language, Language::Ja);
assert_eq!(crate::UiState::default().language, Language::En, "the default never reads the host locale");
assert_eq!(saved_language(&serde_json::json!({"language": "ja"})), Some(Language::Ja));
assert_eq!(saved_language(&serde_json::json!({"language": "xx"})), None);
assert_eq!(saved_language(&serde_json::json!({})), None);
}
#[test]
fn tags_reduce_to_a_supported_language() {
assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja));
assert_eq!(Language::from_tag("en_US"), Some(Language::En));
assert_eq!(Language::from_tag("zh-Hans-CN"), Some(Language::Zh));
assert_eq!(Language::from_tag("ko-KR"), Some(Language::Ko));
assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru));
assert_eq!(Language::from_tag("de-DE"), None);
assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr));
assert_eq!(Language::parse("pt"), Some(Language::PtBr));
assert_eq!(Language::parse("pt-br"), Some(Language::PtBr));
}
#[test]
fn an_unset_language_preference_is_english_until_the_app_applies_the_system_one() {
// Deserializing never reads the host locale (tests stay deterministic); the desktop app
// applies `Language::system()` itself when `ui.json` names no usable language.
let restored: crate::UiState = serde_json::from_str("{}").unwrap();
assert_eq!(restored.language, Language::En);
assert_eq!(saved_language(&serde_json::json!({"language": "zh"})), Some(Language::Zh));
}
#[test]
fn language_commands_switch_and_persist() {
let mut app = crate::SheetApp::new(gridcraft_engine::Session::default(), Default::default());
app.run("app.language.japanese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::Ja);
app.run("app.language.portuguese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::PtBr);
let restored: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::PtBr);
for language in Language::ALL {
app.run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
assert_eq!(app.ui.language, language);
let restored: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
assert_eq!(restored.language, language);
}
app.run("app.language.set", serde_json::json!({"code": "ja"})).unwrap();
assert_eq!(app.ui.language, Language::Ja, "`code` is an alias for `language`");
assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog");
app.run("app.language.english", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::En);
for l in Language::ALL {
assert_eq!(Language::parse(l.code()), Some(l));
}
}
}

View File

@ -0,0 +1,273 @@
//! Korean translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "붙여넣기",
"Paste Values" => "값 붙여넣기",
"Paste Formulas" => "수식 붙여넣기",
"Paste Formatting" => "서식 붙여넣기",
"Transpose" => "행/열 바꾸기",
"Paste Link" => "연결하여 붙여넣기",
"Paste Special…" => "선택하여 붙여넣기…",
"Cut (⌘X)" => "잘라내기 (⌘X)",
"Copy (⌘C)" => "복사 (⌘C)",
"Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)",
"Bold (⌘B)" => "굵게 (⌘B)",
"Italic (⌘I)" => "기울임꼴 (⌘I)",
"Underline (⌘U)" => "밑줄 (⌘U)",
"Double Underline" => "이중 밑줄",
"Strikethrough" => "취소선",
"Underline" => "밑줄",
"Increase Font Size" => "글꼴 크기 늘리기",
"Decrease Font Size" => "글꼴 크기 줄이기",
"Wrap Text" => "텍스트 줄 바꿈",
"Merge & Center" => "병합하고 가운데 맞춤",
"Merge Cells" => "셀 병합",
"Merge Across" => "행 방향 병합",
"Unmerge Cells" => "셀 병합 취소",
"Fill Color" => "채우기 색",
"Font Color" => "글꼴 색",
"Borders" => "테두리",
"Top\nAlign" => "위쪽\n맞춤",
"Middle\nAlign" => "가운데\n맞춤",
"Bottom\nAlign" => "아래쪽\n맞춤",
"Left" => "왼쪽",
"Right" => "오른쪽",
"Center" => "가운데",
"Horizontal" => "가로",
"Vertical Text" => "세로 텍스트",
"Decrease Indent" => "들여쓰기 줄이기",
"Increase Indent" => "들여쓰기 늘리기",
"Orientation" => "방향",
"Angle Counterclockwise" => "시계 반대 방향 각도",
"Angle Clockwise" => "시계 방향 각도",
"Rotate Text Up" => "텍스트 위쪽으로 회전",
"Rotate Text Down" => "텍스트 아래쪽으로 회전",
"Accounting Number Format" => "회계 숫자 서식",
"Percent Style" => "백분율 스타일",
"Comma Style" => "쉼표 스타일",
"Increase Decimal" => "소수 자릿수 늘리기",
"Decrease Decimal" => "소수 자릿수 줄이기",
"More Accounting Formats…" => "회계 형식 더 보기…",
"Conditional\nFormatting" => "조건부\n서식",
"Conditional Formatting" => "조건부 서식",
"Format\nas Table" => "표 서식\n지정",
"Cell\nStyles" => "셀\n스타일",
"Format Cells…" => "셀 서식…",
"Format Cell Alignment…" => "셀 맞춤 서식…",
"Format\nPane" => "서식\n창",
"Format" => "서식",
"Insert" => "삽입",
"Insert Cells…" => "셀 삽입…",
"Delete" => "삭제",
"Delete Cells…" => "셀 삭제…",
"Insert Sheet Rows" => "시트 행 삽입",
"Insert Sheet Columns" => "시트 열 삽입",
"Delete Sheet Rows" => "시트 행 삭제",
"Delete Sheet Columns" => "시트 열 삭제",
"Row Height…" => "행 높이…",
"AutoFit Row Height" => "행 높이 자동 맞춤",
"Column Width…" => "열 너비…",
"AutoFit Column Width" => "열 너비 자동 맞춤",
"Default Width…" => "기본 너비…",
"Hide Rows" => "행 숨기기",
"Unhide Rows" => "행 숨기기 취소",
"Hide Columns" => "열 숨기기",
"Unhide Columns" => "열 숨기기 취소",
"Lock Cell" => "셀 잠금",
"Clear" => "지우기",
"Clear All" => "모두 지우기",
"Clear Formats" => "서식 지우기",
"Clear Contents" => "내용 지우기",
"Clear Comments and Notes" => "댓글 및 메모 지우기",
"Clear Hyperlinks" => "하이퍼링크 지우기",
"AutoSum" => "자동 합계",
"AutoSum (⌘⇧T)" => "자동 합계 (⌘⇧T)",
"Flash\nFill" => "빠른\n채우기",
"Flash Fill" => "빠른 채우기",
"Fill" => "채우기",
"Clear Print Area" => "인쇄 영역 지우기",
"Find &\nSelect" => "찾기 및\n선택",
"Find…" => "찾기…",
"Replace…" => "바꾸기…",
"Go To…" => "이동…",
"Go To Special…" => "선택하여 이동…",
"PivotTable" => "피벗 테이블",
"Recommended\nCharts" => "추천\n차트",
"Table" => "표",
"Pictures" => "그림",
"Shapes" => "도형",
"Icons" => "아이콘",
"Text\nBox" => "텍스트\n상자",
"Comment" => "댓글",
"New\nComment" => "새 댓글",
"New Note" => "새 메모",
"Show/Hide Note" => "메모 표시/숨기기",
"Header &\nFooter" => "머리글 및\n바닥글",
"Text to\nColumns" => "텍스트\n나누기",
"Link" => "링크",
"Symbol" => "기호",
"Sparklines" => "스파크라인",
"Header & Footer" => "머리글 및 바닥글",
"Draw with ink" => "잉크로 그리기",
"Eraser" => "지우개",
"Ink to\nShape" => "잉크를\n도형으로",
"Select\nObjects" => "개체\n선택",
"Themes" => "테마",
"Margins" => "여백",
"Size" => "용지 크기",
"Print\nArea" => "인쇄\n영역",
"Set Print Area" => "인쇄 영역 설정",
"Breaks" => "나누기",
"Insert Page Break" => "페이지 나누기 삽입",
"Remove Page Break" => "페이지 나누기 제거",
"Reset All Page Breaks" => "모든 페이지 나누기 다시 설정",
"Print\nTitles" => "인쇄\n제목",
"Narrow" => "좁게",
"Wide" => "넓게",
"Normal" => "보통",
"Portrait" => "세로",
"Landscape" => "가로",
"Custom Margins…" => "사용자 지정 여백…",
"Insert\nFunction" => "함수\n삽입",
"Name\nManager" => "이름\n관리자",
"Define Name" => "이름 정의",
"Use in Formula" => "수식에서 사용",
"Create from Selection" => "선택 영역에서 만들기",
"Trace Precedents" => "선행 셀 추적",
"Trace Dependents" => "종속 셀 추적",
"Remove Arrows" => "화살표 제거",
"Show Formulas" => "수식 표시",
"Error Checking" => "오류 검사",
"Evaluate Formula" => "수식 계산",
"Watch Window" => "감시 창",
"Calculation\nOptions" => "계산\n옵션",
"Calculate Now" => "지금 계산",
"Calculate Sheet" => "시트 계산",
"Automatic" => "자동",
"Automatic Except for Data Tables" => "데이터 표를 제외한 자동",
"Manual" => "수동",
"Sort &\nFilter" => "정렬 및\n필터",
"Sort" => "정렬",
"Sort A to Z" => "오름차순 정렬",
"Sort Z to A" => "내림차순 정렬",
"Custom Sort…" => "사용자 지정 정렬…",
"Filter" => "필터",
"Reapply" => "다시 적용",
"Get Data\n(Text/CSV)" => "데이터 가져오기\n(텍스트/CSV)",
"Remove\nDuplicates" => "중복\n항목 제거",
"Data\nValidation" => "데이터\n유효성 검사",
"Data Validation" => "데이터 유효성 검사",
"Data Validation…" => "데이터 유효성 검사…",
"Circle Invalid Data" => "잘못된 데이터 표시",
"Clear Validation" => "유효성 검사 지우기",
"Group" => "그룹",
"Ungroup" => "그룹 해제",
"Subtotal" => "부분합",
"What-If\nAnalysis" => "가상 분석",
"Data Table…" => "데이터 표…",
"Scenario Manager…" => "시나리오 관리자…",
"Goal Seek…" => "목표값 찾기…",
"Spelling" => "맞춤법 검사",
"Check\nAccessibility" => "접근성\n검사",
"Threaded Comments" => "스레드 댓글",
"Show\nComments" => "댓글\n표시",
"Protect\nWorkbook" => "통합 문서\n보호",
"Unprotect\nSheet" => "시트 보호\n해제",
"Protect Sheet…" => "시트 보호…",
"Comments" => "댓글",
"Notes" => "메모",
"Page Break\nPreview" => "페이지 나누기\n미리 보기",
"Page\nLayout" => "페이지\n레이아웃",
"Formula Bar" => "수식 입력줄",
"Gridlines" => "눈금선",
"Headings" => "머리글",
"Zoom" => "확대/축소",
"100%" => "100%",
"Zoom to\nSelection" => "선택 영역에\n맞춤",
"Freeze\nPanes" => "틀\n고정",
"Freeze Panes" => "틀 고정",
"Freeze Top Row" => "첫 행 고정",
"Freeze First Column" => "첫 열 고정",
"Unfreeze Panes" => "틀 고정 취소",
"Dark Mode" => "다크 모드",
"Interface language" => "인터페이스 언어",
"Command\nPalette" => "명령\n팔레트",
"Agent\nControl" => "에이전트\n제어",
"Action\nJournal" => "작업\n기록",
"About\nGridCraft" => "GridCraft\n정보",
"Share" => "공유",
"Add Chart\nElement" => "차트\n요소 추가",
"Change\nChart Type" => "차트 종류\n변경",
"Switch\nRow/Column" => "행/열\n전환",
"Data Labels: None" => "데이터 레이블: 없음",
"Data Labels: Show" => "데이터 레이블: 표시",
"Legend: None" => "범례: 없음",
"Legend: Bottom" => "범례: 아래쪽",
"Legend: Top" => "범례: 위쪽",
"Legend: Right" => "범례: 오른쪽",
"Gridlines: None" => "눈금선: 없음",
"Gridlines: Show" => "눈금선: 표시",
"Chart\nTitle" => "차트\n제목",
"Delete\nChart" => "차트\n삭제",
"Convert\nto Range" => "범위로\n변환",
"Table\nStyles" => "표\n스타일",
"Top 10 Items" => "상위 10개 항목",
"Top 10%" => "상위 10%",
"Bottom 10 Items" => "하위 10개 항목",
"Bottom 10%" => "하위 10%",
"Above Average" => "평균 초과",
"Below Average" => "평균 미만",
"Clear Rules from Entire Sheet" => "시트 전체에서 규칙 지우기",
"Clear Rules from Selected Cells" => "선택한 셀에서 규칙 지우기",
"Constants" => "상수",
"Data Bars" => "데이터 막대",
"Color Scales" => "색조",
"Icon Sets" => "아이콘 집합",
"Home" => "홈",
"Draw" => "그리기",
"Page Layout" => "페이지 레이아웃",
"Formulas" => "수식",
"Data" => "데이터",
"Review" => "검토",
"View" => "보기",
"Automate" => "자동화",
"Table Design" => "표 디자인",
"Chart Design" => "차트 디자인",
"Ready" => "준비",
"Enter" => "입력",
"Edit" => "편집",
"Point" => "가리키기",
"Calculate" => "계산",
"Select destination and press Enter or choose Paste" => "대상을 선택하고 Enter 키를 누르거나 [붙여넣기]를 선택하세요",
"General" => "일반",
"Number" => "숫자",
"Currency" => "통화",
"Accounting" => "회계",
"Date" => "날짜",
"Time" => "시간",
"Percentage" => "백분율",
"Fraction" => "분수",
"Scientific" => "과학",
"Text" => "텍스트",
"Special" => "특수",
"Custom" => "사용자 지정",
"Short Date" => "간단한 날짜",
"Long Date" => "자세한 날짜",
"More Number Formats…" => "숫자 서식 더 보기…",
"Financial" => "재무",
"Logical" => "논리",
"Date &\nTime" => "날짜 및\n시간",
"Lookup &\nReference" => "조회 및\n참조",
"Math &\nTrig" => "수학 및\n삼각",
"More\nFunctions" => "기타\n함수",
"Page Layout|Orientation" => "용지 방향",
"Display theme" => "표시 테마",
"System" => "시스템",
"Light" => "밝게",
"Dark" => "어둡게",
"Sheet Options|View" => "화면",
"Print" => "인쇄",
_ => return None,
})
}

View File

@ -0,0 +1,274 @@
//! Brazilian Portuguese translations for the interface strings in i18n.rs. Labels follow Excel's
//! pt-BR wording; anything absent falls back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Colar",
"Paste Values" => "Colar Valores",
"Paste Formulas" => "Colar Fórmulas",
"Paste Formatting" => "Colar Formatação",
"Transpose" => "Transpor",
"Paste Link" => "Colar Vínculo",
"Paste Special…" => "Colar Especial…",
"Cut (⌘X)" => "Recortar (⌘X)",
"Copy (⌘C)" => "Copiar (⌘C)",
"Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)",
"Bold (⌘B)" => "Negrito (⌘B)",
"Italic (⌘I)" => "Itálico (⌘I)",
"Underline (⌘U)" => "Sublinhado (⌘U)",
"Double Underline" => "Sublinhado Duplo",
"Strikethrough" => "Riscado",
"Underline" => "Sublinhado",
"Increase Font Size" => "Aumentar Tamanho da Fonte",
"Decrease Font Size" => "Diminuir Tamanho da Fonte",
"Wrap Text" => "Quebrar Texto Automaticamente",
"Merge & Center" => "Mesclar e Centralizar",
"Merge Cells" => "Mesclar Células",
"Merge Across" => "Mesclar Através",
"Unmerge Cells" => "Desmesclar Células",
"Fill Color" => "Cor de Preenchimento",
"Font Color" => "Cor da Fonte",
"Borders" => "Bordas",
"Top\nAlign" => "Alinhar\nno Topo",
"Middle\nAlign" => "Alinhar\nno Meio",
"Bottom\nAlign" => "Alinhar\nem Baixo",
"Left" => "Esquerda",
"Right" => "Direita",
"Center" => "Centro",
"Horizontal" => "Horizontal",
"Vertical Text" => "Texto Vertical",
"Decrease Indent" => "Diminuir Recuo",
"Increase Indent" => "Aumentar Recuo",
"Orientation" => "Orientação",
"Angle Counterclockwise" => "Ângulo Anti-Horário",
"Angle Clockwise" => "Ângulo Horário",
"Rotate Text Up" => "Girar Texto para Cima",
"Rotate Text Down" => "Girar Texto para Baixo",
"Accounting Number Format" => "Formato de Número Contábil",
"Percent Style" => "Estilo de Porcentagem",
"Comma Style" => "Estilo de Vírgula",
"Increase Decimal" => "Aumentar Casas Decimais",
"Decrease Decimal" => "Diminuir Casas Decimais",
"More Accounting Formats…" => "Mais Formatos Contábeis…",
"Conditional\nFormatting" => "Formatação\nCondicional",
"Conditional Formatting" => "Formatação Condicional",
"Format\nas Table" => "Formatar\ncomo Tabela",
"Cell\nStyles" => "Estilos\nde Célula",
"Format Cells…" => "Formatar Células…",
"Format Cell Alignment…" => "Formatar Alinhamento de Células…",
"Format\nPane" => "Painel de\nFormatação",
"Format" => "Formatar",
"Insert" => "Inserir",
"Insert Cells…" => "Inserir Células…",
"Delete" => "Excluir",
"Delete Cells…" => "Excluir Células…",
"Insert Sheet Rows" => "Inserir Linhas de Planilha",
"Insert Sheet Columns" => "Inserir Colunas de Planilha",
"Delete Sheet Rows" => "Excluir Linhas de Planilha",
"Delete Sheet Columns" => "Excluir Colunas de Planilha",
"Row Height…" => "Altura da Linha…",
"AutoFit Row Height" => "Ajustar Altura da Linha",
"Column Width…" => "Largura da Coluna…",
"AutoFit Column Width" => "Ajustar Largura da Coluna",
"Default Width…" => "Largura Padrão…",
"Hide Rows" => "Ocultar Linhas",
"Unhide Rows" => "Reexibir Linhas",
"Hide Columns" => "Ocultar Colunas",
"Unhide Columns" => "Reexibir Colunas",
"Lock Cell" => "Bloquear Célula",
"Clear" => "Limpar",
"Clear All" => "Limpar Tudo",
"Clear Formats" => "Limpar Formatos",
"Clear Contents" => "Limpar Conteúdo",
"Clear Comments and Notes" => "Limpar Comentários e Notas",
"Clear Hyperlinks" => "Limpar Hiperlinks",
"AutoSum" => "Soma Automática",
"AutoSum (⌘⇧T)" => "Soma Automática (⌘⇧T)",
"Flash\nFill" => "Preenchimento\nRelâmpago",
"Flash Fill" => "Preenchimento Relâmpago",
"Fill" => "Preencher",
"Clear Print Area" => "Limpar Área de Impressão",
"Find &\nSelect" => "Localizar e\nSelecionar",
"Find…" => "Localizar…",
"Replace…" => "Substituir…",
"Go To…" => "Ir Para…",
"Go To Special…" => "Ir Para Especial…",
"PivotTable" => "Tabela Dinâmica",
"Recommended\nCharts" => "Gráficos\nRecomendados",
"Table" => "Tabela",
"Pictures" => "Imagens",
"Shapes" => "Formas",
"Icons" => "Ícones",
"Text\nBox" => "Caixa\nde Texto",
"Comment" => "Comentário",
"New\nComment" => "Novo\nComentário",
"New Note" => "Nova Nota",
"Show/Hide Note" => "Mostrar/Ocultar Nota",
"Header &\nFooter" => "Cabeçalho e\nRodapé",
"Text to\nColumns" => "Texto para\nColunas",
"Link" => "Vínculo",
"Symbol" => "Símbolo",
"Sparklines" => "Minigráficos",
"Header & Footer" => "Cabeçalho e Rodapé",
"Draw with ink" => "Desenhar com Tinta",
"Eraser" => "Borracha",
"Ink to\nShape" => "Tinta para\nForma",
"Select\nObjects" => "Selecionar\nObjetos",
"Themes" => "Temas",
"Margins" => "Margens",
"Size" => "Tamanho",
"Print\nArea" => "Área de\nImpressão",
"Set Print Area" => "Definir Área de Impressão",
"Breaks" => "Quebras",
"Insert Page Break" => "Inserir Quebra de Página",
"Remove Page Break" => "Remover Quebra de Página",
"Reset All Page Breaks" => "Redefinir Todas as Quebras de Página",
"Print\nTitles" => "Imprimir\nTítulos",
"Narrow" => "Estreita",
"Wide" => "Ampla",
"Normal" => "Normal",
"Portrait" => "Retrato",
"Landscape" => "Paisagem",
"Custom Margins…" => "Margens Personalizadas…",
"Insert\nFunction" => "Inserir\nFunção",
"Name\nManager" => "Gerenciador\nde Nomes",
"Define Name" => "Definir Nome",
"Use in Formula" => "Usar na Fórmula",
"Create from Selection" => "Criar a Partir da Seleção",
"Trace Precedents" => "Rastrear Precedentes",
"Trace Dependents" => "Rastrear Dependentes",
"Remove Arrows" => "Remover Setas",
"Show Formulas" => "Exibir Fórmulas",
"Error Checking" => "Verificação de Erros",
"Evaluate Formula" => "Avaliar Fórmula",
"Watch Window" => "Janela de Inspeção",
"Calculation\nOptions" => "Opções de\nCálculo",
"Calculate Now" => "Calcular Agora",
"Calculate Sheet" => "Calcular Planilha",
"Automatic" => "Automático",
"Automatic Except for Data Tables" => "Automático Exceto Tabelas de Dados",
"Manual" => "Manual",
"Sort &\nFilter" => "Classificar e\nFiltrar",
"Sort" => "Classificar",
"Sort A to Z" => "Classificar de A a Z",
"Sort Z to A" => "Classificar de Z a A",
"Custom Sort…" => "Classificação Personalizada…",
"Filter" => "Filtrar",
"Reapply" => "Aplicar Novamente",
"Get Data\n(Text/CSV)" => "Obter Dados\n(Texto/CSV)",
"Remove\nDuplicates" => "Remover\nDuplicatas",
"Data\nValidation" => "Validação\nde Dados",
"Data Validation" => "Validação de Dados",
"Data Validation…" => "Validação de Dados…",
"Circle Invalid Data" => "Circundar Dados Inválidos",
"Clear Validation" => "Limpar Validação",
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Subtotal" => "Subtotal",
"What-If\nAnalysis" => "Análise de\nHipóteses",
"Data Table…" => "Tabela de Dados…",
"Scenario Manager…" => "Gerenciador de Cenários…",
"Goal Seek…" => "Buscar Objetivo…",
"Spelling" => "Ortografia",
"Check\nAccessibility" => "Verificar\nAcessibilidade",
"Threaded Comments" => "Comentários Encadeados",
"Show\nComments" => "Mostrar\nComentários",
"Protect\nWorkbook" => "Proteger\nPasta de Trabalho",
"Unprotect\nSheet" => "Desproteger\nPlanilha",
"Protect Sheet…" => "Proteger Planilha…",
"Comments" => "Comentários",
"Notes" => "Notas",
"Page Break\nPreview" => "Visualização\nde Quebras de Página",
"Page\nLayout" => "Layout\nde Página",
"Formula Bar" => "Barra de Fórmulas",
"Gridlines" => "Linhas de Grade",
"Headings" => "Títulos",
"Zoom" => "Zoom",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom na\nSeleção",
"Freeze\nPanes" => "Congelar\nPainéis",
"Freeze Panes" => "Congelar Painéis",
"Freeze Top Row" => "Congelar Linha Superior",
"Freeze First Column" => "Congelar Primeira Coluna",
"Unfreeze Panes" => "Descongelar Painéis",
"Dark Mode" => "Modo Escuro",
"Interface language" => "Idioma da interface",
"Command\nPalette" => "Paleta de\nComandos",
"Agent\nControl" => "Controle\ndo Agente",
"Action\nJournal" => "Registro\nde Ações",
"About\nGridCraft" => "Sobre o\nGridCraft",
"Share" => "Compartilhar",
"Add Chart\nElement" => "Adicionar\nElemento de Gráfico",
"Change\nChart Type" => "Alterar\nTipo de Gráfico",
"Switch\nRow/Column" => "Alternar\nLinha/Coluna",
"Data Labels: None" => "Rótulos de Dados: Nenhum",
"Data Labels: Show" => "Rótulos de Dados: Exibir",
"Legend: None" => "Legenda: Nenhuma",
"Legend: Bottom" => "Legenda: Abaixo",
"Legend: Top" => "Legenda: Acima",
"Legend: Right" => "Legenda: À Direita",
"Gridlines: None" => "Linhas de Grade: Nenhuma",
"Gridlines: Show" => "Linhas de Grade: Exibir",
"Chart\nTitle" => "Título\ndo Gráfico",
"Delete\nChart" => "Excluir\nGráfico",
"Convert\nto Range" => "Converter\nem Intervalo",
"Table\nStyles" => "Estilos\nde Tabela",
"Top 10 Items" => "10 Primeiros Itens",
"Top 10%" => "10% Primeiros",
"Bottom 10 Items" => "10 Últimos Itens",
"Bottom 10%" => "10% Últimos",
"Above Average" => "Acima da Média",
"Below Average" => "Abaixo da Média",
"Clear Rules from Entire Sheet" => "Limpar Regras de Toda a Planilha",
"Clear Rules from Selected Cells" => "Limpar Regras das Células Selecionadas",
"Constants" => "Constantes",
"Data Bars" => "Barras de Dados",
"Color Scales" => "Escalas de Cores",
"Icon Sets" => "Conjuntos de Ícones",
"Home" => "Página Inicial",
"Draw" => "Desenhar",
"Page Layout" => "Layout da Página",
"Formulas" => "Fórmulas",
"Data" => "Dados",
"Review" => "Revisão",
"View" => "Exibir",
"Automate" => "Automatizar",
"Table Design" => "Design de Tabela",
"Chart Design" => "Design de Gráfico",
"Ready" => "Pronto",
"Enter" => "Inserir",
"Edit" => "Editar",
"Point" => "Apontar",
"Calculate" => "Calcular",
"Select destination and press Enter or choose Paste" => "Selecione o destino e pressione Enter ou escolha Colar",
"General" => "Geral",
"Number" => "Número",
"Currency" => "Moeda",
"Accounting" => "Contábil",
"Date" => "Data",
"Time" => "Hora",
"Percentage" => "Porcentagem",
"Fraction" => "Fração",
"Scientific" => "Científico",
"Text" => "Texto",
"Special" => "Especial",
"Custom" => "Personalizado",
"Short Date" => "Data Abreviada",
"Long Date" => "Data Por Extenso",
"More Number Formats…" => "Mais Formatos de Número…",
"Financial" => "Financeira",
"Logical" => "Lógica",
"Date &\nTime" => "Data e\nHora",
"Lookup &\nReference" => "Pesquisa e\nReferência",
"Math &\nTrig" => "Matemática e\nTrigonometria",
"More\nFunctions" => "Mais\nFunções",
"Page Layout|Orientation" => "Orientação",
"Display theme" => "Tema de exibição",
"System" => "Sistema",
"Light" => "Claro",
"Dark" => "Escuro",
"Sheet Options|View" => "Exibir",
"Print" => "Imprimir",
_ => return None,
})
}

View File

@ -0,0 +1,273 @@
//! Russian translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Вставить",
"Paste Values" => "Вставить значения",
"Paste Formulas" => "Вставить формулы",
"Paste Formatting" => "Вставить форматирование",
"Transpose" => "Транспонировать",
"Paste Link" => "Вставить связь",
"Paste Special…" => "Специальная вставка…",
"Cut (⌘X)" => "Вырезать (⌘X)",
"Copy (⌘C)" => "Копировать (⌘C)",
"Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)",
"Bold (⌘B)" => "Полужирный (⌘B)",
"Italic (⌘I)" => "Курсив (⌘I)",
"Underline (⌘U)" => "Подчёркивание (⌘U)",
"Double Underline" => "Двойное подчёркивание",
"Strikethrough" => "Зачёркивание",
"Underline" => "Подчёркивание",
"Increase Font Size" => "Увеличить размер шрифта",
"Decrease Font Size" => "Уменьшить размер шрифта",
"Wrap Text" => "Переносить текст",
"Merge & Center" => "Объединить и поместить в центре",
"Merge Cells" => "Объединить ячейки",
"Merge Across" => "Объединить по строкам",
"Unmerge Cells" => "Отменить объединение ячеек",
"Fill Color" => "Цвет заливки",
"Font Color" => "Цвет шрифта",
"Borders" => "Границы",
"Top\nAlign" => "По верхнему\nкраю",
"Middle\nAlign" => "По центру\nвертикали",
"Bottom\nAlign" => "По нижнему\nкраю",
"Left" => "Слева",
"Right" => "Справа",
"Center" => "По центру",
"Horizontal" => "По горизонтали",
"Vertical Text" => "Вертикальный текст",
"Decrease Indent" => "Уменьшить отступ",
"Increase Indent" => "Увеличить отступ",
"Orientation" => "Ориентация",
"Angle Counterclockwise" => "Повернуть против часовой стрелки",
"Angle Clockwise" => "Повернуть по часовой стрелке",
"Rotate Text Up" => "Повернуть текст вверх",
"Rotate Text Down" => "Повернуть текст вниз",
"Accounting Number Format" => "Финансовый числовой формат",
"Percent Style" => "Процентный формат",
"Comma Style" => "Разделитель разрядов",
"Increase Decimal" => "Увеличить разрядность",
"Decrease Decimal" => "Уменьшить разрядность",
"More Accounting Formats…" => "Другие финансовые форматы…",
"Conditional\nFormatting" => "Условное\nформатирование",
"Conditional Formatting" => "Условное форматирование",
"Format\nas Table" => "Форматировать\nкак таблицу",
"Cell\nStyles" => "Стили\nячеек",
"Format Cells…" => "Формат ячеек…",
"Format Cell Alignment…" => "Выравнивание ячеек…",
"Format\nPane" => "Область\nформатирования",
"Format" => "Формат",
"Insert" => "Вставить",
"Insert Cells…" => "Вставить ячейки…",
"Delete" => "Удалить",
"Delete Cells…" => "Удалить ячейки…",
"Insert Sheet Rows" => "Вставить строки листа",
"Insert Sheet Columns" => "Вставить столбцы листа",
"Delete Sheet Rows" => "Удалить строки листа",
"Delete Sheet Columns" => "Удалить столбцы листа",
"Row Height…" => "Высота строки…",
"AutoFit Row Height" => "Автоподбор высоты строки",
"Column Width…" => "Ширина столбца…",
"AutoFit Column Width" => "Автоподбор ширины столбца",
"Default Width…" => "Ширина по умолчанию…",
"Hide Rows" => "Скрыть строки",
"Unhide Rows" => "Показать строки",
"Hide Columns" => "Скрыть столбцы",
"Unhide Columns" => "Показать столбцы",
"Lock Cell" => "Заблокировать ячейку",
"Clear" => "Очистить",
"Clear All" => "Очистить всё",
"Clear Formats" => "Очистить форматы",
"Clear Contents" => "Очистить содержимое",
"Clear Comments and Notes" => "Удалить комментарии и заметки",
"Clear Hyperlinks" => "Удалить гиперссылки",
"AutoSum" => "Автосумма",
"AutoSum (⌘⇧T)" => "Автосумма (⌘⇧T)",
"Flash\nFill" => "Мгновенное\nзаполнение",
"Flash Fill" => "Мгновенное заполнение",
"Fill" => "Заполнить",
"Clear Print Area" => "Очистить область печати",
"Find &\nSelect" => "Найти и\nвыделить",
"Find…" => "Найти…",
"Replace…" => "Заменить…",
"Go To…" => "Перейти…",
"Go To Special…" => "Выделить группу ячеек…",
"PivotTable" => "Сводная таблица",
"Recommended\nCharts" => "Рекомендуемые\nдиаграммы",
"Table" => "Таблица",
"Pictures" => "Изображения",
"Shapes" => "Фигуры",
"Icons" => "Значки",
"Text\nBox" => "Текстовое\nполе",
"Comment" => "Комментарий",
"New\nComment" => "Новый\nкомментарий",
"New Note" => "Новая заметка",
"Show/Hide Note" => "Показать/скрыть заметку",
"Header &\nFooter" => "Верхний и\nнижний колонтитулы",
"Text to\nColumns" => "Текст\nпо столбцам",
"Link" => "Ссылка",
"Symbol" => "Символ",
"Sparklines" => "Спарклайны",
"Header & Footer" => "Верхний и нижний колонтитулы",
"Draw with ink" => "Рисование от руки",
"Eraser" => "Ластик",
"Ink to\nShape" => "Преобразовать рукописный\nввод в фигуру",
"Select\nObjects" => "Выбрать\nобъекты",
"Themes" => "Темы",
"Margins" => "Поля",
"Size" => "Размер",
"Print\nArea" => "Область\nпечати",
"Set Print Area" => "Задать область печати",
"Breaks" => "Разрывы",
"Insert Page Break" => "Вставить разрыв страницы",
"Remove Page Break" => "Удалить разрыв страницы",
"Reset All Page Breaks" => "Сбросить все разрывы страниц",
"Print\nTitles" => "Печатать\nзаголовки",
"Narrow" => "Узкие",
"Wide" => "Широкие",
"Normal" => "Обычные",
"Portrait" => "Книжная",
"Landscape" => "Альбомная",
"Custom Margins…" => "Настраиваемые поля…",
"Insert\nFunction" => "Вставить\nфункцию",
"Name\nManager" => "Диспетчер\nимён",
"Define Name" => "Присвоить имя",
"Use in Formula" => "Использовать в формуле",
"Create from Selection" => "Создать из выделенного",
"Trace Precedents" => "Влияющие ячейки",
"Trace Dependents" => "Зависимые ячейки",
"Remove Arrows" => "Убрать стрелки",
"Show Formulas" => "Показать формулы",
"Error Checking" => "Проверка ошибок",
"Evaluate Formula" => "Вычислить формулу",
"Watch Window" => "Окно контрольных значений",
"Calculation\nOptions" => "Параметры\nвычислений",
"Calculate Now" => "Вычислить сейчас",
"Calculate Sheet" => "Вычислить лист",
"Automatic" => "Автоматически",
"Automatic Except for Data Tables" => "Автоматически, кроме таблиц данных",
"Manual" => "Вручную",
"Sort &\nFilter" => "Сортировка и\nфильтр",
"Sort" => "Сортировка",
"Sort A to Z" => "Сортировка от А до Я",
"Sort Z to A" => "Сортировка от Я до А",
"Custom Sort…" => "Настраиваемая сортировка…",
"Filter" => "Фильтр",
"Reapply" => "Повторить",
"Get Data\n(Text/CSV)" => "Получить данные\n(текст/CSV)",
"Remove\nDuplicates" => "Удалить\nдубликаты",
"Data\nValidation" => "Проверка\nданных",
"Data Validation" => "Проверка данных",
"Data Validation…" => "Проверка данных…",
"Circle Invalid Data" => "Обвести неверные данные",
"Clear Validation" => "Очистить проверку данных",
"Group" => "Группировать",
"Ungroup" => "Разгруппировать",
"Subtotal" => "Промежуточный итог",
"What-If\nAnalysis" => "Анализ «что\nесли»",
"Data Table…" => "Таблица данных…",
"Scenario Manager…" => "Диспетчер сценариев…",
"Goal Seek…" => "Подбор параметра…",
"Spelling" => "Орфография",
"Check\nAccessibility" => "Проверка\nдоступности",
"Threaded Comments" => "Комментарии с ветками",
"Show\nComments" => "Показать\nкомментарии",
"Protect\nWorkbook" => "Защитить\nкнигу",
"Unprotect\nSheet" => "Снять защиту\nлиста",
"Protect Sheet…" => "Защитить лист…",
"Comments" => "Комментарии",
"Notes" => "Заметки",
"Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц",
"Page\nLayout" => "Разметка\nстраницы",
"Formula Bar" => "Строка формул",
"Gridlines" => "Сетка",
"Headings" => "Заголовки",
"Zoom" => "Масштаб",
"100%" => "100%",
"Zoom to\nSelection" => "Масштаб по\nвыделению",
"Freeze\nPanes" => "Закрепить\nобласти",
"Freeze Panes" => "Закрепить области",
"Freeze Top Row" => "Закрепить верхнюю строку",
"Freeze First Column" => "Закрепить первый столбец",
"Unfreeze Panes" => "Снять закрепление областей",
"Dark Mode" => "Тёмный режим",
"Interface language" => "Язык интерфейса",
"Command\nPalette" => "Палитра\nкоманд",
"Agent\nControl" => "Управление\nагентом",
"Action\nJournal" => "Журнал\nдействий",
"About\nGridCraft" => "О\nGridCraft",
"Share" => "Поделиться",
"Add Chart\nElement" => "Добавить элемент\nдиаграммы",
"Change\nChart Type" => "Изменить тип\nдиаграммы",
"Switch\nRow/Column" => "Поменять\nстроки/столбцы",
"Data Labels: None" => "Подписи данных: нет",
"Data Labels: Show" => "Подписи данных: показать",
"Legend: None" => "Легенда: нет",
"Legend: Bottom" => "Легенда: снизу",
"Legend: Top" => "Легенда: сверху",
"Legend: Right" => "Легенда: справа",
"Gridlines: None" => "Линии сетки: нет",
"Gridlines: Show" => "Линии сетки: показать",
"Chart\nTitle" => "Название\nдиаграммы",
"Delete\nChart" => "Удалить\nдиаграмму",
"Convert\nto Range" => "Преобразовать\nв диапазон",
"Table\nStyles" => "Стили\nтаблиц",
"Top 10 Items" => "Первые 10 элементов",
"Top 10%" => "Верхние 10%",
"Bottom 10 Items" => "Последние 10 элементов",
"Bottom 10%" => "Нижние 10%",
"Above Average" => "Выше среднего",
"Below Average" => "Ниже среднего",
"Clear Rules from Entire Sheet" => "Очистить правила на всём листе",
"Clear Rules from Selected Cells" => "Очистить правила для выделенных ячеек",
"Constants" => "Константы",
"Data Bars" => "Гистограммы",
"Color Scales" => "Цветовые шкалы",
"Icon Sets" => "Наборы значков",
"Home" => "Главная",
"Draw" => "Рисование",
"Page Layout" => "Разметка страницы",
"Formulas" => "Формулы",
"Data" => "Данные",
"Review" => "Рецензирование",
"View" => "Вид",
"Automate" => "Автоматизация",
"Table Design" => "Конструктор таблиц",
"Chart Design" => "Конструктор диаграмм",
"Ready" => "Готово",
"Enter" => "Ввод",
"Edit" => "Изменение",
"Point" => "Указание",
"Calculate" => "Вычисление",
"Select destination and press Enter or choose Paste" => "Выберите место вставки и нажмите Enter или выберите «Вставить»",
"General" => "Общий",
"Number" => "Числовой",
"Currency" => "Денежный",
"Accounting" => "Финансовый",
"Date" => "Дата",
"Time" => "Время",
"Percentage" => "Процентный",
"Fraction" => "Дробный",
"Scientific" => "Экспоненциальный",
"Text" => "Текстовый",
"Special" => "Дополнительный",
"Custom" => "Пользовательский",
"Short Date" => "Краткая дата",
"Long Date" => "Длинная дата",
"More Number Formats…" => "Другие числовые форматы…",
"Financial" => "Финансовые",
"Logical" => "Логические",
"Date &\nTime" => "Дата и\nвремя",
"Lookup &\nReference" => "Поиск и\nссылки",
"Math &\nTrig" => "Математические и\nтригонометрические",
"More\nFunctions" => "Другие\nфункции",
"Page Layout|Orientation" => "Ориентация",
"Display theme" => "Тема оформления",
"System" => "Системная",
"Light" => "Светлая",
"Dark" => "Тёмная",
"Sheet Options|View" => "На экране",
"Print" => "Печать",
_ => return None,
})
}

View File

@ -0,0 +1,273 @@
//! Simplified Chinese translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "粘贴",
"Paste Values" => "粘贴值",
"Paste Formulas" => "粘贴公式",
"Paste Formatting" => "粘贴格式",
"Transpose" => "转置",
"Paste Link" => "粘贴链接",
"Paste Special…" => "选择性粘贴…",
"Cut (⌘X)" => "剪切 (⌘X)",
"Copy (⌘C)" => "复制 (⌘C)",
"Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)",
"Bold (⌘B)" => "粗体 (⌘B)",
"Italic (⌘I)" => "斜体 (⌘I)",
"Underline (⌘U)" => "下划线 (⌘U)",
"Double Underline" => "双下划线",
"Strikethrough" => "删除线",
"Underline" => "下划线",
"Increase Font Size" => "增大字号",
"Decrease Font Size" => "减小字号",
"Wrap Text" => "自动换行",
"Merge & Center" => "合并后居中",
"Merge Cells" => "合并单元格",
"Merge Across" => "跨行合并",
"Unmerge Cells" => "取消合并单元格",
"Fill Color" => "填充颜色",
"Font Color" => "字体颜色",
"Borders" => "边框",
"Top\nAlign" => "顶端对齐",
"Middle\nAlign" => "垂直居中",
"Bottom\nAlign" => "底端对齐",
"Left" => "左对齐",
"Right" => "右对齐",
"Center" => "居中",
"Horizontal" => "水平",
"Vertical Text" => "竖排文字",
"Decrease Indent" => "减少缩进",
"Increase Indent" => "增加缩进",
"Orientation" => "方向",
"Angle Counterclockwise" => "逆时针旋转",
"Angle Clockwise" => "顺时针旋转",
"Rotate Text Up" => "文字向上旋转",
"Rotate Text Down" => "文字向下旋转",
"Accounting Number Format" => "会计数字格式",
"Percent Style" => "百分比样式",
"Comma Style" => "千位分隔样式",
"Increase Decimal" => "增加小数位数",
"Decrease Decimal" => "减少小数位数",
"More Accounting Formats…" => "更多会计格式…",
"Conditional\nFormatting" => "条件\n格式",
"Conditional Formatting" => "条件格式",
"Format\nas Table" => "套用表格\n格式",
"Cell\nStyles" => "单元格\n样式",
"Format Cells…" => "设置单元格格式…",
"Format Cell Alignment…" => "设置单元格对齐方式…",
"Format\nPane" => "格式\n窗格",
"Format" => "格式",
"Insert" => "插入",
"Insert Cells…" => "插入单元格…",
"Delete" => "删除",
"Delete Cells…" => "删除单元格…",
"Insert Sheet Rows" => "插入工作表行",
"Insert Sheet Columns" => "插入工作表列",
"Delete Sheet Rows" => "删除工作表行",
"Delete Sheet Columns" => "删除工作表列",
"Row Height…" => "行高…",
"AutoFit Row Height" => "自动调整行高",
"Column Width…" => "列宽…",
"AutoFit Column Width" => "自动调整列宽",
"Default Width…" => "默认列宽…",
"Hide Rows" => "隐藏行",
"Unhide Rows" => "取消隐藏行",
"Hide Columns" => "隐藏列",
"Unhide Columns" => "取消隐藏列",
"Lock Cell" => "锁定单元格",
"Clear" => "清除",
"Clear All" => "全部清除",
"Clear Formats" => "清除格式",
"Clear Contents" => "清除内容",
"Clear Comments and Notes" => "清除批注和备注",
"Clear Hyperlinks" => "清除超链接",
"AutoSum" => "自动求和",
"AutoSum (⌘⇧T)" => "自动求和 (⌘⇧T)",
"Flash\nFill" => "快速\n填充",
"Flash Fill" => "快速填充",
"Fill" => "填充",
"Clear Print Area" => "清除打印区域",
"Find &\nSelect" => "查找与\n选择",
"Find…" => "查找…",
"Replace…" => "替换…",
"Go To…" => "定位…",
"Go To Special…" => "定位条件…",
"PivotTable" => "数据透视表",
"Recommended\nCharts" => "推荐\n图表",
"Table" => "表格",
"Pictures" => "图片",
"Shapes" => "形状",
"Icons" => "图标",
"Text\nBox" => "文本\n框",
"Comment" => "批注",
"New\nComment" => "新建\n批注",
"New Note" => "新建备注",
"Show/Hide Note" => "显示/隐藏备注",
"Header &\nFooter" => "页眉和\n页脚",
"Text to\nColumns" => "分列",
"Link" => "链接",
"Symbol" => "符号",
"Sparklines" => "迷你图",
"Header & Footer" => "页眉和页脚",
"Draw with ink" => "墨迹绘制",
"Eraser" => "橡皮擦",
"Ink to\nShape" => "墨迹转\n形状",
"Select\nObjects" => "选择\n对象",
"Themes" => "主题",
"Margins" => "页边距",
"Size" => "纸张大小",
"Print\nArea" => "打印\n区域",
"Set Print Area" => "设置打印区域",
"Breaks" => "分页符",
"Insert Page Break" => "插入分页符",
"Remove Page Break" => "删除分页符",
"Reset All Page Breaks" => "重置所有分页符",
"Print\nTitles" => "打印\n标题",
"Narrow" => "窄",
"Wide" => "宽",
"Normal" => "普通",
"Portrait" => "纵向",
"Landscape" => "横向",
"Custom Margins…" => "自定义页边距…",
"Insert\nFunction" => "插入\n函数",
"Name\nManager" => "名称\n管理器",
"Define Name" => "定义名称",
"Use in Formula" => "在公式中使用",
"Create from Selection" => "根据所选内容创建",
"Trace Precedents" => "追踪引用单元格",
"Trace Dependents" => "追踪从属单元格",
"Remove Arrows" => "删除箭头",
"Show Formulas" => "显示公式",
"Error Checking" => "错误检查",
"Evaluate Formula" => "公式求值",
"Watch Window" => "监视窗口",
"Calculation\nOptions" => "计算\n选项",
"Calculate Now" => "立即计算",
"Calculate Sheet" => "计算工作表",
"Automatic" => "自动",
"Automatic Except for Data Tables" => "除数据表以外自动",
"Manual" => "手动",
"Sort &\nFilter" => "排序和\n筛选",
"Sort" => "排序",
"Sort A to Z" => "升序排序",
"Sort Z to A" => "降序排序",
"Custom Sort…" => "自定义排序…",
"Filter" => "筛选",
"Reapply" => "重新应用",
"Get Data\n(Text/CSV)" => "获取数据\n(文本/CSV)",
"Remove\nDuplicates" => "删除\n重复项",
"Data\nValidation" => "数据\n验证",
"Data Validation" => "数据验证",
"Data Validation…" => "数据验证…",
"Circle Invalid Data" => "圈出无效数据",
"Clear Validation" => "清除验证",
"Group" => "组合",
"Ungroup" => "取消组合",
"Subtotal" => "分类汇总",
"What-If\nAnalysis" => "假设分析",
"Data Table…" => "数据表…",
"Scenario Manager…" => "方案管理器…",
"Goal Seek…" => "单变量求解…",
"Spelling" => "拼写检查",
"Check\nAccessibility" => "检查\n辅助功能",
"Threaded Comments" => "线程式批注",
"Show\nComments" => "显示\n批注",
"Protect\nWorkbook" => "保护\n工作簿",
"Unprotect\nSheet" => "撤销\n工作表保护",
"Protect Sheet…" => "保护工作表…",
"Comments" => "批注",
"Notes" => "备注",
"Page Break\nPreview" => "分页符\n预览",
"Page\nLayout" => "页面\n布局",
"Formula Bar" => "编辑栏",
"Gridlines" => "网格线",
"Headings" => "标题",
"Zoom" => "缩放",
"100%" => "100%",
"Zoom to\nSelection" => "缩放到\n所选内容",
"Freeze\nPanes" => "冻结\n窗格",
"Freeze Panes" => "冻结窗格",
"Freeze Top Row" => "冻结首行",
"Freeze First Column" => "冻结首列",
"Unfreeze Panes" => "取消冻结窗格",
"Dark Mode" => "深色模式",
"Interface language" => "界面语言",
"Command\nPalette" => "命令\n面板",
"Agent\nControl" => "智能体\n控制",
"Action\nJournal" => "操作\n日志",
"About\nGridCraft" => "关于\nGridCraft",
"Share" => "分享",
"Add Chart\nElement" => "添加图表\n元素",
"Change\nChart Type" => "更改\n图表类型",
"Switch\nRow/Column" => "切换\n行/列",
"Data Labels: None" => "数据标签:无",
"Data Labels: Show" => "数据标签:显示",
"Legend: None" => "图例:无",
"Legend: Bottom" => "图例:底部",
"Legend: Top" => "图例:顶部",
"Legend: Right" => "图例:右侧",
"Gridlines: None" => "网格线:无",
"Gridlines: Show" => "网格线:显示",
"Chart\nTitle" => "图表\n标题",
"Delete\nChart" => "删除\n图表",
"Convert\nto Range" => "转换\n为区域",
"Table\nStyles" => "表格\n样式",
"Top 10 Items" => "前 10 项",
"Top 10%" => "前 10%",
"Bottom 10 Items" => "后 10 项",
"Bottom 10%" => "后 10%",
"Above Average" => "高于平均值",
"Below Average" => "低于平均值",
"Clear Rules from Entire Sheet" => "清除整张工作表的规则",
"Clear Rules from Selected Cells" => "清除所选单元格的规则",
"Constants" => "常量",
"Data Bars" => "数据条",
"Color Scales" => "色阶",
"Icon Sets" => "图标集",
"Home" => "开始",
"Draw" => "绘图",
"Page Layout" => "页面布局",
"Formulas" => "公式",
"Data" => "数据",
"Review" => "审阅",
"View" => "视图",
"Automate" => "自动化",
"Table Design" => "表格设计",
"Chart Design" => "图表设计",
"Ready" => "就绪",
"Enter" => "输入",
"Edit" => "编辑",
"Point" => "指向",
"Calculate" => "计算",
"Select destination and press Enter or choose Paste" => "选择目标位置并按 Enter,或选择“粘贴”",
"General" => "常规",
"Number" => "数值",
"Currency" => "货币",
"Accounting" => "会计专用",
"Date" => "日期",
"Time" => "时间",
"Percentage" => "百分比",
"Fraction" => "分数",
"Scientific" => "科学记数法",
"Text" => "文本",
"Special" => "特殊",
"Custom" => "自定义",
"Short Date" => "短日期",
"Long Date" => "长日期",
"More Number Formats…" => "更多数字格式…",
"Financial" => "财务",
"Logical" => "逻辑",
"Date &\nTime" => "日期和\n时间",
"Lookup &\nReference" => "查找和\n引用",
"Math &\nTrig" => "数学和\n三角函数",
"More\nFunctions" => "更多\n函数",
"Page Layout|Orientation" => "纸张方向",
"Display theme" => "显示主题",
"System" => "系统",
"Light" => "浅色",
"Dark" => "深色",
"Sheet Options|View" => "屏幕",
"Print" => "打印",
_ => return None,
})
}

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;
@ -35,15 +36,20 @@ 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,
pub formula_bar_expanded: bool,
pub status_bar: bool,
pub recent: Vec<String>,
/// Interface language. Absent in older `ui.json` → the system's language (English fallback).
#[serde(default = "i18n::Language::system")]
/// Interface language. English by default (so tests and headless renders never depend on the
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
/// rather than failing the whole `UiState`.
#[serde(deserialize_with = "i18n::lenient")]
pub language: i18n::Language,
}
@ -51,13 +57,36 @@ impl Default for UiState {
fn default() -> Self {
UiState {
dark: false,
system_theme: false,
ribbon_tab: "Home".into(),
ribbon_collapsed: false,
formula_bar: true,
formula_bar_expanded: false,
status_bar: true,
recent: vec![],
language: i18n::Language::system(),
language: i18n::Language::En,
}
}
}
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"
}
}
}
@ -109,6 +138,10 @@ pub struct SheetApp {
pub perf: Perf,
pub fonts_ready: bool,
fonts_set: bool,
/// Han face order the installed fonts were built with; a language switch rebuilds the fonts
/// only when it changes this (see `theme::HanOrder`).
fonts_han: Option<theme::HanOrder>,
effective_dark: bool,
pub name_box: Option<String>,
pub(crate) shots: control::Shots,
/// Chart selected on the sheet (id).
@ -138,6 +171,8 @@ impl SheetApp {
perf: Perf::default(),
fonts_ready: false,
fonts_set: false,
fonts_han: None,
effective_dark: false,
name_box: None,
shots: control::Shots::default(),
selected_chart: None,
@ -145,9 +180,14 @@ impl SheetApp {
}
}
/// One-time context setup: fonts and visuals.
/// One-time context setup: fonts and visuals (default Han order; never reads the host locale).
pub fn setup_context(ctx: &egui::Context, dark: bool) {
ctx.set_fonts(theme::font_definitions());
Self::setup_context_for_language(ctx, dark, i18n::Language::En);
}
/// Sets up fonts and visuals for a specific persisted interface language.
pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) {
ctx.set_fonts(theme::font_definitions_for_language(language));
theme::apply(ctx, dark);
}
@ -188,20 +228,32 @@ 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())),
"app.language.set" => {
let code = p.get("language").and_then(Json::as_str).and_then(i18n::Language::parse);
match code {
// `{"language": "ja"}`; `code` is accepted as an alias.
let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or("");
match i18n::Language::parse(code) {
Some(l) => {
self.ui.language = l;
Ok(json!({"language": l}))
}
None => {
Err(format!("unknown language {:?} (use \"en\", \"ja\" or \"pt\")", p.get("language").and_then(Json::as_str).unwrap_or("")))
}
None => Err(format!("unknown language {code:?} (use \"en\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")),
}
}
"app.language.english" => {
@ -212,10 +264,6 @@ impl SheetApp {
self.ui.language = i18n::Language::Ja;
Ok(json!({"language": i18n::Language::Ja}))
}
"app.language.portuguese" => {
self.ui.language = i18n::Language::PtBr;
Ok(json!({"language": i18n::Language::PtBr}))
}
"ui.dialog" => {
let name = p.get("name").and_then(Json::as_str).unwrap_or("");
self.open_dialog(name, p.clone());
@ -305,7 +353,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),
@ -381,19 +429,27 @@ impl SheetApp {
/// Per-frame logic (control channel, screenshots). Call before `ui`.
pub fn logic(&mut self, ctx: &egui::Context) {
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
self.fonts_ready = false;
self.fonts_set = false;
}
if !self.fonts_ready {
// New fonts apply from the next frame on: paint nothing until then.
if self.fonts_set {
self.fonts_ready = true;
} else {
SheetApp::setup_context(ctx, self.ui.dark);
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
self.fonts_set = true;
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
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

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());
@ -203,6 +204,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()
@ -354,6 +381,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"),
@ -371,7 +399,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}));
}
@ -1218,7 +1246,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
],
);
});
let o = big_button(ui, Icon::Orient, "Orientation", "Orientation", true);
let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true);
egui::Popup::menu(&o).show(|ui| {
menu_items(
app,
@ -1266,9 +1294,10 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
.active()
.and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings)))
.unwrap_or((true, true, false, false));
// These two checkboxes mean the screen view vs. the printed page, so they do not share the
// "View" tab label; pick per language rather than through the shared table.
let (screen, print) = if app.ui.language == crate::i18n::Language::Ja { ("画面", "印刷") } else { ("View", "Print") };
// These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in
// Excel), but the key is distinct from the "View" tab so languages can word them apart.
let screen = app.ui.language.tr("Sheet Options|View");
let print = app.ui.language.tr("Print");
ui.vertical(|ui| {
ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small());
let mut v = sh.0;
@ -1572,11 +1601,21 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
],
);
});
let mut dark = app.ui.dark;
ui.vertical(|ui| {
if ui.checkbox(&mut dark, lang.tr("Dark Mode")).changed() {
app.ui.dark = dark;
}
ui.label(lang.tr("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(lang.tr(label)).width(88.0).show_ui(ui, |ui| {
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
act(app, "view.theme", json!({"mode": value}));
}
}
});
});
sep(ui);
// Interface language: settings live in the View tab, like Excel's Options. The label follows
@ -1588,12 +1627,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|ui| {
for l in crate::i18n::Language::ALL {
if ui.selectable_label(l == lang, l.name()).clicked() {
let code = match l {
crate::i18n::Language::En => "app.language.english",
crate::i18n::Language::Ja => "app.language.japanese",
crate::i18n::Language::PtBr => "app.language.portuguese",
};
act(app, code, json!({}));
act(app, "app.language.set", json!({"language": l.code()}));
}
}
},

View File

@ -287,14 +287,37 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
v
}
/// Builds the font set from system fonts (with egui's defaults as fallback).
/// Builds the font set from system fonts (with egui's defaults as fallback), with the default
/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable.
pub fn font_definitions() -> FontDefinitions {
font_definitions_for_language(crate::i18n::Language::En)
}
/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only
/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two
/// languages with the same order needs no font rebuild.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HanOrder {
/// Japanese glyph shapes first (the interface is Japanese).
Japanese,
/// Simplified Chinese first: the widest Han coverage, for every other interface language.
Chinese,
}
impl HanOrder {
pub fn of(language: crate::i18n::Language) -> Self {
if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese }
}
}
/// Builds the font set with the Han fallback faces ordered for the interface language.
pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
// CJK has to sit at the end of each chain: a Latin font owns Latin, and epaint picks the first
// face that has the glyph, so the CJK faces only ever catch Han/Kana/Hangul.
let cjk = load_cjk(&mut fonts);
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
let fallback = load_fallback(&mut fonts, HanOrder::of(language));
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
let paths = candidates(role);
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
@ -309,7 +332,7 @@ pub fn font_definitions() -> FontDefinitions {
chain.push(format!("sys-{CELL}"));
}
chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() });
chain.extend(cjk.iter().cloned());
chain.extend(fallback.iter().cloned());
chain.retain(|k| fonts.font_data.contains_key(k));
fonts.families.insert(FontFamily::Name(role.into()), chain);
}
@ -320,85 +343,202 @@ pub fn font_definitions() -> FontDefinitions {
fonts
}
/// Loads the system CJK faces (Han/Kana and Hangul, one file each) and returns their keys so every
/// family can carry them as fallback. egui's bundled fonts have no CJK coverage, so without this a
/// Japanese or Chinese workbook renders as tofu boxes even though the text was read correctly.
/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so
/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so
/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text
/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing
/// is bundled, and a machine without the face keeps the old behaviour.
#[cfg(not(target_arch = "wasm32"))]
fn load_cjk(fonts: &mut FontDefinitions) -> Vec<String> {
fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec<String> {
let mut keys = Vec::new();
for (key, cands) in [("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul())] {
let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect();
if let Some(fd) = try_load(&refs) {
fonts.font_data.insert(key.to_string(), Arc::new(fd));
keys.push(key.to_string());
// Files already loaded as a fallback face. A file serves at most one key: the region-merged
// Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and
// Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
let mut loaded: Vec<String> = Vec::new();
for (key, cands) in fallback_faces(han) {
for (path, index) in &cands {
if loaded.contains(path) {
break; // a face loaded for an earlier script already covers this one
}
if let Some(fd) = try_load(&[(path.as_str(), *index)]) {
fonts.font_data.insert(key.to_string(), Arc::new(fd));
keys.push(key.to_string());
loaded.push(path.clone());
break;
}
}
}
keys
}
#[cfg(target_arch = "wasm32")]
fn load_cjk(_fonts: &mut FontDefinitions) -> Vec<String> {
fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec<String> {
Vec::new()
}
/// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render
/// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries
/// kana, so any of them renders Japanese; the order only decides kanji glyph style.
/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script
/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one
/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so
/// the interface language reorders the two Han entries and nothing else.
#[cfg(not(target_arch = "wasm32"))]
fn cjk_han() -> Vec<(String, u32)> {
fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> {
let ja = ("sys-cjk-han-ja", cjk_han_japanese());
let zh = ("sys-cjk-han-zh", cjk_han_simplified());
let (first, second) = match han {
HanOrder::Japanese => (ja, zh),
HanOrder::Chinese => (zh, ja),
};
vec![first, second, ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())]
}
/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`,
/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and
/// flat layouts.
#[cfg(not(target_arch = "wasm32"))]
const LINUX_CJK_DIRS: [&str; 10] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/noto-cjk",
"/usr/share/fonts/truetype/noto",
"/usr/share/fonts/noto",
"/usr/share/fonts/truetype",
"/usr/share/fonts/TTF",
"/usr/share/fonts",
"/usr/local/share/fonts",
"/usr/local/share/fonts/noto-cjk",
];
/// Japanese Han + Kana faces (Japanese kanji glyph shapes).
///
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list
/// comes first loads it; [`load_fallback`] does not load the same file again for the other.
#[cfg(not(target_arch = "wasm32"))]
fn cjk_han_japanese() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic (Japanese)
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic (Japanese)
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (Chinese)
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0));
v.push((format!("{mac}/PingFang.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0));
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
}
v
}
/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks
/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph.
#[cfg(not(target_arch = "wasm32"))]
fn cjk_han_simplified() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
}
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
v.push((format!("{d}/wqy-microhei.ttc"), 0));
}
v
}
/// Hangul faces (Korean; not covered by the Han fonts).
/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file
/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice.
#[cfg(not(target_arch = "wasm32"))]
fn cjk_hangul() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype"];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
for d in lin {
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
}
v
}
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
#[cfg(not(target_arch = "wasm32"))]
fn thai() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
}
v
}
/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
#[cfg(not(target_arch = "wasm32"))]
fn hebrew() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\david.ttf"), 0)); // David
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
v.push((format!("{mac_sup}/David.ttf"), 0));
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
}
v
}
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;
@ -418,37 +558,96 @@ pub fn color32(rgb: [u8; 3]) -> Color32 {
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::i18n::Language;
#[test]
fn cjk_fallback_is_appended_to_every_family() {
// Structural: whatever loaded, the CJK keys must sit at the end of each chain (after the
// Latin/default fonts) so Latin still wins for Latin text.
let fonts = font_definitions();
let cjk_keys: Vec<String> =
["sys-cjk-han", "sys-cjk-hangul"].into_iter().filter(|k| fonts.font_data.contains_key(*k)).map(str::to_string).collect();
if cjk_keys.is_empty() {
return; // no CJK face on this machine (minimal Linux)
}
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
assert!(chain.len() >= cjk_keys.len(), "{role} chain too short for its CJK fallback");
let tail = &chain[chain.len() - cjk_keys.len()..];
assert_eq!(tail, cjk_keys.as_slice(), "{role} must end its chain with the CJK faces");
fn fallback_faces_are_appended_to_every_family() {
// Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain
// (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order.
for han in [HanOrder::Chinese, HanOrder::Japanese] {
let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh };
let fonts = font_definitions_for_language(language);
let fb_keys: Vec<String> =
fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect();
if fb_keys.is_empty() {
return; // no fallback face on this machine (minimal Linux)
}
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback");
let tail = &chain[chain.len() - fb_keys.len()..];
assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces");
}
}
}
#[test]
fn cjk_fallback_covers_japanese() {
// Real render check: the proportional family (what cells and the UI use) must resolve
// Japanese kana/kanji to a glyph, not the tofu replacement char.
if cjk_han().iter().all(|(p, _)| std::fs::read(p).is_err()) {
return; // no CJK face on this machine (minimal Linux); nothing to assert
fn language_reorders_only_the_han_faces() {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] {
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
}
}
#[test]
fn fallback_faces_load_each_file_once() {
// The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded
// once, not once per script (tens of MB each).
for language in [Language::En, Language::Ja] {
let fonts = font_definitions_for_language(language);
let present: Vec<&Arc<FontData>> =
fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect();
for (i, a) in present.iter().enumerate() {
for b in present.iter().skip(i + 1) {
assert!(a.font != b.font, "the same font file is loaded for two fallback scripts");
}
}
}
}
#[test]
fn fallback_covers_its_script() {
// Real render check, per script: the proportional family (what cells and the UI use) must
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
let cases: [(&str, Vec<(String, u32)>, &str); 6] = [
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
("sys-cjk-hangul", cjk_hangul(), "한글"),
("sys-thai", thai(), "ทดสอบ"),
("sys-hebrew", hebrew(), "עברית"),
("ui", candidates(UI), "Русский"),
];
for language in [Language::En, Language::Ja] {
let ctx = egui::Context::default();
ctx.set_fonts(font_definitions_for_language(language));
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
// `has_glyphs` is a false negative when the face that owns the script is also the face
// that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare
// the laid out glyphs with the tofu glyph itself instead.
for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] {
let font = FontId::new(14.0, family);
let glyphs = |text: &str| {
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK));
galley.rows.iter().flat_map(|r| r.glyphs.iter().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::<Vec<_>>()
};
let tofu = glyphs("\u{25FB}");
for (key, cands, sample) in &cases {
if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) {
continue; // no face for this script on this machine; nothing to assert
}
let drawn = glyphs(sample);
assert!(
!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)),
"{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})",
font.family
);
}
}
}
let ctx = egui::Context::default();
ctx.set_fonts(font_definitions());
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), "カテゴリ"));
assert!(covered, "Japanese text must render with a real glyph, not tofu");
}
}

View File

@ -46,8 +46,10 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(11.5);
let label = tr(ui, label);
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() {
@ -56,12 +58,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(tr(ui, tip))
}

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);
}

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));
}

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")));
}
}

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());
@ -102,15 +187,29 @@ fn renders_every_ribbon_tab_without_panicking() {
fn language_switch_translates_the_ribbon_and_persists() {
use gridcraft_ui_egui::i18n::Language;
let mut h = harness(blank());
// English by default on a machine whose system language is not Japanese.
assert_ne!(h.state().ui.language, Language::Ja);
h.state_mut().run("app.language.japanese", json!({})).unwrap();
h.run_steps(2); // the ribbon renders the Japanese tabs without panicking
assert_eq!(h.state().ui.language, Language::Ja);
// The preference round-trips through the saved UiState.
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::Ja);
h.state_mut().run("app.language.english", json!({})).unwrap();
// English by default whatever the host's locale: only the desktop app reads the system language.
assert_eq!(h.state().ui.language, Language::En);
// The language commands are listed with the other UI commands.
let ctx = h.ctx.clone();
let gridcraft_ui_egui::control::Outcome::Done(listed) = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "engine.commands", &json!({}))
else {
panic!("engine.commands returns a response");
};
let ids: Vec<&str> = listed["result"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).collect();
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
assert!(ids.contains(&id), "engine.commands lists {id}");
}
for (language, expected) in
[(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные"), (Language::PtBr, "Dados")]
{
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
h.run_steps(2); // every locale renders the ribbon without panicking
assert_eq!(h.state().ui.language, language);
assert_eq!(language.tr("Data"), expected);
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, language);
}
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
assert_eq!(h.state().ui.language, Language::En);
}
@ -162,3 +261,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:?}");
}
}

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());
}

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})");
}
}
}

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);
}

View File

@ -0,0 +1,164 @@
//! 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) {
// The View tab's first combo box is the display theme (the interface language follows it).
h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("display theme combo").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);
}

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);
}
}

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))");
}
}

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))
}

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() {

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}");
}

View File

@ -39,5 +39,17 @@ 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.
`{command: "app.language.set", params: {language: "ja"}}` switches the interface language
(`code` is accepted as an alias for `language`); `app.language.english` and
`app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or
document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the
system language.
For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`;
see [`mcp.md`](mcp.md) and [`cli.md`](cli.md).

View File

@ -101,6 +101,9 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then
stapled. The DMG (with an `Applications` link) is signed, notarized and stapled too, and
checked with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`.
Its Finder window (background, icon size and positions) comes from
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `GridCraft`
without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `gridcraft-cli` is signed, zipped and notarized.
Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization:

View File

@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/gridcraft.svg`, linked, not copied) cropped as a cover on the GridCraft colour field (`#1f9d55`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, GridCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `GridCraft`, not `GridCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`gridcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "GridCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"GridCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()

View File

@ -131,10 +131,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/GridCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "GridCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "GridCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"