Merge fixed #38 (font fallback) into feat/i18n-language
# Conflicts: # crates/ui-egui/src/grid.rs # crates/ui-egui/src/ribbon.rs # crates/ui-egui/src/widgets.rs
This commit is contained in:
commit
277f01265b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -1534,7 +1534,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -1551,7 +1551,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
|
|
@ -4297,7 +4297,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -81,11 +81,13 @@ by open equivalents or left out, not cloned.
|
|||
autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose,
|
||||
operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists,
|
||||
text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special,
|
||||
undo/redo with history, format painter.
|
||||
undo/redo with history, format painter. Quick selection drags retain their final endpoint;
|
||||
the fill handle explains how to fill versus select cells.
|
||||
- Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number
|
||||
formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates,
|
||||
duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic
|
||||
row heights, autofit, hide/unhide, freeze panes.
|
||||
Border presets include visual diagrams; Format Cells previews draft borders before applying.
|
||||
- Data: sort (multi-level, by colour, custom lists), AutoFilter (values, custom, top 10,
|
||||
average, colour), tables with totals rows, remove duplicates, text to columns, data validation
|
||||
with error alerts, grouping/outline, subtotals.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_preserves_doubled_quotes() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-t2c-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("make temp directory");
|
||||
let script = dir.join("steps.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
concat!(
|
||||
"{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"\\\"a\\\"\\\"b\\\",c\"}}\n",
|
||||
"{\"command\":\"data.textToColumns\",\"params\":{\"range\":\"A1\",\"delimiters\":[\",\"]}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write commands");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["run", "--script"])
|
||||
.arg(&script)
|
||||
.args(["--print", "A1:B1", "--csv", "--quiet"])
|
||||
.output()
|
||||
.expect("run Gridcraft CLI");
|
||||
std::fs::remove_file(script).expect("remove script");
|
||||
std::fs::remove_dir(dir).expect("remove temp directory");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "\"a\"\"b\",c");
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<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(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
|
|
@ -61,8 +64,19 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`GRIDCRAFT_NO_PREFS`, agents' test runs) neither read nor write
|
||||
/// them, and keep no log file.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("GRIDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
Some(config_dir()?.join("logs"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SheetApp) {
|
||||
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = config_dir() else { return };
|
||||
|
|
@ -79,7 +93,7 @@ fn load_prefs(app: &mut SheetApp) {
|
|||
}
|
||||
|
||||
fn save_prefs(app: &SheetApp) {
|
||||
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = config_dir() else { return };
|
||||
|
|
@ -133,6 +147,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 +191,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,6 +235,13 @@ fn main() -> eframe::Result<()> {
|
|||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
};
|
||||
eframe::run_native(
|
||||
"GridCraft",
|
||||
options,
|
||||
|
|
@ -195,7 +250,7 @@ fn main() -> eframe::Result<()> {
|
|||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => eprintln!("control channel on port {port}: {e}"),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(Box::new(App(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,7 @@
|
|||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.nihilist-narwhal]
|
||||
display_name = "nihilist-narwhal"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -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") =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,40 @@ impl T {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_range_operator() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "3");
|
||||
t.set("B1", "=SEQUENCE(A1)");
|
||||
t.set("C1", "=SUM(B1#)");
|
||||
t.set("C2", "=ROWS(B1#)");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0));
|
||||
// Growing and shrinking the spill recalculates its users.
|
||||
t.set("A1", "5");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0));
|
||||
t.set("A1", "2");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0));
|
||||
// A blocked spill, or a cell that doesn't spill, gives #REF!.
|
||||
t.set("B2", "x");
|
||||
assert_eq!(t.get("C1"), Value::Error(CellError::Ref));
|
||||
t.set("B2", "");
|
||||
assert_eq!(t.num("C1"), 3.0);
|
||||
t.set("D1", "=SUM(A1#)");
|
||||
assert_eq!(t.get("D1"), Value::Error(CellError::Ref));
|
||||
// Through a defined name.
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Seq".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$B$1#".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.set("E1", "=SUM(Seq)*10");
|
||||
assert_eq!(t.num("E1"), 30.0);
|
||||
t.set("A1", "4");
|
||||
assert_eq!(t.num("E1"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_dependencies() {
|
||||
let mut t = T::new();
|
||||
|
|
@ -215,3 +249,94 @@ fn recalc_all_after_load() {
|
|||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A3"), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_sum_range_takes_the_criteria_size() {
|
||||
// Excel adds up the range that starts at sum_range's top-left cell with the criteria range's
|
||||
// height and width, whatever size sum_range has.
|
||||
let mut t = T::new();
|
||||
for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", r + 1), k);
|
||||
t.set(&format!("B{}", r + 1), &v.to_string());
|
||||
}
|
||||
t.set("D1", "=SUMIF(A1:A3,\"a\",B1)");
|
||||
assert_eq!(t.num("D1"), 4.0);
|
||||
t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)");
|
||||
assert_eq!(t.num("D2"), 2.0);
|
||||
t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)");
|
||||
assert_eq!(t.num("D3"), 4.0);
|
||||
t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)");
|
||||
assert_eq!(t.num("D4"), 4.0);
|
||||
t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)");
|
||||
assert_eq!(t.num("D5"), 4.0);
|
||||
t.set("D6", "=SUMIF(A:A,\"a\",B1)");
|
||||
assert_eq!(t.num("D6"), 4.0);
|
||||
t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)");
|
||||
assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty)
|
||||
t.set("D8", "=SUMIF(A1:A3,\"a\")");
|
||||
assert_eq!(t.num("D8"), 0.0);
|
||||
// A horizontal criteria range resizes across.
|
||||
t.set("H1", "1");
|
||||
t.set("I1", "0");
|
||||
t.set("J1", "1");
|
||||
t.set("H2", "10");
|
||||
t.set("I2", "20");
|
||||
t.set("J2", "30");
|
||||
t.set("D9", "=SUMIF(H1:J1,1,H2)");
|
||||
assert_eq!(t.num("D9"), 40.0);
|
||||
// The resized cells are precedents: editing one recalculates.
|
||||
t.set("B3", "5");
|
||||
assert_eq!(t.num("D1"), 6.0);
|
||||
assert_eq!(t.num("D2"), 3.0);
|
||||
assert_eq!(t.num("D3"), 6.0);
|
||||
assert_eq!(t.num("D4"), 6.0);
|
||||
assert_eq!(t.num("D6"), 6.0);
|
||||
// SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!.
|
||||
t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")");
|
||||
assert_eq!(t.get("D11"), Value::Error(CellError::Value));
|
||||
t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")");
|
||||
assert_eq!(t.get("D12"), Value::Error(CellError::Value));
|
||||
t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")");
|
||||
assert_eq!(t.num("D13"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_resized_sum_range_stops_at_the_sheet_edge() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "a");
|
||||
t.set("A2", "a");
|
||||
t.set("B1048575", "5");
|
||||
t.set("B1048576", "7");
|
||||
t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)");
|
||||
assert_eq!(t.num("C1"), 7.0);
|
||||
t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)");
|
||||
assert_eq!(t.num("C2"), 12.0);
|
||||
t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)");
|
||||
assert_eq!(t.num("C3"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
|
||||
let mut t = T::new();
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Amounts".into(),
|
||||
scope: None,
|
||||
formula: "Data!$B$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
for (r, k) in ["a", "b", "a"].iter().enumerate() {
|
||||
t.set(&format!("A{}", r + 1), k);
|
||||
t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64));
|
||||
}
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)");
|
||||
assert_eq!(t.num("C1"), 4.0);
|
||||
t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)");
|
||||
assert_eq!(t.num("C2"), 2.0);
|
||||
t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0));
|
||||
t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]);
|
||||
assert_eq!(t.num("C1"), 10.0);
|
||||
assert_eq!(t.num("C2"), 5.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("e) {
|
||||
cur.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = !in_q;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !in_q && delims.contains(&c) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -160,6 +160,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
|||
("view.formulaBar", "Formula Bar"),
|
||||
("view.collapseRibbon", "Collapse Ribbon"),
|
||||
("view.darkMode", "Dark Mode"),
|
||||
("view.theme", "Display Theme"),
|
||||
("view.zoom100", "100%"),
|
||||
("ui.dialog", "Open Dialog"),
|
||||
] {
|
||||
|
|
@ -178,6 +179,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
|||
"ui.set" => {
|
||||
if let Some(v) = p.get("dark").and_then(Json::as_bool) {
|
||||
app.ui.dark = v;
|
||||
app.ui.system_theme = false;
|
||||
}
|
||||
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
|
||||
app.ui.formula_bar = v;
|
||||
|
|
@ -403,7 +405,7 @@ pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
if now < *deadline {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use `gridcraft-cli snapshot`"}));
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"}));
|
||||
false
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +36,9 @@ pub use control::{ControlRequest, ControlResponse};
|
|||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
/// The manual preference, retained when following the system appearance.
|
||||
pub dark: bool,
|
||||
pub system_theme: bool,
|
||||
pub ribbon_tab: String,
|
||||
pub ribbon_collapsed: bool,
|
||||
pub formula_bar: bool,
|
||||
|
|
@ -51,6 +54,7 @@ impl Default for UiState {
|
|||
fn default() -> Self {
|
||||
UiState {
|
||||
dark: false,
|
||||
system_theme: false,
|
||||
ribbon_tab: "Home".into(),
|
||||
ribbon_collapsed: false,
|
||||
formula_bar: true,
|
||||
|
|
@ -62,6 +66,28 @@ impl Default for UiState {
|
|||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn theme_preference(&self) -> egui::ThemePreference {
|
||||
if self.system_theme {
|
||||
egui::ThemePreference::System
|
||||
} else if self.dark {
|
||||
egui::ThemePreference::Dark
|
||||
} else {
|
||||
egui::ThemePreference::Light
|
||||
}
|
||||
}
|
||||
|
||||
pub fn theme_mode(&self) -> &'static str {
|
||||
if self.system_theme {
|
||||
"system"
|
||||
} else if self.dark {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-sheet view state (scroll position in sheet points).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct SheetView {
|
||||
|
|
@ -109,6 +135,7 @@ pub struct SheetApp {
|
|||
pub perf: Perf,
|
||||
pub fonts_ready: bool,
|
||||
fonts_set: bool,
|
||||
effective_dark: bool,
|
||||
pub name_box: Option<String>,
|
||||
pub(crate) shots: control::Shots,
|
||||
/// Chart selected on the sheet (id).
|
||||
|
|
@ -138,6 +165,7 @@ impl SheetApp {
|
|||
perf: Perf::default(),
|
||||
fonts_ready: false,
|
||||
fonts_set: false,
|
||||
effective_dark: false,
|
||||
name_box: None,
|
||||
shots: control::Shots::default(),
|
||||
selected_chart: None,
|
||||
|
|
@ -188,9 +216,22 @@ impl SheetApp {
|
|||
Ok(Json::Null)
|
||||
}
|
||||
"view.darkMode" => {
|
||||
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.dark);
|
||||
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
||||
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current);
|
||||
self.ui.system_theme = false;
|
||||
Ok(json!({"on": self.ui.dark}))
|
||||
}
|
||||
"view.theme" => {
|
||||
match p.get("mode").and_then(Json::as_str) {
|
||||
Some("system") => self.ui.system_theme = true,
|
||||
Some(mode @ ("light" | "dark")) => {
|
||||
self.ui.dark = mode == "dark";
|
||||
self.ui.system_theme = false;
|
||||
}
|
||||
_ => return Some(Err("theme mode must be system, light, or dark".into())),
|
||||
}
|
||||
Ok(json!({"mode": self.ui.theme_mode()}))
|
||||
}
|
||||
"view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())),
|
||||
"app.language.set" => {
|
||||
let code = p.get("language").and_then(Json::as_str).and_then(i18n::Language::parse);
|
||||
|
|
@ -299,7 +340,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),
|
||||
|
|
@ -385,9 +426,12 @@ impl SheetApp {
|
|||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
if ctx.global_style().visuals.dark_mode != self.ui.dark {
|
||||
theme::apply(ctx, self.ui.dark);
|
||||
let preference = self.ui.theme_preference();
|
||||
if ctx.options(|o| o.theme_preference) != preference {
|
||||
ctx.set_theme(preference);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.effective_dark = ctx.theme() == egui::Theme::Dark;
|
||||
control::poll(self, ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
let arrived: Vec<(String, Vec<u8>)> = self
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
|
|
@ -1572,11 +1600,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
|
||||
|
|
|
|||
|
|
@ -292,9 +292,9 @@ pub fn font_definitions() -> 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);
|
||||
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 +309,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 +320,182 @@ 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) -> 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() {
|
||||
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) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 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 (e.g. Japanese vs Chinese kanji).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fallback_faces() -> Vec<(&'static str, Vec<(String, u32)>)> {
|
||||
vec![("sys-cjk-han", cjk_han()), ("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",
|
||||
];
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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; index 0 picks the Japanese glyph shapes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cjk_han() -> 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!("{win}\\simsun.ttc"), 0)); // SimSun (Chinese, older installs)
|
||||
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans (Japanese)
|
||||
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}/NotoSansCJK-Regular.ttc"), 0));
|
||||
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
|
||||
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
|
||||
}
|
||||
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;
|
||||
|
|
@ -420,35 +517,65 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[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.
|
||||
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.
|
||||
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)
|
||||
let fb_keys: Vec<String> = fallback_faces().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() >= 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");
|
||||
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 fallback_faces_load_each_file_once() {
|
||||
// The region-merged Noto Sans CJK .ttc serves Han and Hangul; it must be loaded once, not
|
||||
// once per script (tens of MB each).
|
||||
let fonts = font_definitions();
|
||||
let present: Vec<&Arc<FontData>> = fallback_faces().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); 4] = [
|
||||
("sys-cjk-han", cjk_han(), "カテゴリ"),
|
||||
("sys-cjk-hangul", cjk_hangul(), "한글"),
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
];
|
||||
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");
|
||||
// `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.
|
||||
let font = FontId::proportional(14.0);
|
||||
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().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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
@ -162,3 +247,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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
//! Saved appearance preferences and live OS changes through the whole UI.
|
||||
|
||||
use egui::{Theme, ThemePreference};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{SheetApp, UiState, control, theme::Tokens};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn harness(ui: UiState) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.ui = ui;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn preferences(h: &egui_kittest::Harness<'static, SheetApp>) -> Value {
|
||||
serde_json::to_value(&h.state().ui).unwrap()
|
||||
}
|
||||
|
||||
fn system_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, theme: Option<Theme>) {
|
||||
h.input_mut().system_theme = theme;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn set_mode(h: &mut egui_kittest::Harness<'static, SheetApp>, mode: &str) {
|
||||
let ctx = h.ctx.clone();
|
||||
let control::Outcome::Done(response) =
|
||||
control::handle(h.state_mut(), &ctx, "engine.execute", &json!({"command": "view.theme", "params": {"mode": mode}}))
|
||||
else {
|
||||
panic!("engine.execute should return a response");
|
||||
};
|
||||
assert_eq!(response["ok"], true, "{response}");
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn select_ribbon_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
|
||||
h.get_by_role(egui::accesskit::Role::ComboBox).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn assert_appearance(h: &egui_kittest::Harness<'static, SheetApp>, dark: bool) {
|
||||
let visuals = &h.ctx.global_style().visuals;
|
||||
let tokens = if dark { Tokens::dark() } else { Tokens::light() };
|
||||
assert_eq!(visuals.dark_mode, dark);
|
||||
assert_eq!(visuals.panel_fill, tokens.window);
|
||||
assert_eq!(visuals.window_fill, tokens.menu_bg);
|
||||
assert_eq!(visuals.selection.bg_fill, tokens.accent_soft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_preferences_stay_manual_and_system_preference_survives_reload() {
|
||||
for dark in [false, true] {
|
||||
let ui: UiState = serde_json::from_value(json!({"dark": dark})).unwrap();
|
||||
assert_eq!(ui.dark, dark);
|
||||
let mut h = harness(ui);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false, "old saved settings remain manual");
|
||||
system_theme(&mut h, Some(if dark { Theme::Light } else { Theme::Dark }));
|
||||
assert_appearance(&h, dark);
|
||||
}
|
||||
|
||||
let mut h = harness(UiState::default());
|
||||
set_mode(&mut h, "system");
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
let saved = serde_json::to_string(&h.state().ui).unwrap();
|
||||
let mut restored = harness(serde_json::from_str(&saved).unwrap());
|
||||
assert_eq!(preferences(&restored)["systemTheme"], true);
|
||||
system_theme(&mut restored, Some(Theme::Light));
|
||||
assert_appearance(&restored, false);
|
||||
system_theme(&mut restored, Some(Theme::Dark));
|
||||
assert_appearance(&restored, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback() {
|
||||
let mut h = harness(UiState::default());
|
||||
set_mode(&mut h, "system");
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System);
|
||||
// egui switches between these two style slots when the OS appearance changes.
|
||||
for (theme, tokens) in [(Theme::Light, Tokens::light()), (Theme::Dark, Tokens::dark())] {
|
||||
let style = h.ctx.style_of(theme);
|
||||
assert_eq!(style.visuals.panel_fill, tokens.window);
|
||||
assert_eq!(style.visuals.window_fill, tokens.menu_bg);
|
||||
assert_eq!(style.visuals.selection.bg_fill, tokens.accent_soft);
|
||||
}
|
||||
for (reported, dark) in [(Some(Theme::Dark), true), (Some(Theme::Light), false), (Some(Theme::Dark), true), (None, false)] {
|
||||
system_theme(&mut h, reported);
|
||||
assert_appearance(&h, dark);
|
||||
assert_eq!(preferences(&h)["systemTheme"], true);
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() {
|
||||
let mut h = harness(UiState::default());
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
assert_appearance(&h, false);
|
||||
h.state_mut().run("ui.ribbonTab", json!({"tab": "View"})).unwrap();
|
||||
h.run_steps(2);
|
||||
select_ribbon_theme(&mut h, "System");
|
||||
assert_eq!(preferences(&h)["systemTheme"], true);
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System);
|
||||
assert_appearance(&h, true);
|
||||
for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] {
|
||||
select_ribbon_theme(&mut h, label);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false);
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), preference);
|
||||
for os in [Some(Theme::Dark), Some(Theme::Light), None] {
|
||||
system_theme(&mut h, os);
|
||||
assert_appearance(&h, dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() {
|
||||
let mut h = harness(UiState::default());
|
||||
set_mode(&mut h, "system");
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
let before = preferences(&h);
|
||||
assert!(h.state_mut().run("view.theme", json!({"mode": "sepia"})).is_err());
|
||||
h.run_steps(2);
|
||||
assert_eq!(preferences(&h), before);
|
||||
assert_appearance(&h, true);
|
||||
|
||||
// The old toggle flips the displayed appearance, even when System chose it.
|
||||
h.state_mut().run("view.darkMode", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false);
|
||||
assert_appearance(&h, false);
|
||||
|
||||
set_mode(&mut h, "system");
|
||||
system_theme(&mut h, Some(Theme::Light));
|
||||
h.state_mut().run("view.darkMode", json!({"on": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false);
|
||||
assert_appearance(&h, true);
|
||||
|
||||
set_mode(&mut h, "system");
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
let ctx = h.ctx.clone();
|
||||
let control::Outcome::Done(response) = control::handle(h.state_mut(), &ctx, "ui.set", &json!({"dark": false})) else {
|
||||
panic!("ui.set should return a response");
|
||||
};
|
||||
assert_eq!(response["ok"], true);
|
||||
h.run_steps(2);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false);
|
||||
assert_appearance(&h, false);
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
assert_appearance(&h, false);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,11 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`.
|
|||
|
||||
Any other method name that is a command id (e.g. `home.bold`) runs that command.
|
||||
|
||||
Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}`
|
||||
to choose the display theme; `mode` accepts `"system"`, `"light"`, or `"dark"`.
|
||||
System follows live OS appearance changes and falls back to Light when the platform supplies no preference.
|
||||
The saved choice remains System; new users still start in Light. The legacy
|
||||
`view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme.
|
||||
|
||||
For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`;
|
||||
see [`mcp.md`](mcp.md) and [`cli.md`](cli.md).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue