Merge remote-tracking branch 'origin/main' into fix-268

This commit is contained in:
Brandon Thomas 2026-10-11 12:41:54 -04:00
commit de8d70cc39
136 changed files with 14750 additions and 1169 deletions

View File

@ -19,78 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/gridcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/gridcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.gridcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.gridcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
desktop-shortcut:
name: Windows desktop shortcut smoke
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build fixture MSIs and test shortcut lifecycle
shell: pwsh
run: |
dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' }
$env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH"
./packaging/windows/test-desktop-shortcut.ps1
- name: Capture native MSI dialog previews
shell: pwsh
run: |
pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 `
-Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" `
-OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs"
if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' }
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: desktop-shortcut-msi-evidence
path: |
${{ runner.temp }}/gridcraft-shortcut-smoke/*.log
${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/*
if-no-files-found: ignore
retention-days: 7
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main

View File

@ -1,13 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "GridCraft v<version>". See docs/releasing.md.
# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step.
#
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
# (restricted to the `release` branch), which holds the signing secrets. Every secret is
# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run:
# they build and upload their artifacts, the environment refuses the signing jobs, and the
# draft-release job (which needs them) is skipped.
name: Release
on:
@ -21,431 +11,27 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job needs no secrets (see the header about dry runs).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### GridCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
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@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/gridcraft$|metainfo|applications'
ldd target/release/gridcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
# FreeBSD has no hosted runners: freebsd.yml builds in a FreeBSD VM and, with package: true,
# uploads the release-freebsd artifact (gridcraft-<v>-freebsd-x86_64.tar.gz). It needs no
# secrets, so the called workflow runs without the release environment (and in dry runs).
freebsd:
name: FreeBSD
needs: version
uses: ./.github/workflows/freebsd.yml
with:
version: ${{ needs.version.outputs.version }}
version_override: ${{ inputs.version != '' }}
package: true
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd]
# Belt and braces for branch dry runs: only the release branch drafts a release (the
# environment's branch rule refuses this job and the signing jobs anywhere else, too).
if: github.ref == 'refs/heads/release'
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="GridCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: gridcraft
app-title: GridCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}

View File

@ -17,71 +17,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
if: github.ref == 'refs/heads/release'
runs-on: windows-latest
timeout-minutes: 90
steps:
- 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.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gridcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: gridcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'GridCraft'
foreach ($exe in 'gridcraft.exe', 'gridcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'gridcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "gridcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok gridcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'gridcraft.exe')) { throw 'uninstall left gridcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft

81
Cargo.lock generated
View File

@ -447,6 +447,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@ -697,6 +706,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -746,6 +764,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@ -776,6 +803,16 @@ dependencies = [
"rayon",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@ -1427,6 +1464,7 @@ dependencies = [
"image",
"log",
"muda",
"pollster 1.0.1",
"rfd",
"serde_json",
"winresource",
@ -1529,6 +1567,7 @@ dependencies = [
"gridcraft-formula",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
]
@ -1555,6 +1594,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"skrifa",
"sys-locale",
"toml",
"web-sys",
@ -1584,7 +1624,7 @@ dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-model",
"quick-xml 0.38.4",
"quick-xml",
"thiserror 2.0.21",
"zip",
]
@ -1663,6 +1703,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "image"
version = "0.25.10"
@ -2480,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2739,15 +2788,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -3071,6 +3111,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -3452,6 +3503,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
@ -3725,7 +3782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]

View File

@ -32,8 +32,9 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.38"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
sha2 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
@ -45,6 +46,9 @@ rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
sys-locale = "0.3"
# Font parser egui's epaint uses; the UI validates system font files with it before handing them
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file
compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 501 of Excel's 523 worksheet functions
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we
estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large
workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage,
@ -230,8 +230,16 @@ $env:WGPU_BACKEND = "vulkan"
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.
An explicit `gl` override can bring the driver crash back on affected systems, and `vulkan` can
bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The macOS, Linux and
web backend defaults are unchanged.
**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no
graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every
frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if
the first renderer fails to start, it tries the other one once. The log file records the choice
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
### macOS

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Excel for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@ -21,7 +21,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@ -62,7 +62,7 @@ collaboration, mobile, AI).
| File formats | ~50% | 80–120 | [file-format-parity.md](docs/file-format-parity.md) |
| Performance | ~35% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#performance) |
| Stability | ~60% | 15–25 | [gaps.md](docs/gaps.md) |
| Localization | ~10% (ribbon in 5 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
| Localization | ~10% (ribbon in 6 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
| Hardware (multi-core calc, GPU, pen, multi-window) | ~40% | 25–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Platforms | ~80% (no iOS/Android; ahead on Linux, BSD, RISC-V) | 30–60 | [target-app-parity.md](docs/target-app-parity.md#platforms) |
| Ecosystem / add-ins | ~10% (VBA out of scope; own scripts, MCP, CLI) | 25–40 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
@ -74,7 +74,7 @@ collaboration, mobile, AI).
| Area | Breadth | Ready | Hours | Doc |
|---|---|---|---|---|
| Formula language and calculation | ~92% | ~72% | 20–30 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Worksheet functions | 95.8% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) |
| Worksheet functions | 96.6% measured | ~82% | 16–26 | [function-parity.md](docs/function-parity.md) |
| Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) |
| Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
@ -89,11 +89,12 @@ collaboration, mobile, AI).
## Languages
The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese and
Russian (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82), the
The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese, Russian
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
added 10-11), the
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. German
is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Status | UI strings |
|---|---|---|---|
@ -106,11 +107,11 @@ is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
| Portuguese | pt-BR | menus only | ~12% |
| Indonesian | id | none | 0% |
| Japanese | ja | menus only | ~12% |
| German | de | none (PR #163 open) | 0% |
| German | de | menus only | ~12% |
| Korean | ko | menus only | ~12% |
| Vietnamese | vi | none | 0% |
Other shipped languages: 1 (Russian, menus only, ~12%).
Other shipped languages: 2 (Russian and German, menus only, ~12%).
## Upcoming
@ -131,6 +132,24 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |
| 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) |
| 2026-10-11 | BAHTTEXT: amounts in Thai words with Baht and Satang (#291) |
| 2026-10-11 | TRIMRANGE: ranges and arrays without blank outer rows and columns (#296) |
| 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) |
| 2026-10-11 | Ribbon translated into German (266 labels; `de`, `de-DE`, `de-AT`, `de-CH` locale tags resolve to German) |
| 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) |
| 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) |
| 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) |
| 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) |
| 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) |
| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) |
| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) |
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
@ -142,6 +161,16 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
| 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row |
| 2026-10-11 | minor | Worksheet functions 502 / 523 (TRIMRANGE); progress log row |
| 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) |
| 2026-10-11 | minor | Catalog 258 → 260 / 290: View › Formula Bar and Zoom 100% (#289) |
| 2026-10-11 | minor | Progress log: XLSX threaded comments round trip (#300) |
| 2026-10-11 | minor | Catalog 258 → 259 / 290: Allow Edit Ranges (#307) |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row |
| 2026-10-11 | minor | Czech (cs) listed under other shipped languages: ribbon only, ~12% |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |

View File

@ -30,6 +30,10 @@ arboard = { workspace = true }
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
pollster = "1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a

View File

@ -1,47 +1,192 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//!
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
//! violation happens in the driver, where no Rust error handling can catch it.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType},
};
/// 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);
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
/// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the
/// processor, which makes the app lag on older machines.
pub device_type: DeviceType,
/// Whether the adapter offers the limits eframe will ask for when it creates the device.
pub meets_limits: bool,
}
/// The backends to initialize on Windows.
///
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
/// driver has no DirectX 12 support still have an OpenGL driver, and GL can't reach the AMD driver
/// crash described in [`configure_backends`], because an AMD GPU with a driver shows up as a
/// DirectX 12 adapter and keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays
/// enabled next to GL as a last resort for machines without an OpenGL driver either (virtual
/// machines): wgpu prefers any GPU over a software adapter, so it's only picked when GL has
/// nothing better. Vulkan is never part of the default.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
if let Some(backends) = backend_override {
return backends;
}
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
Backends::DX12
} else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) {
Backends::DX12 | Backends::GL
} else {
// No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a
// DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one.
Backends::GL
}
}
/// The backends for a second start when the first one failed with a graphics error before the
/// app was created (no adapter, no device, no surface): the other one of DirectX 12 and OpenGL.
/// `None` when `WGPU_BACKEND` chose the backends (the user asked for exactly those) or the first
/// start already tried both. Never Vulkan (#120).
pub fn fallback_backends(backend_override: Option<Backends>, first: Backends) -> Option<Backends> {
if backend_override.is_some() {
return None;
}
let rest = (Backends::DX12 | Backends::GL).difference(first);
(!rest.is_empty()).then_some(rest)
}
/// Restricts the wgpu instance eframe creates to `backends`.
pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's
// atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before
// adapter selection or any Rust error handling runs, so the window never appears.
// Choosing an adapter afterwards is too late: only leaving the backend out of the
// instance avoids it. This matches the verified WGPU_BACKEND=dx12 workaround without
// mutating process environment variables.
create.instance_descriptor.backends = backends;
}
}
/// Chooses the backends ([`choose_backends`]), applies them to `options` and returns them.
/// Without a `backend_override` this first probes DirectX 12 with a short-lived instance of its
/// own, which is dropped before eframe creates the real one.
#[cfg(target_os = "windows")]
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) -> Backends {
let backends = match backend_override {
Some(backends) => {
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
backends
}
None => {
let adapters = probe_dx12(options);
let backends = choose_backends(None, &adapters);
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
backends
}
};
configure_backends(options, backends);
backends
}
/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor
/// will require. Empty when this build has no DirectX 12 backend.
#[cfg(target_os = "windows")]
fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
use eframe::wgpu::{Instance, InstanceDescriptor};
// `Instance::new` panics when the build has no backend for the platform.
if !Instance::enabled_backend_features().contains(Backends::DX12) {
return Vec::new();
}
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
return Vec::new();
};
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
.iter()
.map(|adapter| {
let info = adapter.get_info();
let required = (create.device_descriptor)(adapter).required_limits;
let meets_limits = required.check_limits(&adapter.limits());
log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver);
Dx12Adapter { device_type: info.device_type, meets_limits }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure_backends(&mut options, backends);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter {
Dx12Adapter { device_type, meets_limits }
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
fn windows_default_never_initializes_vulkan() {
// eframe's own default (PRIMARY | GL) would initialize Vulkan and 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);
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
// #120: whatever the DirectX 12 probe finds, and whatever the retry picks, no Vulkan.
let probes: [&[Dx12Adapter]; 4] = [&[], &gpu, &[adapter(DeviceType::Cpu, true)], &[adapter(DeviceType::IntegratedGpu, false)]];
for adapters in probes {
let first = choose_backends(None, adapters);
assert!(!first.contains(Backends::VULKAN), "{adapters:?}");
if let Some(second) = fallback_backends(None, first) {
assert!(!second.contains(Backends::VULKAN), "{adapters:?}");
}
}
}
#[test]
fn explicit_backend_override_is_preserved() {
let warp_only = [adapter(DeviceType::Cpu, true)];
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
// Whatever the probe would have found, the override wins (the probe doesn't run then),
// and a failed start isn't retried with something the user didn't ask for.
assert_eq!(choose_backends(Some(backends), &[]), backends);
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
assert_eq!(configured_backends(backends), backends);
assert_eq!(fallback_backends(Some(backends), backends), None);
}
}
#[test]
fn gl_when_dx12_has_no_usable_gpu() {
// No DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
let warp_only = [adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[]), Backends::GL);
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
}
#[test]
fn failed_start_retries_with_the_other_backend() {
assert_eq!(fallback_backends(None, Backends::DX12), Some(Backends::GL));
assert_eq!(fallback_backends(None, Backends::GL), Some(Backends::DX12));
// Both were already tried: nothing left to fall back to.
assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None);
}
}

View File

@ -17,6 +17,9 @@ mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
use std::cell::RefCell;
use std::rc::Rc;
use gridcraft_engine::Session;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
@ -51,6 +54,16 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
// `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends
// in a crash report. eframe calls `on_exit` on every way of quitting (Quit/⌘Q, the Dock,
// the close button, `app.quit`), and nothing after it is needed: the preferences are
// saved and the log file is unbuffered. eframe does the same when it doesn't
// run-and-return. `on_exit` only runs once the app exists, never on a failed start.
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@ -84,6 +97,8 @@ fn load_prefs(app: &mut SheetApp) {
}
// First run (or a ui.json without a usable language): follow the desktop's language.
app.ui.language = gridcraft_ui_egui::i18n::Language::system();
// ...and its light or dark appearance (#239), until a theme is chosen and saved in ui.json.
app.ui.system_theme = true;
let Some(dir) = config_dir() else { return };
if let Ok(b) = std::fs::read(dir.join("ui.json"))
&& let Ok(ui) = serde_json::from_slice::<gridcraft_ui_egui::UiState>(&b)
@ -116,14 +131,23 @@ fn save_prefs(app: &SheetApp) {
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let mut clipboard: Option<arboard::Clipboard> = None;
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let paste_clipboard = clipboard.clone();
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
if clipboard.is_none() {
clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
*clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
})),
paste_html: Some(Box::new(move || {
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
clipboard.as_mut()?.get().html().ok()
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
@ -165,6 +189,9 @@ fn services() -> Services {
}
fn icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() })
@ -260,37 +287,65 @@ 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).
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
#[cfg(target_os = "windows")]
let options = {
let backend_override = eframe::wgpu::Backends::from_env();
#[cfg(target_os = "windows")]
let (options, first_backends) = {
let mut options = options;
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
options
let backends = graphics::configure(&mut options, backend_override);
(options, backends)
};
#[cfg(target_os = "windows")]
let retry_options = options.clone();
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"GridCraft",
options,
Box::new(move |cc| {
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
match control_server::start(port, cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
// The app waits here until a start gets as far as creating it; a start that fails before
// (no graphics adapter, device or surface) leaves it here for the next attempt.
let pending = std::cell::Cell::new(Some(app));
let run = |options: eframe::NativeOptions| {
eframe::run_native(
"GridCraft",
options,
Box::new(|cc| {
let Some(mut app) = pending.take() else {
return Err("the app was already created".into());
};
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
match control_server::start(port, cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
}
}
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
None,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
};
let result = run(options);
// A graphics error before the app existed: start once more with the other backend (eframe
// reuses its event loop for the second run). Errors after that, or from winit, are returned.
#[cfg(target_os = "windows")]
if let Err(eframe::Error::Wgpu(e)) = &result
&& let Some(app) = pending.take()
&& let Some(backends) = graphics::fallback_backends(backend_override, first_backends)
{
log::error!("graphics: the renderer didn't start on {first_backends:?} ({e}); trying {backends:?}");
pending.set(Some(app));
let mut options = retry_options;
graphics::configure_backends(&mut options, backends);
return run(options);
}
result
}

View File

@ -28,3 +28,20 @@ sync_github_name = false
[people.edurbs]
sync_github_name = true
[people.vangel76]
real_name = "Valentin Klein"
sync_github_name = true
[people.MacJedi42]
sync_github_name = true
[people.alexanderalber]
real_name = "Alexander Alber"
[people.webdevtodayjason]
display_name = "Jason Brashear"
sync_github_name = false
[people.Losminek]
sync_github_name = true

View File

@ -71,6 +71,11 @@ pub trait Host {
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
self.workbook().sheet(sheet).and_then(|s| s.used_range())
}
/// Calls `f` on each stored value of a block, row-major (formula cells evaluated first),
/// until `f` returns false. `false` when this host can't (callers use `range_values`).
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
false
}
}
pub struct Evaluator<'h> {
@ -554,7 +559,20 @@ impl<'h> Evaluator<'h> {
if args.len() < 2 {
return err(CellError::Value);
}
let i = match self.arg_num(args, 0, 0.0) {
let index = self.value(&args[0]);
if let Value::Array(_) = index {
// An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)`
// is a two-column array. The library CHOOSE lifts over its arguments.
let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) };
let mut evaluated = vec![Arg { value: index, from_ref: false }];
for a in &args[1..] {
let value = self.value(a);
evaluated.push(Arg { value, from_ref: false });
}
let mut ctx = FnCtx { ev: self };
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
}
let i = match index.to_number() {
Ok(n) => n.trunc() as i64,
Err(e) => return err(e),
};
@ -664,6 +682,7 @@ impl<'h> Evaluator<'h> {
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
"CELL" => self.cell_info(args),
"TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
"TEXT" => {
if args.len() != 2 {
@ -726,6 +745,9 @@ impl<'h> Evaluator<'h> {
if args.len() < spec.min_args || args.len() > spec.max_args {
return err(CellError::Value);
}
if let Some(kind) = Agg::of(spec.name) {
return self.aggregate(kind, args);
}
let mut evaluated = Vec::with_capacity(args.len());
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
let mut criteria_shape = None;
@ -762,6 +784,70 @@ impl<'h> Evaluator<'h> {
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
}
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
/// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one
/// after another, as the array path does, so volatile formulas (like `RAND`) see the same order.
/// Only `SUM`, `COUNT` and `AVG` are supported currently.
fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev {
let mut acc = Acc::default();
let mut first_error = None;
for a in args {
let ev = self.eval(a);
let steps = match ev {
Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(),
ev => {
let value = self.deref(ev);
vec![self.aggregate_value(kind, value, false, &mut acc)]
}
};
if first_error.is_none() {
first_error = steps.into_iter().find_map(Result::err);
}
}
first_error.unwrap_or_else(|| Ev::V(acc.finish(kind)))
}
/// Adds one area to `acc`; `Err` is the result the function ends with.
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
if !a.range.is_single() {
let mut error = None;
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
Ok(()) => true,
Err(e) => {
error = Some(e);
false
}
});
if walked {
return error.map_or(Ok(()), |e| Err(err(e)));
}
}
let value = self.area_value(a);
self.aggregate_value(kind, value, true, acc)
}
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
return Err(err(CellError::Name));
};
let args = [Arg { value, from_ref }];
if kind != Agg::Count {
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) {
Value::Number(n) => acc.sum += n,
other => return Err(Ev::V(other)),
}
}
if kind != Agg::Sum {
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) {
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
other => return Err(Ev::V(other)),
}
}
Ok(())
}
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
if args.len() > l.params.len() {
return err(CellError::Value);
@ -1239,6 +1325,57 @@ pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option<String> {
Some(format!("{sheet}{out}"))
}
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Agg {
Sum,
Count,
Average,
}
impl Agg {
fn of(name: &str) -> Option<Agg> {
match name {
"SUM" => Some(Agg::Sum),
"COUNT" => Some(Agg::Count),
"AVERAGE" => Some(Agg::Average),
_ => None,
}
}
}
/// What a folded aggregate carries from one value to the next.
#[derive(Default)]
struct Acc {
sum: f64,
count: u64,
}
impl Acc {
/// Takes a value found inside a reference: only numbers count. `Err` is an error value that
/// ends `SUM` and `AVERAGE`; `COUNT` skips errors.
fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> {
match v {
Value::Number(n) => {
self.sum += n;
self.count = self.count.saturating_add(1);
Ok(())
}
Value::Error(e) if kind != Agg::Count => Err(*e),
_ => Ok(()),
}
}
fn finish(&self, kind: Agg) -> Value {
match kind {
Agg::Sum => Value::number(self.sum),
Agg::Count => Value::number(self.count as f64),
Agg::Average if self.count == 0 => Value::Error(CellError::Div0),
Agg::Average => Value::number(self.sum / self.count as f64),
}
}
}
// ---------------------------------------------------------------- operators
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value {
@ -1455,6 +1592,7 @@ fn is_special(n: &str) -> bool {
| "INDIRECT"
| "INDEX"
| "CELL"
| "TRIMRANGE"
| "SUBTOTAL"
| "AGGREGATE"
| "TEXT"
@ -1492,6 +1630,7 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
"INDIRECT",
"INDEX",
"CELL",
"TRIMRANGE",
"SUBTOTAL",
"AGGREGATE",
"TEXT",

View File

@ -5,6 +5,7 @@
pub mod eval;
pub mod recalc;
mod trimrange;
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
pub use recalc::{Calc, Key, evaluate, now_serial};

View File

@ -598,6 +598,30 @@ impl Host for PassHost<'_> {
}
out
}
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
let wb = self.wb;
let Some(sh) = wb.sheet(sheet) else { return false };
// Formula cells first, in row-major order like `range_values`: reading one calculates it
// and the pass remembers the value, so the walk below gets the same value again without
// this method holding any.
for (c, cell) in sh.cells.iter_range(range) {
if cell.formula.is_some() {
let _ = self.cell_value(sheet, c);
}
}
// Spilled values sit between the stored cells (and the formulas just calculated may have
// produced some): leave such blocks to `range_values`.
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
return false;
}
for (c, cell) in sh.cells.iter_range(range) {
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
if !go {
break;
}
}
true
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
@ -974,13 +998,13 @@ fn prof_now() -> f64 {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use std::time::{Duration, Instant};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, Intervals, Key};
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
@ -1181,4 +1205,41 @@ mod tests {
calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps);
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
}
/// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the
/// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been
/// calculated yet, or `None` when it declines the block.
pub(crate) fn probe_visit_range(wb: &Workbook, pending: &[(&str, &str)], range: RangeRef) -> Option<Vec<Value>> {
let mut exprs = HashMap::default();
for (at, formula) in pending {
let expr = gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).ok()?;
exprs.insert((0, CellRef::parse(at)?), std::sync::Arc::new(expr));
}
// A cell in `pending` is calculated the first time something reads it, which is the state
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
// being calculated under it.
let mut rng = 1u64;
let mut host = PassHost {
wb,
pending: exprs.keys().copied().collect(),
exprs: &exprs,
results: HashMap::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: 0.0,
cycle: false,
cycles: vec![],
depth: 0,
};
let mut seen = Vec::new();
host.visit_range(0, range, &mut |v| {
seen.push(v.clone());
true
})
.then_some(seen)
}
}

View File

@ -219,6 +219,25 @@ fn ifs_and_lazy() {
assert_eq!(t.get("B4"), Value::from("five"));
}
#[test]
fn choose_with_an_array_index() {
let mut t = T::new();
for (i, (k, v)) in [(1, 10), (2, 20), (3, 30)].iter().enumerate() {
t.set(&format!("A{}", i + 1), &k.to_string());
t.set(&format!("B{}", i + 1), &v.to_string());
}
t.set("D1", "=CHOOSE({1,2},\"a\",\"b\")");
assert_eq!(t.get("D1"), Value::from("a"));
assert_eq!(t.get("E1"), Value::from("b"));
// The usual "look left" idiom: a two-column array from two ranges.
t.set("D3", "=VLOOKUP(20,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE)");
assert_eq!(t.num("D3"), 2.0);
t.set("D4", "=SUM(CHOOSE({1,2},A1:A3,B1:B3))");
assert_eq!(t.num("D4"), 66.0);
t.set("D5", "=CHOOSE(2,A1:A3,B1:B3)");
assert_eq!(t.num("D7"), 30.0);
}
#[test]
fn references_functions() {
let mut t = T::new();
@ -467,3 +486,223 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
assert_eq!(t.num("C1"), 10.0);
assert_eq!(t.num("C2"), 5.0);
}
#[test]
fn trimrange() {
let mut t = T::new();
t.set("C3", "1");
t.set("D5", "2");
let cases = [
("J1", "=ROWS(TRIMRANGE(A1:F10))", 3.0),
("J2", "=COLUMNS(TRIMRANGE(A1:F10))", 2.0),
("J3", "=ROWS(TRIMRANGE(A1:F10,2))", 5.0),
("J4", "=ROWS(TRIMRANGE(A1:F10,1,0))", 8.0),
("J5", "=COLUMNS(TRIMRANGE(A1:F10,1,0))", 6.0),
("J6", "=ROWS(TRIMRANGE(A1:F10,0,0))", 10.0),
("J7", "=SUM(TRIMRANGE(C:C))", 1.0),
("J8", "=ROWS(TRIMRANGE(C:C))", 1.0),
("J9", "=ROWS(TRIMRANGE(C:C,2))", 3.0),
("J10", "=MIN(ROW(TRIMRANGE(A1:F10)))", 3.0),
("J11", "=COLUMNS(TRIMRANGE({1,2;3,4}))", 2.0),
];
for (at, f, _) in cases {
t.set(at, f);
}
for (at, f, want) in cases {
assert_eq!(t.num(at), want, "{f}");
}
// The result spills as the trimmed block and follows edits.
t.set("L1", "=TRIMRANGE(A1:F10)");
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
t.set("E6", "3");
assert_eq!((t.num("J1"), t.num("J2")), (4.0, 3.0));
// Errors: a bad trim code, nothing but blanks, several areas.
t.set("K1", "=TRIMRANGE(A1:F10,4)");
assert_eq!(t.get("K1"), Value::Error(CellError::Value));
t.set("K2", "=TRIMRANGE(Q1:Q9)");
assert_eq!(t.get("K2"), Value::Error(CellError::Calc));
t.set("K3", "=TRIMRANGE((A1:B2,C3:D4))");
assert_eq!(t.get("K3"), Value::Error(CellError::Value));
}
/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it:
///
/// ```text
/// A B C E
/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges)
/// 2 2 TRUE #DIV/0! (E2 only when `error` is set)
/// 3 text 3
/// 4 -0.25
/// ```
fn aggregate_of(error: bool, formula: &str) -> Value {
let mut t = T::new();
for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] {
t.set(at, v);
}
if error {
t.set("E2", "#DIV/0!");
}
// AB1 is outside every range used, so the formula can't read itself.
t.set("AB1", formula);
t.get("AB1")
}
#[test]
fn sum_count_average_over_ranges() {
// Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole
// columns; blocks holding an error or nothing numeric. The expected values are what the
// array path (the ranges copied into an array) gave before the ranges were folded in place,
// so any difference is a change of behavior.
let n = Value::Number;
let e = Value::Error;
// (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula)
#[rustfmt::skip]
let cases: &[(&str, bool, [Value; 3])] = &[
("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]),
("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]),
("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]),
("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]),
("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]),
("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]),
("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]),
("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]),
("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]),
("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]),
("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]),
("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]),
("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]),
("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]),
("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]),
("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]),
("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
];
for (formula, error, want) in cases {
for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) {
// The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`.
let f = formula.replace("SUM", func);
assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})");
}
}
}
#[test]
fn sum_count_average_have_no_size_limit() {
// One stored cell far down stretches the used range to 27 million cells (27 columns by a
// million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are
// stored, so the sheet itself is tiny.
for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] {
for range in ["A1:AA1000000", "A:AA"] {
let mut t = T::new();
t.set("A1", "1.5");
t.set("Z1000000", "1");
t.set("AB1", &format!("={func}({range})"));
assert_eq!(t.get("AB1"), want, "{func}({range})");
}
}
}
#[test]
fn sum_count_average_over_spilled_values() {
// A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no
// stored cell of their own). The host must not fold such a block in place: it falls back
// to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10.
let mut t = T::new();
t.set("A1", "=SEQUENCE(3)");
t.set("A4", "10");
for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] {
t.set("C1", &format!("={f}(A1:A5)"));
assert_eq!(t.num("C1"), want, "{f}");
}
}
#[test]
fn aggregate_arguments_are_read_in_order() {
// Volatile cells draw a random number each time they are calculated, so the order in which
// the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must
// draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave
// 7.9043 here.) The expected values are what the array path gave; they depend on the random
// generator and its seed, and must be updated if either changes.
let mut t = T::new();
t.set("B1", "1");
t.set("B2", "=RAND()*10");
t.set("A1", "=SUM(B1:B2,RAND())");
assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356));
}
#[test]
fn sum_count_average_follow_edits_inside_the_range() {
// A range read in place must still count as a precedent of the formula: if it didn't, an
// edit inside it would leave the formula showing a stale value. Each step below changes one
// cell inside the range and checks all three functions; the last one changes a cell outside.
for range in ["A1:A5", "A:A"] {
let mut t = T::new();
t.set("A1", "1");
t.set("A3", "3");
t.set("C1", &format!("=SUM({range})"));
t.set("C2", &format!("=COUNT({range})"));
t.set("C3", &format!("=AVERAGE({range})"));
let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3"));
assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}");
t.set("A1", "5");
assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit");
t.set("A2", "10");
assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank");
t.set("A3", "text");
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text");
t.set("A2", "");
assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear");
t.set("A5", "=A1*2");
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range");
t.set("A1", "1");
assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes");
// A7 is inside the whole column A:A but outside A1:A5.
t.set("A7", "100");
let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) };
assert_eq!(all(&t), outside, "{range}: a cell outside the range");
}
}
#[test]
fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() {
// The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the
// block D1:D5 is read.
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)")));
sh.cells.set(c("D4"), Cell::value(Value::Number(10.0)));
let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap();
// Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells
// would never see 2 and 3, so the host must decline and leave the block to `range_values`.
// (`Calc` recalculates a pass that produced new spills, so final results are right either
// way; only this direct call shows the host's contract.)
let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block);
assert_eq!(walked, None, "walked a block holding a spill it can't see");
}

View File

@ -0,0 +1,108 @@
//! TRIMRANGE: a range or array without its blank outer rows and columns (Microsoft's
//! documented semantics: trim_rows / trim_cols 0 none, 1 leading, 2 trailing, 3 both).
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use crate::eval::{Area, Ev, Evaluator, MAX_CELLS};
/// (leading, trailing) for a trim_rows / trim_cols code.
fn mode(code: f64) -> Result<(bool, bool), CellError> {
match code.trunc() {
0.0 => Ok((false, false)),
1.0 => Ok((true, false)),
2.0 => Ok((false, true)),
3.0 => Ok((true, true)),
_ => Err(CellError::Value),
}
}
/// Rows and columns (first, last) holding a non-blank value in a row-major grid.
fn bounds(values: &[Value], width: usize) -> Option<((usize, usize), (usize, usize))> {
let mut b: Option<((usize, usize), (usize, usize))> = None;
for (i, v) in values.iter().enumerate() {
if matches!(v, Value::Empty) {
continue;
}
let (r, c) = (i / width.max(1), i % width.max(1));
b = Some(match b {
None => ((r, r), (c, c)),
Some(((r0, r1), (c0, c1))) => ((r0.min(r), r1.max(r)), (c0.min(c), c1.max(c))),
});
}
b
}
/// The kept span of `0..len` given the non-blank span and the trim flags.
fn keep(len: usize, nonblank: (usize, usize), (lead, trail): (bool, bool)) -> (usize, usize) {
(if lead { nonblank.0 } else { 0 }, if trail { nonblank.1 } else { len.saturating_sub(1) })
}
impl Evaluator<'_> {
pub(crate) fn trimrange(&mut self, args: &[Expr]) -> Ev {
match self.trimrange_inner(args) {
Ok(ev) => ev,
Err(e) => Ev::V(Value::Error(e)),
}
}
fn trim_mode(&mut self, args: &[Expr], i: usize) -> Result<(bool, bool), CellError> {
match args.get(i) {
None | Some(Expr::Missing) => Ok((true, true)),
Some(e) => match self.value(e).scalar() {
Value::Error(e) => Err(e),
v => mode(v.to_number()?),
},
}
}
fn trimrange_inner(&mut self, args: &[Expr]) -> Result<Ev, CellError> {
let first = match args {
[first] | [first, _] | [first, _, _] => first,
_ => return Err(CellError::Value),
};
let rows = self.trim_mode(args, 1)?;
let cols = self.trim_mode(args, 2)?;
let none = rows == (false, false) && cols == (false, false);
match self.eval(first) {
Ev::R(areas) => {
let [a] = areas.as_slice() else { return Err(CellError::Value) };
if none {
return Ok(Ev::R(vec![*a]));
}
// Cells past the used range are blank: only the used part is read.
let part = self.trim(a);
let (h, w) = (part.height() as usize, part.width() as usize);
if (h as u64) * (w as u64) > MAX_CELLS {
return Err(CellError::Num);
}
let values = self.host.range_values(a.sheet, part);
let ((r0, r1), (c0, c1)) = bounds(&values, w).ok_or(CellError::Calc)?;
let (h_all, w_all) = (a.range.height() as usize, a.range.width() as usize);
let (kr0, kr1) = keep(h_all, (r0, r1), rows);
let (kc0, kc1) = keep(w_all, (c0, c1), cols);
let at = |r: usize, c: usize| CellRef::new(a.range.start.row.saturating_add(r as u32), a.range.start.col.saturating_add(c as u32));
Ok(Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(at(kr0, kc0), at(kr1, kc1)) }]))
}
Ev::V(Value::Array(arr)) => {
if none {
return Ok(Ev::V(Value::Array(arr)));
}
let ((r0, r1), (c0, c1)) = bounds(&arr.data, arr.cols).ok_or(CellError::Calc)?;
let (kr0, kr1) = keep(arr.rows, (r0, r1), rows);
let (kc0, kc1) = keep(arr.cols, (c0, c1), cols);
let mut data = Vec::with_capacity((kr1 + 1 - kr0) * (kc1 + 1 - kc0));
for r in kr0..=kr1 {
for c in kc0..=kc1 {
data.push(arr.get(r, c).cloned().unwrap_or(Value::Empty));
}
}
Array::new(kr1 + 1 - kr0, kc1 + 1 - kc0, data).map(|a| Ev::V(Value::from(a))).ok_or(CellError::Value)
}
Ev::V(Value::Error(e)) => Err(e),
Ev::V(Value::Empty) if !none => Err(CellError::Calc),
Ev::V(v) => Ok(Ev::V(v)),
Ev::L(_) => Err(CellError::Value),
}
}
}

View File

@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
Some((sh, r))
}
/// Evaluates a series formula to a flat list of values (row-major), capped.
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
fn union_parts(f: &str) -> Option<Vec<&str>> {
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
let mut parts = Vec::new();
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
for (i, c) in inner.char_indices() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth -= 1,
',' if !quoted && depth == 0 => {
parts.push(inner.get(start..i)?);
start = i + 1;
}
_ => {}
}
if depth < 0 {
return None;
}
}
parts.push(inner.get(start..)?);
(parts.len() > 1).then_some(parts)
}
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0)
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
}
if depth < 4
&& let Some(parts) = union_parts(f)
{
let mut out = Vec::new();
for p in parts {
if out.len() >= MAX_POINTS {
break;
}
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
}
return out;
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
v => vec![v],

View File

@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart {
y_title: Some("Revenue".into()),
source: None,
by_rows: false,
pivot: None,
}
}
@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() {
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
}
#[test]
fn union_series_references() {
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() {
s.set_value(CellRef::new(r as u32, 0), Value::from(*l));
s.set_value(CellRef::new(r as u32, 1), Value::Number(*v));
}
}
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("\"Sum of X\"".into()),
categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()),
values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(),
..Series::default()
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]);
assert_eq!(d.series[0].name, "Sum of X");
assert_eq!(d.categories, vec!["a", "b", "d"]);
// Deeply nested parentheses from a file are bounded, not a crash.
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
let _ = resolve(&wb, 0, &c);
}

View File

@ -30,14 +30,14 @@ impl CellRef {
}
/// Moved by `(dr, dc)`, `None` when the result leaves the sheet.
pub fn offset(&self, dr: i64, dc: i64) -> Option<CellRef> {
let r = self.row as i64 + dr;
let c = self.col as i64 + dc;
let r = (self.row as i64).checked_add(dr)?;
let c = (self.col as i64).checked_add(dc)?;
if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None }
}
/// Moved by `(dr, dc)` and clamped to the sheet.
pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef {
let r = (self.row as i64 + dr).clamp(0, MAX_ROWS as i64 - 1);
let c = (self.col as i64 + dc).clamp(0, MAX_COLS as i64 - 1);
let r = (self.row as i64).saturating_add(dr).clamp(0, MAX_ROWS as i64 - 1);
let c = (self.col as i64).saturating_add(dc).clamp(0, MAX_COLS as i64 - 1);
CellRef::new(r as u32, c as u32)
}
/// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range.
@ -272,6 +272,21 @@ mod tests {
assert_eq!(a.iter().count(), 9);
}
#[test]
fn extreme_offsets() {
let cell = CellRef::new(1, 1);
for delta in [i64::MIN, i64::MAX] {
assert_eq!(cell.offset(delta, 0), None);
assert_eq!(cell.offset(0, delta), None);
assert_eq!(cell.offset(delta, delta), None);
}
assert_eq!(cell.offset_clamped(i64::MAX, i64::MAX), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1));
assert_eq!(cell.offset_clamped(i64::MIN, i64::MIN), CellRef::new(0, 0));
assert_eq!(cell.offset_clamped(i64::MAX, i64::MIN), CellRef::new(MAX_ROWS - 1, 0));
assert_eq!(cell.offset_clamped(i64::MIN, i64::MAX), CellRef::new(0, MAX_COLS - 1));
assert_eq!(cell.offset(2, -1), Some(CellRef::new(3, 0)));
}
#[test]
fn offsets() {
assert_eq!(CellRef::new(0, 0).offset(-1, 0), None);

View File

@ -263,7 +263,8 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
}
fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
if !(1..=12).contains(&m) || d < 1 {
// Dates before 1900 stay text (`DATE()` would add 1900 to such a year).
if !(1..=12).contains(&m) || d < 1 || y < 1900 {
return None;
}
let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64;
@ -290,6 +291,8 @@ pub fn current_year() -> i64 {
fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
if let Some((tf, secs, ampm)) = parse_time(t) {
let f = match (secs, ampm) {
// 24 hours or more: elapsed hours, so 25:00 shows as 25:00:00, not 1:00.
_ if tf >= 1.0 => "[h]:mm:ss",
(false, false) => "h:mm",
(true, false) => "h:mm:ss",
(false, true) => "h:mm AM/PM",
@ -352,11 +355,18 @@ mod tests {
assert_eq!(p("7-Oct-2026").value, Value::Number(46302.0));
assert_eq!(p("Oct 7, 2026").value, Value::Number(46302.0));
assert_eq!(p("2/30/2026").value, Value::text("2/30/2026"));
assert_eq!(p("7/4/1776").value, Value::text("7/4/1776"));
assert_eq!(p("1776-07-04").value, Value::text("1776-07-04"));
assert_eq!(p("4-Jul-1776").value, Value::text("4-Jul-1776"));
assert_eq!(p("12/31/1899").value, Value::text("12/31/1899"));
assert_eq!(p("1/1/1900").value, Value::Number(1.0));
assert_eq!(p("12:00").value, Value::Number(0.5));
assert_eq!(p("6:00 PM").value, Value::Number(0.75));
assert_eq!(p("12:00 AM").value, Value::Number(0.0));
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));
assert_eq!(p("25:00").format, Some("[h]:mm:ss"));
assert_eq!(p("23:59").format, Some("h:mm"));
}
#[test]

View File

@ -90,10 +90,7 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
]
}
/// Number of icons in a set name like `3TrafficLights1`, `5Quarters`.
pub fn icon_count(set: &str) -> usize {
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3) as usize
}
pub use gridcraft_model::icon_count;
impl CfCache {
pub fn new() -> Self {
@ -144,7 +141,8 @@ impl CfCache {
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
};
(ok && !v.is_empty()).then_some(style.as_ref())
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
(ok && !v.is_error()).then_some(style.as_ref())
}
CfRule::Expression { formula, style } => {
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
@ -154,7 +152,7 @@ impl CfCache {
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()),
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
CfRule::Duplicate { style } => {
@ -261,11 +259,18 @@ impl CfCache {
CfRule::IconSet { set, thresholds, reverse, show_value } => {
if let Some(n) = v.as_f64() {
let k = icon_count(set);
let cuts: Vec<f64> = if thresholds.len() + 1 == k {
thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect()
} else {
(1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect()
// One threshold per icon, the first being the lower bound (as in the file);
// also accept the lower bound left out, else Excel's defaults.
let defaults;
let lower = match thresholds.len() {
n if n == k => thresholds.get(1..).unwrap_or_default(),
n if n + 1 == k => thresholds.as_slice(),
_ => {
defaults = default_icon_thresholds(set);
defaults.get(1..).unwrap_or_default()
}
};
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
if *reverse {
idx = k - 1 - idx.min(k - 1);
@ -281,10 +286,12 @@ impl CfCache {
};
if let Some(st) = matched_style {
any = true;
out.style = Some(match out.style.take() {
None => st.clone(),
Some(prev) => prev,
});
// Every matching rule applies; where two set the same part, the rule with the
// higher priority (seen first) wins.
match &mut out.style {
None => out.style = Some(st.clone()),
Some(prev) => layer_under(prev, st),
}
if cf.stop_if_true {
break;
}
@ -294,6 +301,39 @@ impl CfCache {
}
}
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
/// are unset).
fn layer_under(style: &mut Style, lower: &Style) {
if style.fill.pattern == PatternType::None {
style.fill = lower.fill;
}
if style.font.color == Color::Auto {
style.font.color = lower.font.color;
}
style.font.bold |= lower.font.bold;
style.font.italic |= lower.font.italic;
style.font.strike |= lower.font.strike;
if style.font.underline == Underline::None {
style.font.underline = lower.font.underline;
}
let b = &mut style.borders;
for (dst, src) in [
(&mut b.left, lower.borders.left),
(&mut b.right, lower.borders.right),
(&mut b.top, lower.borders.top),
(&mut b.bottom, lower.borders.bottom),
(&mut b.diag_down, lower.borders.diag_down),
(&mut b.diag_up, lower.borders.diag_up),
] {
if dst.is_none() {
*dst = src;
}
}
if style.num_fmt.as_str() == "General" {
style.num_fmt = lower.num_fmt.clone();
}
}
/// Shifts a rule formula written for the top-left cell of the range to `c`.
fn relative(f: &str, first: CellRef, c: CellRef) -> String {
if first == c {
@ -306,3 +346,68 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String {
Err(_) => f.to_string(),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn every_matching_rule_applies() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap();
let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}});
s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap();
s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
assert!(look("A1").is_none());
let only_fill = look("A2").unwrap();
assert_eq!(only_fill.fill.pattern, PatternType::Solid);
assert!(only_fill.borders.top.is_none());
let both = look("A3").unwrap();
assert_eq!(both.fill, only_fill.fill);
assert!(!both.borders.top.is_none());
}
#[test]
fn cell_value_rules_on_blanks_and_errors() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap();
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"})))
.unwrap();
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap();
s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"})))
.unwrap();
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
let fill = |st: Option<Style>| st.is_some_and(|st| st.fill.pattern == PatternType::Solid);
let border = |st: Option<Style>| st.is_some_and(|st| !st.borders.top.is_none());
// Less than 5: 3 and the blank cell (as 0), not #N/A.
assert_eq!([look("A1"), look("A2"), look("A3"), look("A5")].map(fill), [true, true, false, false]);
// Greater than 5: 7, not #N/A.
assert_eq!([look("B1"), look("B2")].map(fill), [false, true]);
// No blanks: neither the empty cell nor the one holding a space.
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
}
#[test]
fn cell_value_operator_names() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [7]]})).unwrap();
let rule = |op: &str| json!({"range": "A1:A2", "rule": {"type": "cellIs", "operator": op, "value": "5", "preset": "lightRedFill"}});
s.execute("home.conditionalFormat", rule("lessThan")).unwrap();
assert!(s.execute("home.conditionalFormat", rule("smaller")).is_err());
let d = s.doc().unwrap();
let mut cache = CfCache::new();
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
assert_eq!([look("A1"), look("A2")], [true, false]);
}
}

View File

@ -0,0 +1,323 @@
//! Reading the HTML flavour of the system clipboard on paste.
//!
//! Spreadsheets put an HTML table on the clipboard next to the tab-separated text. The text holds
//! what each cell *shows* ("Rp10,000.00"), which en-US input parsing can't always turn back into a
//! number. The HTML of the common sources also carries the underlying number as a cell attribute:
//! `x:num="10000"` (Excel, Excel for the web) and `sdval="10000"` (LibreOffice Calc). Paste uses
//! it to keep such cells numeric (#257). Only the table structure, the cell text and these two
//! attributes are read; everything else in the markup is ignored.
/// Largest clipboard HTML we look at; past this the paste uses the plain text alone.
const MAX_HTML: usize = 16 * 1024 * 1024;
/// Widest `colspan` honoured (the sheet's column count).
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
}
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
if html.len() > MAX_HTML {
return rows;
}
let mut cell: Option<(HtmlCell, usize)> = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest);
}
break;
};
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("<!--") {
rest = after.find("-->").and_then(|e| after.get(e + 3..)).unwrap_or_default();
continue;
}
let Some(gt) = tag_end(rest) else { break };
let tag = rest.get(1..gt).unwrap_or_default();
rest = rest.get(gt + 1..).unwrap_or_default();
let closing = tag.starts_with('/');
let body = tag.trim_start_matches('/');
let name_end = body.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(body.len());
let name = body.get(..name_end).unwrap_or_default().to_ascii_lowercase();
let attrs = body.get(name_end..).unwrap_or_default();
match (name.as_str(), closing) {
("style" | "script", false) => {
// Raw text: skip to the closing tag.
let end = format!("</{name}");
rest = find_ci(rest, &end).and_then(|i| rest.get(i..)).unwrap_or_default();
}
("table", false) => {
if in_table {
break; // a nested table: the outer one is the clipboard grid, stop here
}
in_table = true;
}
("table", true) => {
finish(&mut rows, &mut cell);
if !rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
finish(&mut rows, &mut cell);
rows.push(Vec::new());
}
("td" | "th", false) if in_table => {
finish(&mut rows, &mut cell);
if rows.is_empty() {
rows.push(Vec::new());
}
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
cell = Some((HtmlCell { text: String::new(), num }, span));
}
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
("br", _) => {
if let Some((c, _)) = cell.as_mut() {
c.text.push(' ');
}
}
_ => {}
}
}
finish(&mut rows, &mut cell);
rows
}
/// Closes the open cell (if any) into the last row, decoding its text.
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
let Some((mut c, span)) = cell.take() else { return };
c.text = decode(&c.text);
if rows.is_empty() {
rows.push(Vec::new());
}
if let Some(row) = rows.last_mut() {
row.push(c);
let room = MAX_SPAN.saturating_sub(row.len());
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
}
}
/// Byte index of the `>` that closes the tag `rest` starts with; a `>` inside a quoted attribute
/// value (an inline `mso-number-format` condition such as `[>=1000]`) doesn't end it.
fn tag_end(rest: &str) -> Option<usize> {
let mut quote: Option<char> = None;
for (i, c) in rest.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
_ => {}
}
}
None
}
/// Case-insensitive ASCII search.
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let (h, n) = (hay.as_bytes(), needle.as_bytes());
if n.is_empty() || h.len() < n.len() {
return None;
}
(0..=h.len() - n.len()).find(|&i| h.get(i..i + n.len()).is_some_and(|w| w.eq_ignore_ascii_case(n)))
}
/// The value of attribute `name` in a tag's attribute text (quoted or bare). A bare attribute
/// without `=` has no value.
fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
let mut s = attrs;
loop {
s = s.trim_start_matches(|c: char| c.is_whitespace() || c == '/');
if s.is_empty() {
return None;
}
let key_end = s.find(|c: char| c.is_whitespace() || c == '=' || c == '/').unwrap_or(s.len());
let key = s.get(..key_end).unwrap_or_default();
s = s.get(key_end..).unwrap_or_default().trim_start();
let mut value = None;
if let Some(after) = s.strip_prefix('=') {
let after = after.trim_start();
let (v, next) = match after.chars().next() {
Some(q @ ('"' | '\'')) => {
let inner = after.get(1..).unwrap_or_default();
let end = inner.find(q).unwrap_or(inner.len());
(inner.get(..end).unwrap_or_default(), inner.get(end + 1..).unwrap_or_default())
}
_ => {
let end = after.find(char::is_whitespace).unwrap_or(after.len());
(after.get(..end).unwrap_or_default(), after.get(end..).unwrap_or_default())
}
};
value = Some(v);
s = next;
}
if key.eq_ignore_ascii_case(name) {
return value;
}
if key.is_empty() && value.is_none() {
// Stray character (e.g. a lone '='): skip it so the loop always advances.
let mut chars = s.chars();
chars.next();
s = chars.as_str();
}
}
}
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
let semi = rest.get(..12).unwrap_or(rest).find(';');
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => {
let n = e
.strip_prefix("#x")
.or_else(|| e.strip_prefix("#X"))
.map(|h| u32::from_str_radix(h, 16))
.or_else(|| e.strip_prefix('#').map(str::parse::<u32>));
n.and_then(Result::ok).and_then(char::from_u32)
}
});
match (ch, semi) {
(Some(c), Some(e)) => {
out.push(c);
rest = rest.get(e + 1..).unwrap_or_default();
}
_ => {
out.push('&');
rest = rest.get(1..).unwrap_or_default();
}
}
}
out.push_str(rest);
let mut text = String::with_capacity(out.len());
let mut space = false;
for c in out.chars() {
if c.is_whitespace() && c != '\u{a0}' {
space = true;
continue;
}
if space && !text.is_empty() {
text.push(' ');
}
space = false;
text.push(c);
}
text
}
/// Same-looking text: non-breaking spaces as spaces, whitespace runs collapsed, trimmed.
pub(crate) fn normalized(s: &str) -> String {
s.replace('\u{a0}', " ").split_whitespace().collect::<Vec<_>>().join(" ")
}
/// A number format that shows `n` exactly as `shown` ("Rp10,000.00" for 10000 →
/// `"Rp"#,##0.00`), inferred from the text around the digits. `None` unless the format renders
/// `shown` back character for character.
pub(crate) fn format_for(shown: &str, n: f64) -> Option<String> {
let shown = normalized(shown);
let start = shown.find(|c: char| c.is_ascii_digit())?;
let end = shown.rfind(|c: char| c.is_ascii_digit()).map(|i| i + 1)?;
let core = shown.get(start..end)?;
let mut prefix = shown.get(..start)?.to_string();
let suffix = shown.get(end..)?;
if core.chars().any(|c| !(c.is_ascii_digit() || c == ',' || c == '.')) {
return None;
}
if n < 0.0 {
// A one-section format shows the minus sign itself, in front of everything.
prefix = prefix.strip_prefix('-')?.to_string();
}
let decimals = core.split_once('.').map_or(0, |(_, f)| f.len());
let mut code = String::new();
let quote = |s: &str| if s.is_empty() { String::new() } else { format!("\"{}\"", s.replace('"', "")) };
code.push_str(&quote(&prefix));
code.push_str(if core.contains(',') { "#,##0" } else { "0" });
if decimals > 0 {
code.push('.');
code.push_str(&"0".repeat(decimals.min(30)));
}
code.push_str(&quote(suffix));
let rendered = gridcraft_numfmt::text_function(&gridcraft_core::Value::number(n), &code, gridcraft_core::date::DateSystem::D1900).ok()?;
(normalized(&rendered) == shown).then_some(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_excel_and_libreoffice_cell_values() {
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
let rows = table_cells(excel);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
assert_eq!(rows[1][0].text, "2.50 & up");
assert_eq!(rows[1][2].text, "x");
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
"",
"<",
"<td",
"<table><td x:num=>",
"<table><tr><td colspan=999999999>a",
"&#xFFFFFFFF;&",
"<table><td x:num='1e999'>1</td>",
"<!--",
"<style>",
"<table><table><td>",
] {
let _ = table_cells(html);
}
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
}
#[test]
fn infers_a_format_that_shows_the_same_text() {
assert_eq!(format_for("Rp10,000.00", 10000.0).as_deref(), Some("\"Rp\"#,##0.00"));
assert_eq!(format_for("1 234 kr", 1234.0), None, "space grouping is not reproduced");
assert_eq!(format_for("10.000,00 €", 10000.0), None, "comma decimals don't render back in en-US");
assert_eq!(format_for("-Rp5", -5.0).as_deref(), Some("\"Rp\"0"));
assert_eq!(format_for("12 EUR", 12.0).as_deref(), Some("0\" EUR\""));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"data.filter",
"Filter",
["Data", "Sort & Filter"],
Some("Cmd+Shift+F"),
Some("Ctrl+Shift+L"),
"{range?} toggles AutoFilter on the current region",
has_doc,
toggle_filter
@ -248,7 +248,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
pos(va).cmp(&pos(vb))
} else if case && va.is_text() && vb.is_text() {
va.as_text().unwrap_or("").cmp(vb.as_text().unwrap_or(""))
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
} else {
sort_compare(va, vb)
};
@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"filter": false}))
});
}
let r = data_range(s, p)?;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
@ -653,6 +662,12 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
}
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
/// before uppercase at the first letter that differs in case.
fn case_order(a: &str, b: &str) -> Ordering {
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
}
fn sort_key(v: &Value) -> (u8, i64, String) {
match v {
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
@ -685,8 +700,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
let mut seen = std::collections::HashSet::new();
let mut keep: Vec<u32> = Vec::new();
// Cells are compared by the text they show, as in Excel: the same date in two formats
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
for row in start..=r.end.row {
let key: Vec<String> = cols.iter().map(|c| sh.value(CellRef::new(row, *c)).display().to_lowercase()).collect();
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
if seen.insert(key) {
keep.push(row);
} else {

View File

@ -1,6 +1,6 @@
//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
has_doc,
paste
),
@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
has_doc,
auto_fill
),
@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
@ -199,10 +199,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
let input =
str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default();
let array = bool_param(p, "array").unwrap_or(false);
let protected =
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked));
if protected {
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
let d = s.doc()?;
if let Some(sh) = d.wb.sheet(sheet)
&& sh.is_protected()
&& d.wb.styles.get(sh.style_id(at)).protection.locked
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
{
return Err(EngineError::Other(m));
}
edit(s, |cx| {
if array {
@ -408,9 +411,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col);
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1));
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
@ -604,25 +607,34 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clipboard
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
if ranges.len() > 1 {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
if ranges.len() > 1 && cut {
return Err(multiple());
}
let range = ranges.first().copied().unwrap_or_default();
let d = s.doc()?;
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
let range = if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
let trim = |range: RangeRef| {
if range.is_full_cols() || range.is_full_rows() {
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
} else {
range
}
};
let text = crate::display::range_text(&d.wb, sheet, range);
let (wb, range, areas) = if ranges.len() > 1 {
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
(std::sync::Arc::new(wb), block, areas)
} else {
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
};
let text = crate::display::range_text(&wb, sheet, range);
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
// scripts get it only when they ask for it.
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas };
s.clipboard = Some(clip);
let mut result = json!({"range": range.a1(), "text": text});
if let Some(html) = html {
@ -631,6 +643,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
Ok(result)
}
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
/// into one block at the first area's place, and that block. Formulas travel as their values, as
/// in Excel. `None` when the areas don't line up.
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
let first = *areas.first()?;
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
let mut sorted = areas.to_vec();
if across {
sorted.sort_by_key(|a| a.start.col);
} else if down {
sorted.sort_by_key(|a| a.start.row);
} else {
return None;
}
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
return None;
}
let anchor = sorted.first()?.start;
let (h, w) = if across {
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
} else {
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
};
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
let src = wb.sheet(sheet)?;
let mut out = wb.clone();
let dst = out.sheet_mut(sheet)?;
dst.take_cells(block);
dst.spill.retain(|c, _| !block.contains(*c));
dst.spill_ranges.retain(|c, _| !block.contains(*c));
dst.comments.retain(|c, _| !block.contains(*c));
dst.merges.retain(|m| !m.intersects(&block));
if across {
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
} else {
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
}
let mut next = if across { anchor.col } else { anchor.row };
for a in &sorted {
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
for c in cells {
let Some(to) = c.offset(dr, dc) else { continue };
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
if let Some(picture) = src.cell_pictures.get(&c) {
dst.cell_pictures.insert(to, picture.clone());
}
}
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
if let Some(to) = c.offset(dr, dc) {
dst.comments.insert(to, comment.clone());
}
}
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
dst.merges.push(RangeRef::new(s, e));
}
}
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
if across {
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
}
} else {
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
}
}
next = next.saturating_add(if across { a.width() } else { a.height() });
}
Some((out, block))
}
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
copy_impl(s, p, false)
}
@ -674,13 +763,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
edit(s, |cx| {
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 cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
@ -700,6 +794,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
@ -769,6 +884,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
let transpose = bool_param(p, "transpose").unwrap_or(false);
let link = bool_param(p, "link").unwrap_or(false);
if link && !clip.areas.is_empty() {
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
}
let dest_sheet = target_sheet(s, p)?;
let sel = s.doc()?.selection.current();
let at = cell_param(p, "at").unwrap_or(sel.start);
@ -1050,6 +1168,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
// and only when every box in the selection is unchecked are the boxes removed.
let uncheck_only = what == "contents" && {
let d = s.doc()?;
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
};
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
@ -1061,6 +1191,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
cell.value = Value::Empty;
if checkbox {
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
}
}
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
@ -1068,8 +1213,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.remove_cell(c);
}
"contents" => {
cell.value = Value::Empty;
cell.formula = None;
sh.set_cell(c, cell);
}
"formats" => {
@ -1139,6 +1282,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
let mode = match str_param(p, "mode").unwrap_or("series") {
"copy" => crate::fill::FillMode::Copy,
"alternate" => crate::fill::FillMode::Alternate,
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
_ => crate::fill::FillMode::Series,

View File

@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data…",
["Chart Design", "Data"],
None,
"{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}",
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
has_doc,
select_data
),
@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Checkbox",
["Insert", "Cell Controls"],
None,
"{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)",
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
has_doc,
checkbox
),
@ -350,22 +350,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
}
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string();
let (sheet_name, body) = split_sheet(&range);
let d = s.doc()?;
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = super::insert::series_from_range(src, r, by_rows);
let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1());
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet,
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
@ -828,6 +876,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
}
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
let ranges = target_ranges(s, p)?;
let si = target_sheet(s, p)?;
@ -843,9 +896,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
s.align.h = HAlign::Center;
});
let sh = cx.sheet_mut(si)?;
let v = sh.value(c);
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
sh.set_cell(c, Cell { value, formula: None, style: new });
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
// cell starts unchecked.
let mut cell = sh.cell(c).cloned().unwrap_or_default();
if cell.value.is_empty() && cell.formula.is_none() {
cell.value = Value::Bool(false);
}
cell.style = new;
sh.set_cell(c, cell);
cx.touch(si, c);
}
}
@ -862,12 +920,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
};
edit(s, |cx| {
for c in cells {
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
let sh = cx.sheet_mut(si)?;
if let Some(cell) = sh.cells.get_mut(c)
&& let Value::Bool(b) = cell.value
&& cell.formula.is_none()
{
cell.value = Value::Bool(!b);
// An empty checkbox cell counts as FALSE.
let next = match cell.value {
Value::Bool(b) if checkbox => Value::Bool(!b),
Value::Empty if checkbox => Value::Bool(true),
_ => continue,
};
cell.value = next;
cx.changed.push((si, c));
}
}

View File

@ -1,4 +1,4 @@
//! File menu: new, open, save, close, export, properties.
//! File menu: new, open, save, close, export, properties, options.
use serde_json::{Value as Json, json};
@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
query "file.options",
"Options",
["File"],
None,
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
always,
options
),
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
]
}
@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
/// page. Nothing changes here; every setting has its own command.
fn options(s: &mut Session, _: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
let calculation = s.active().map(|d| {
let c = &d.wb.calc;
let mode = match c.mode {
gridcraft_model::CalcMode::Automatic => "automatic",
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
gridcraft_model::CalcMode::Manual => "manual",
};
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
});
Ok(json!({"calculation": calculation}))
}
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {

View File

@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
// Alignment
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], Some("Cmd+R"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Row Height…",
["Home", "Cells", "Format"],
None,
"{rows?: \"2:5\", height: 20 (points on screen)}",
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
has_doc,
row_height
),
@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Column Width…",
["Home", "Cells", "Format"],
None,
"{cols?: \"B:D\", width: 64 (points) | chars: 8.43}",
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
has_doc,
col_width
),
@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
let key = if rows { "rows" } else { "cols" };
if let Some(t) = str_param(p, key) {
let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?;
return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]);
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
return t
.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect();
}
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
}

View File

@ -1,7 +1,7 @@
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{CalcMode, DefinedName};
use gridcraft_model::{CalcMode, DefinedName, Formula};
use serde_json::{Value as Json, json};
use super::*;
@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?}",
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
has_doc,
define_name
),
@ -185,6 +185,7 @@ pub fn function_list() -> Vec<Json> {
("SHEET", "Information", "SHEET([value])", "Sheet number."),
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
("TRIMRANGE", "Lookup", "TRIMRANGE(range, [trim_rows], [trim_cols])", "A range or array without its blank outer rows and columns."),
("ISOMITTED", "Information", "ISOMITTED(argument)", "TRUE when a LAMBDA argument was left out."),
];
for (n, c, sig, d) in specials {
@ -265,7 +266,32 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
// Editing an existing name (Name Manager › Edit…): it must exist, and its new name must not
// collide with another name in the target scope.
let old = match str_param(p, "oldName") {
Some(old_name) => {
let old_scope = match str_param(p, "oldScope") {
None | Some("Workbook") => None,
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
if !d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(old_name) && n.scope == old_scope) {
return Err(bad("formulas.defineName", "no such name to edit"));
}
let same = old_name.eq_ignore_ascii_case(&name) && old_scope == scope;
if !same && d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name) && n.scope == scope) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
Some((old_name.to_string(), old_scope))
}
None => None,
};
edit(s, |cx| {
if let Some((old_name, old_scope)) = &old {
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(old_name) && n.scope == *old_scope));
if !old_name.eq_ignore_ascii_case(&name) {
rename_name_in_formulas(&mut cx.wb, old_name, *old_scope, &name);
}
}
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && n.scope == scope));
cx.wb.names.push(DefinedName { name: name.clone(), scope, formula: refers.clone(), comment: comment.clone(), hidden: false });
cx.structural = true;
@ -273,6 +299,51 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Formulas that use a renamed name follow it: cell formulas and other names' definitions, on
/// the sheets where `old` resolves to the renamed name (its own sheet for a sheet-scoped name;
/// every sheet without a same-named sheet-level name for a workbook-level one).
fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_scope: Option<usize>, new: &str) {
let shadowed: Vec<usize> = wb.names.iter().filter(|n| n.name.eq_ignore_ascii_case(old)).filter_map(|n| n.scope).collect();
let applies = |sheet: usize| match old_scope {
Some(s) => s == sheet,
None => !shadowed.contains(&sheet),
};
// The renamed expression, or `None` when the name doesn't occur in it.
let rename = |e: gridcraft_formula::Expr| {
let mut hit = false;
let out = e.map(&mut |x| match x {
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(old) => {
hit = true;
gridcraft_formula::Expr::Name(new.to_string())
}
o => o,
});
hit.then_some(out)
};
for i in 0..wb.sheets.len() {
if !applies(i) {
continue;
}
let Some(sh) = wb.sheet_mut(i) else { continue };
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> = sh.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect();
for (c, f) in formulas {
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
}
}
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") {

View File

@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_chart
),
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
s,
&with(p, "type", json!("auto"))
)),
cmd!(
"insert.recommendedCharts",
"Recommended Charts",
["Insert", "Charts"],
None,
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
has_doc,
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
),
cmd!(
query "insert.chartRecommendations",
"Chart Recommendations",
[],
None,
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
has_doc,
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
),
cmd!(
"chart.set",
"Chart Properties",
@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
insert_link
),
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
s, p, true
)),
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
let header_row = r.height() > 1
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
// A blank top-left cell above a header row marks the first column as the category labels even
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
// plotted as a series of its own (#287).
let blank_corner = header_row && sh.value(r.start).is_empty();
let label_col =
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
let mut out = Vec::new();
if !by_rows {
@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
/// Most cells a chart's source block may span (bounds the series it builds).
const MAX_CHART_CELLS: u64 = 1_000_000;
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
/// series → clustered and stacked columns and bars, line.
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let by_rows = r.width() > r.height() + 1;
let series = series_from_range(sh, r, by_rows);
let ranges: Vec<RangeRef> = series
.iter()
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
_ => None,
})
.collect();
let points = ranges.first().map_or(0, RangeRef::count);
let (mut any_number, mut non_negative) = (false, true);
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
any_number = true;
non_negative &= n >= 0.0;
}
if series.is_empty() || !any_number {
return Err(EngineError::Other("Select data for the chart first.".into()));
}
let labelled = series.first().is_some_and(|x| x.categories.is_some());
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
const LINE: (&str, &str, &str) = ("line", "", "Line");
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
const AREA: (&str, &str, &str) = ("area", "", "Area");
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
let mut out = Vec::new();
if series.len() == 2 && !labelled && !by_rows {
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
} else if series.len() == 1 {
if points > 12 {
out.extend([LINE, COLUMN, AREA]);
} else {
out.extend([COLUMN, BAR]);
if non_negative && points >= 2 {
out.extend([PIE, DOUGHNUT]);
}
out.push(LINE_MARKERS);
}
} else if points > 12 {
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
} else {
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
}
Ok(out)
}
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
/// goes on and the chart (previews in the Recommended Charts dialog).
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
let r = block(s, p)?;
if r.count() > MAX_CHART_CELLS {
return Err(bad("insert.chart", "range too large for a chart"));
}
let mut t = str_param(p, "type").unwrap_or("column").to_string();
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
if t == "auto" {
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
t = first.0.into();
sub = first.1.into();
}
let sheet = target_sheet(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// Excel plots series by the longer dimension (more rows → series in columns).
let data_h = r.height();
let data_w = r.width();
let by_rows = data_w > data_h + 1;
if t == "auto" {
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
}
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
let kind = chart_kind(&t, &sub);
let series = series_from_range(sh, r, by_rows);
if series.is_empty() {
return Err(EngineError::Other("Select data for the chart first.".into()));
@ -606,7 +695,14 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
by_rows,
pivot: None,
};
Ok((sheet, chart))
}
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, chart) = build_chart(s, p)?;
let id = chart.id;
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
Ok(json!({"chart": id}))
@ -782,7 +878,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
};
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
let style = sh.style_id(at);
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
}
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
@ -1036,9 +1132,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
let text = str_param(p, "text").unwrap_or("").to_string();
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
edit(s, |cx| {
cx.sheet_mut(sheet)?
.comments
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
cx.sheet_mut(sheet)?.comments.insert(
at,
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
);
Ok(json!({"cell": at.a1()}))
})
}
@ -1157,15 +1254,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
};
let rule = match kind.as_str() {
"cellIs" => {
// Excel's file and VBA names (`lessThan`, …) are accepted too.
let op = match g("operator").as_deref().unwrap_or("greater") {
"between" => CfOperator::Between,
"notBetween" => CfOperator::NotBetween,
"equal" => CfOperator::Equal,
"notEqual" => CfOperator::NotEqual,
"less" => CfOperator::Less,
"greaterOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" => CfOperator::LessOrEqual,
_ => CfOperator::Greater,
"less" | "lessThan" => CfOperator::Less,
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
"greater" | "greaterThan" => CfOperator::Greater,
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
};
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
let b = rule.get("value2").map(super::edit::json_to_input);

View File

@ -1,6 +1,7 @@
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
@ -14,6 +15,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod solver;
pub mod spelling;
pub mod view;
pub mod whatif;
@ -69,6 +71,21 @@ impl CommandSpec {
}
}
/// Params that hold a secret: `password`, or a name ending in `Password`.
pub fn is_secret_param(key: &str) -> bool {
key == "password" || key.ends_with("Password")
}
/// The params as the journal records them: secret params are left out, so a password never
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
/// Replaying such an entry runs the command without that param.
pub fn journal_params(params: &Json) -> Json {
match params {
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
other => other.clone(),
}
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@ -118,8 +135,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
})
@ -257,7 +276,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb)?;
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
@ -292,7 +311,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is
/// 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<()> {
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
continue;
@ -302,14 +321,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
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()));
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
return Err(EngineError::Other(m));
}
}
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
None => PROTECTED.into(),
})
}
/// 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());

View File

@ -43,6 +43,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
summarize_table
),
cmd!(
"pivot.calculatedField",
"Calculated Field",
["PivotTable Analyze", "Calculations"],
None,
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
has_doc,
calculated_field
),
cmd!(
"insert.pivotChart",
"PivotChart",
["Insert", "Charts"],
None,
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
has_doc,
insert_pivot_chart
),
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
@ -320,6 +338,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
}
/// The calculated field named `field`, if any.
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
}
/// The canonical name of a source or calculated field.
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
match calc_field(pt, field) {
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
_ => canonical(cmd, src, field),
}
}
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
if !taken(base) {
@ -343,7 +374,11 @@ fn add_to_area(
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
let field = canonical(cmd, src, field)?;
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
if is_calc && area != "values" {
return Err(other("A calculated field can only be placed in the Values area."));
}
let field = canonical_any(cmd, pt, src, field)?;
match area {
"rows" | "columns" => {
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
@ -369,7 +404,7 @@ fn add_to_area(
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
}
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
let base = match name {
Some(n) if !n.trim().is_empty() => {
@ -663,6 +698,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
"style": pt.style,
"showHeaders": pt.show_headers,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
})
}
@ -702,6 +738,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
.collect(),
Err(_) => vec![],
};
let mut list = list;
for c in &pt.calculated_fields {
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
}
if let Some(m) = out.as_object_mut() {
m.insert("fields".into(), Json::Array(list));
if let Ok(src) = &src {
@ -743,7 +784,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.calculatedField";
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
// Menu-style call: the Insert Calculated Field dialog.
let (si, pi) = find_pivot(s, p)?;
let d = s.doc()?;
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
let pivot = pt.name.clone();
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
return ok();
};
if name.is_empty() || name.chars().count() > 255 {
return Err(bad(C, "`name` must be 1 to 255 characters"));
}
let delete = bool_param(p, "delete").unwrap_or(false);
let formula = str_param(p, "formula").map(|f| {
let f = f.trim();
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
});
let to_values = bool_param(p, "values").unwrap_or(true);
if !delete && formula.is_none() {
return Err(bad(C, "missing `formula`"));
}
update(s, p, |pt, src, _| {
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
if delete {
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
let gone = pt.calculated_fields.remove(i);
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
return Ok(json!({"name": gone.name, "deleted": true}));
}
let formula = formula.clone().unwrap_or_default();
if src.col(&name).is_some() {
return Err(other("PivotTable field name already exists."));
}
let canon = match existing {
Some(i) => {
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
c.formula = formula.clone();
c.name.clone()
}
None => {
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
}
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
name.clone()
}
};
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
if let Some(e) = set.error(i) {
return Err(bad(C, e.to_string()));
}
if set.is_cyclic(i) {
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
}
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
}
Ok(json!({
"name": canon,
"formula": formula,
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
}))
})
}
@ -1171,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
c.pivot = None;
}
}
}
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
if pi < sh.pivots.len() {
@ -1181,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
})
}
// ---------------------------------------------------------------- PivotChart
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(t) = str_param(p, "pivot") {
let d = s.doc()?;
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
}
}
find_pivot(s, p)
}
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = chart_pivot(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
}
let series = pv::chart_series(&sh.name, area.start, &out);
let t = str_param(p, "type").unwrap_or("column");
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let title = match p.get("title") {
Some(Json::String(t)) => Some(t.clone()),
Some(Json::Null) => None,
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
};
let id = d.wb.next_object_id();
let link = pv::chart_link(&sh.name, &pt.name);
let pivot_name = pt.name.clone();
let n = series.len();
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: Some(link),
};
edit(s, |cx| {
cx.sheet_mut(si)?.charts.push(chart);
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
})
}
// ---------------------------------------------------------------- recommended
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
@ -1280,8 +1476,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use gridcraft_core::{CellError, CellRef, Value};
use serde_json::{Value as Json, json};
use crate::Session;
@ -1550,6 +1746,133 @@ mod tests {
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
#[test]
fn pivot_chart_follows_the_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
// Any cell of the report picks the PivotTable.
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
assert_eq!(ch.title.as_deref(), Some("Total"));
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
// A column field: one series per item, rebound by the layout change.
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
let ch = pivot_chart(&s);
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
// Nested row fields: the innermost items only, as a union around the parent rows.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
let ch = pivot_chart(&s);
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
let pts = plotted(&s);
assert_eq!(pts[0].len(), 9);
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
// Survives an XLSX round trip as a PivotChart.
let wb = (*s.doc().unwrap().wb).clone();
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
let si = back.sheet_index("Sheet2").unwrap();
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
// Renaming the sheet keeps the link; a refresh still rebinds.
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
assert_eq!(pivot_chart(&s).series.len(), 3);
// Deleting the PivotTable leaves an ordinary chart.
s.execute("pivot.delete", json!({})).unwrap();
assert_eq!(pivot_chart(&s).pivot, None);
}
#[test]
fn pivot_chart_errors() {
let mut s = sales();
// No PivotTable yet.
assert!(s.execute("insert.pivotChart", json!({})).is_err());
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
// Not in the report; no values.
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
assert_eq!(ch.title, None);
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
}
#[test]
fn calculated_field_on_sums() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
assert_eq!(r["formula"], "=Sales / Units");
assert_eq!(text(&s, "C3"), "Sum of Price");
assert_eq!(text(&s, "A4"), "East");
// East: 415 sales over 33 units, from the sums.
let east = val(&s, "C4").as_f64().unwrap();
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
// The grand total applies the formula to the totals, not the sum of the rows.
assert_eq!(text(&s, "A7"), "Grand Total");
let total = val(&s, "C7").as_f64().unwrap();
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
// Quoted names, functions, and another calculated field.
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
assert_eq!(val(&s, "D4"), Value::Number(25.2));
// Division by zero shows #DIV/0! cells.
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
// Change and delete.
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
assert_eq!(val(&s, "E4"), Value::Number(66.0));
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
assert_eq!(pt(&s).values.len(), 3);
assert_eq!(pt(&s).calculated_fields.len(), 2);
// The field list shows calculated fields.
let f = s.execute("pivot.fields", json!({})).unwrap();
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
}
#[test]
fn calculated_field_errors() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
assert!(e(json!({"name": "Bad"})).contains("formula"));
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
// A cycle is refused.
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
assert!(err.contains("itself"), "{err}");
// Calculated fields only go to Values; nothing changed after the failures.
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
assert_eq!(pt(&s).calculated_fields.len(), 2);
// Without a name the dialog opens (menu-style call).
s.ui_requests.clear();
s.execute("pivot.calculatedField", json!({})).unwrap();
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
}
#[test]
fn replace_protection() {
let mut s = sales();

View File

@ -1,6 +1,6 @@
//! Review tab: protection, spelling (basic), workbook statistics.
use gridcraft_model::{SheetProtection, password_hash};
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash};
use serde_json::{Value as Json, json};
use super::*;
@ -19,6 +19,18 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
// Not journaled: the params can hold a password.
CommandSpec {
id: "review.allowEditRanges",
label: "Allow Edit Ranges…",
menu: &["Review", "Protect"],
shortcut: None,
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
enabled: has_doc,
run: allow_edit_ranges,
journal: false,
undoable: true,
},
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
]
}
@ -60,6 +72,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
}
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
/// protected sheet, or unlock a password range for this session.
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let sheet = target_sheet(s, p)?;
let action = str_param(p, "action").unwrap_or("list");
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let sheet_name = sh.name.clone();
let find = |t: Option<&str>| -> Result<usize> {
let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
sh.edit_ranges
.iter()
.position(|r| r.title.eq_ignore_ascii_case(t))
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
};
let hash = |pw: &str, title: &str| {
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
};
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
}
match action {
"list" => {}
"unlock" => {
let i = find(title)?;
let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
if let Some(h) = &r.password {
let pw = str_param(p, "password").unwrap_or("");
if !h.verify(pw).map_err(EngineError::Other)? {
return Err(EngineError::Other("The password you supplied is not correct.".into()));
}
let key = (sheet_name.clone(), r.title.clone());
let d = s.doc_mut()?;
if !d.unlocked_ranges.contains(&key) {
d.unlocked_ranges.push(key);
}
}
}
"add" => {
let t = match title {
Some(t) => t.to_string(),
None => {
(1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
}
};
if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
}
let ranges = target_ranges(s, p)?;
let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
let r = EditRange { title: t, ranges, password, security_descriptor: None };
edit(s, |cx| {
cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
Ok(())
})?;
}
"modify" => {
let i = find(title)?;
let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
}
r.title = n.to_string();
}
if let Some(pw) = str_param(p, "password") {
r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
}
if p.get("range").is_some() {
r.ranges = target_ranges(s, p)?;
}
edit(s, |cx| {
if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
*x = r.clone();
}
Ok(())
})?;
}
"delete" => {
let i = find(title)?;
edit(s, |cx| {
let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
if i < list.len() {
list.remove(i);
}
Ok(())
})?;
}
other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
}
let d = s.doc()?;
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
Ok(Json::Array(
sh.edit_ranges
.iter()
.map(|r| {
let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
})
.collect(),
))
}
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
let cur = s.doc()?.wb.protected_structure;
let on = bool_param(p, "on").unwrap_or(!cur);
@ -97,3 +220,61 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
}
Ok(Json::Array(issues))
}
#[cfg(test)]
mod tests {
use super::*;
fn protected_book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s
}
#[test]
fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
let mut s = protected_book();
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
assert!(err.contains("Budget"), "{err}");
// A paste or fill is checked the same way.
assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
assert_eq!(list[1]["unlocked"], true);
s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
// Ranges can't change while the sheet is protected.
assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
}
#[test]
fn edit_ranges_add_modify_delete_list_and_undo() {
let mut s = Session::new();
s.new_workbook();
// Empty params list; nothing to list yet.
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
let list = s
.execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
.unwrap();
assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
// Inserting a row above moves the range with its cells.
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
}
}

View File

@ -146,6 +146,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
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)).collect();
@ -538,6 +542,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
{
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
}
}
}
for n in cx.wb.names.iter_mut() {
@ -693,6 +703,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
for cf in sh.cond_formats.iter_mut() {
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
for er in sh.edit_ranges.iter_mut() {
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
}
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
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();

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
cmd!(query "view.formulaBar", "Formula Bar", ["View", "Show"], None, "{on?: bool}", always, |s, p| {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_bar}))
}),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
@ -336,3 +341,28 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_100_resets_the_active_sheet_zoom() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": 150})).unwrap();
assert_eq!(s.execute("view.zoom100", json!({})).unwrap()["zoom"], 100);
assert_eq!(s.doc().unwrap().wb.active().unwrap().zoom, 100);
}
#[test]
fn formula_bar_toggles_without_a_workbook_and_defaults_to_shown() {
let mut s = Session::new();
assert!(s.prefs.formula_bar);
assert_eq!(s.execute("view.formulaBar", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("view.formulaBar", json!({"on": true})).unwrap()["on"], true);
// A prefs.json saved before the option existed keeps the formula bar shown.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
}

View File

@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
/// are trimmed to the used range.
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;

View File

@ -13,6 +13,9 @@ use crate::cmd::Ctx;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMode {
Series,
/// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a
/// series stepping by 1; anything a plain drag would extend is copied.
Alternate,
Copy,
Formats,
ValuesOnly,
@ -62,13 +65,14 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
};
let pattern = if mode == FillMode::Series {
Pattern::detect(
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
let detected = Pattern::detect(
&src_cells,
&custom,
cx.wb.date_system,
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
)
);
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
} else {
Pattern::Copy
};
@ -84,7 +88,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Copy => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@ -231,6 +235,19 @@ impl Pattern {
Pattern::Copy
}
/// The pattern of a Ctrl+drag, which swaps copying and extending: a single
/// number (which a plain drag copies) counts up by 1, and every series a
/// plain drag would extend (numbers, dates, lists, `Item 1`) is copied.
fn alternate(self, cells: &[(CellRef, Option<Cell>)]) -> Pattern {
match (self, cells) {
(Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() {
Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 },
_ => Pattern::Copy,
},
_ => Pattern::Copy,
}
}
/// The value `k` steps beyond the source (forward) or before it (backward).
fn value(&self, k: i64, forward: bool, _len: usize) -> Option<Value> {
let sign = if forward { 1 } else { -1 };

View File

@ -8,11 +8,13 @@
pub mod catalog;
pub mod cf;
mod clip_html;
pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod pivot;
pub mod pivot_calc;
pub mod sample;
pub mod selection;
pub mod tables;
@ -70,6 +72,9 @@ pub struct Clipboard {
pub doc_uid: u64,
/// Plain text (tab-separated) for the system clipboard.
pub text: String,
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
/// together into one block at `range`. Empty for a single-range copy.
pub areas: Vec<RangeRef>,
}
/// In-cell edit mode as shown in the status bar.
@ -96,6 +101,8 @@ pub struct DocState {
pub title: String,
/// Per-sheet selection memory.
pub sheet_selections: Vec<Selection>,
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
pub unlocked_ranges: Vec<(String, String)>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@ -119,6 +126,7 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
title,
sheet_selections: vec![],
unlocked_ranges: vec![],
}
}
pub fn is_dirty(&self) -> bool {
@ -150,6 +158,8 @@ pub struct Prefs {
pub r1c1: bool,
/// Words added with Add to Dictionary.
pub user_dictionary: Vec<String>,
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
pub formula_bar: bool,
}
impl Default for Prefs {
@ -163,6 +173,7 @@ impl Default for Prefs {
sheets_in_new_workbook: 1,
r1c1: false,
user_dictionary: vec![],
formula_bar: true,
}
}
}
@ -184,6 +195,7 @@ pub struct Session {
pub clipboard: Option<Clipboard>,
pub prefs: Prefs,
pub mode: Mode,
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
pub journal: Vec<(String, Json)>,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
@ -284,7 +296,7 @@ impl Session {
}
};
if spec.journal && result.is_ok() {
self.journal.push((id.to_string(), params.clone()));
self.journal.push((id.to_string(), cmd::journal_params(&params)));
if self.journal.len() > 10_000 {
self.journal.drain(..5_000);
}

View File

@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::*;
use crate::pivot_calc::CalcSet;
/// Largest source block read (cells).
pub const MAX_SOURCE_CELLS: u64 = 10_000_000;
/// Largest report written (cells).
@ -503,6 +505,12 @@ pub struct Output {
/// Rows above the body (report filters plus a blank row).
pub body_offset: u32,
pub width: u32,
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
pub chart_rows: Vec<u32>,
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
pub chart_cols: Vec<(u32, String)>,
/// Grid column holding the innermost row labels (`None` without row fields).
pub label_col: Option<u32>,
}
impl Output {
@ -546,6 +554,8 @@ enum LineKind {
struct Line {
kind: LineKind,
/// A category of a PivotChart: an innermost (or collapsed) item.
leaf: bool,
path: Vec<u32>,
values: bool,
/// (column, value, indent, number format)
@ -587,14 +597,21 @@ impl Ctx<'_> {
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
});
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
self.flatten(&node.children, depth + 1, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
@ -621,12 +638,13 @@ impl Ctx<'_> {
labels.push((depth, v, 0, fmt));
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
@ -666,13 +684,44 @@ fn flatten_cols(
type AggMap = HashMap<(Vec<u32>, Vec<u32>), Vec<Acc>>;
/// Where a value field's numbers come from.
#[derive(Clone, Copy)]
enum ValSrc {
/// A source column.
Col(usize),
/// A calculated field (index into the PivotTable's calculated fields).
Calc(usize),
}
/// Computes a PivotTable report from the workbook.
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
let src = read_source(wb, &pt.source, pivot_sheet)?;
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect();
// Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`).
let calc = CalcSet::new(pt, &src.headers);
let vals: Vec<(ValSrc, &PivotValue)> = pt
.values
.iter()
.filter_map(|v| match src.col(&v.source_col) {
Some(c) => Some((ValSrc::Col(c), v)),
None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)),
})
.collect();
// Source columns summed for the calculated fields, accumulated after the value fields.
let mut extra: Vec<usize> = Vec::new();
for (vs, _) in &vals {
if let ValSrc::Calc(i) = vs {
for name in calc.source_refs(*i) {
if let Some(c) = src.col(&name)
&& !extra.contains(&c)
{
extra.push(c);
}
}
}
}
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
}
@ -728,8 +777,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}
for a in 0..=rp.len() {
for b in 0..=cp.len() {
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k]);
for (acc, (c, _)) in e.iter_mut().zip(vals.iter()) {
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]);
for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) {
if let ValSrc::Col(c) = vs {
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
}
}
for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) {
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
}
}
@ -755,7 +809,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
@ -763,7 +817,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
lines.push(Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@ -829,7 +889,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if empty {
let r = g.push_row(RowKind::Placeholder)?;
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
return Ok(Output {
width: g.width as u32,
grid: g.rows,
row_kinds: g.kinds,
body_offset,
chart_rows: vec![],
chart_cols: vec![],
label_col: None,
});
}
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
@ -915,8 +983,21 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
// ---- body
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
let (_, v) = vals.get(vi)?;
aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func))
let (vs, v) = vals.get(vi)?;
let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?;
match vs {
ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)),
ValSrc::Calc(i) => {
let sum = |name: &str| -> Value {
src.col(name)
.and_then(|c| extra.iter().position(|x| *x == c))
.and_then(|j| accs.get(k + j))
.map(|a| a.result(PivotFunc::Sum))
.unwrap_or(Value::Number(0.0))
};
Some(calc.value(wb, *i, &sum))
}
}
};
let num = |v: &Option<Value>| v.as_ref().and_then(Value::as_f64);
let ratio = |a: Value, b: Option<Value>| -> Value {
@ -935,7 +1016,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
};
let data_fmt: Vec<Option<String>> = vals
.iter()
.map(|(c, v)| match v.show_as {
.map(|(vs, v)| match v.show_as {
PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => {
Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into()))
}
@ -943,12 +1024,42 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
_ => v.number_format.clone().or_else(|| match v.func {
PivotFunc::Count | PivotFunc::CountNumbers => None,
_ => {
let f = fmt_of(*c);
let f = match vs {
ValSrc::Col(c) => fmt_of(*c),
ValSrc::Calc(_) => "General".to_string(),
};
(f != "General").then_some(f)
}
}),
})
.collect();
let mut chart_rows: Vec<u32> = Vec::new();
let chart_cols: Vec<(u32, String)> = data_cols
.iter()
.enumerate()
.filter_map(|(j, (si, vi))| {
let spec = specs.get(*si)?;
if spec.kind != ColKind::Leaf {
return None;
}
let mut parts: Vec<String> =
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
if let Some(vi) = vi
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
LineKind::Item(b) => RowKind::Item(b),
@ -961,6 +1072,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
};
let r = g.push_row(rk)?;
if line.leaf && k > 0 {
chart_rows.push(r as u32);
}
for (c, v, indent, fmt) in &line.labels {
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
}
@ -1015,7 +1129,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
}
// ---------------------------------------------------------------- styling and writing
@ -1205,9 +1319,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
if let Some(p) = shm.pivots.get_mut(idx) {
p.last_range = Some(area);
}
let sheet_name = shm.name.clone();
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
Ok(area)
}
// ---------------------------------------------------------------- PivotCharts
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
pub fn chart_link(sheet: &str, pivot: &str) -> String {
format!("{}!{pivot}", quote_sheet(sheet))
}
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
let t = link.trim();
let t = match t.strip_prefix('[') {
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
None => t,
};
let (sheet, name) = t.rsplit_once('!')?;
let sheet = sheet.trim();
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
let name = name.trim();
if sheet.is_empty() || name.is_empty() {
return None;
}
Some((sheet, name.to_string()))
}
/// Whether a PivotChart link points at `pivot` on `sheet`.
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
}
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
let q = quote_sheet(sheet);
let c = origin.col.checked_add(col)?;
let letters = gridcraft_core::col_to_letters(c);
let mut parts: Vec<String> = Vec::new();
let mut i = 0;
while let Some(&start) = rows.get(i) {
let mut end = start;
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
end += 1;
i += 1;
}
i += 1;
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
}
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(format!("({})", parts.join(","))),
}
}
/// Most category rows a PivotChart plots.
const MAX_CHART_ROWS: usize = 10_000;
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
/// items, one series per item column (column items × value fields); totals are left out.
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
out.chart_cols
.iter()
.filter_map(|(c, name)| {
Some(Series {
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
categories: categories.clone(),
values: rows_ref(sheet, origin, *c, &rows)?,
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
})
})
.collect()
}
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
if hits.is_empty() {
return;
}
let series = chart_series(sheet, origin, out);
for i in hits {
let Some(sh) = wb.sheet_mut(i) else { continue };
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
ch.series = series.clone();
}
}
}
/// Clears a pivot's last output area.
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
let Some(shm) = wb.sheet_mut(sheet) else { return };

View File

@ -0,0 +1,362 @@
//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`,
//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`).
//!
//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names
//! for each report cell (items, subtotals and grand totals alike), not record by record: for
//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written
//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not
//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`.
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{PivotTable, Workbook};
/// Longest formula accepted.
pub const MAX_FORMULA_LEN: usize = 8_192;
/// Most calculated fields per PivotTable.
pub const MAX_CALC_FIELDS: usize = 256;
/// Deepest chain of calculated fields using calculated fields.
const MAX_DEPTH: usize = 64;
const PREFIX: &str = "__pcf";
fn placeholder(i: usize) -> String {
format!("{PREFIX}{i}")
}
/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the
/// order of `refs`.
#[derive(Clone, Debug)]
pub struct Compiled {
pub body: Expr,
/// Canonical names of the fields used.
pub refs: Vec<String>,
}
/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a
/// source or calculated field.
pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<Compiled, String> {
let text = formula.trim();
let text = text.strip_prefix('=').unwrap_or(text).trim();
if text.is_empty() {
return Err("The formula is empty.".into());
}
if text.len() > MAX_FORMULA_LEN {
return Err("The formula is too long.".into());
}
let chars: Vec<char> = text.chars().collect();
let at = |i: usize| chars.get(i).copied();
let mut out = String::with_capacity(text.len() + 16);
let mut refs: Vec<String> = Vec::new();
let slot = |name: String, refs: &mut Vec<String>| -> String {
let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| {
refs.push(name);
refs.len() - 1
});
placeholder(i)
};
let mut i = 0;
while let Some(c) = at(i) {
match c {
'"' => {
out.push('"');
i += 1;
loop {
match at(i) {
None => return Err("The formula has an unterminated text value.".into()),
Some('"') if at(i + 1) == Some('"') => {
out.push_str("\"\"");
i += 2;
}
Some('"') => {
out.push('"');
i += 1;
break;
}
Some(x) => {
out.push(x);
i += 1;
}
}
}
}
'\'' => {
let mut name = String::new();
i += 1;
loop {
match at(i) {
None => return Err("The formula has an unterminated field name.".into()),
Some('\'') if at(i + 1) == Some('\'') => {
name.push('\'');
i += 2;
}
Some('\'') => {
i += 1;
break;
}
Some(x) => {
name.push(x);
i += 1;
}
}
}
let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?;
out.push_str(&slot(canon, &mut refs));
}
c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') {
out.push(at(i).unwrap_or('0'));
i += 1;
}
if matches!(at(i), Some('e' | 'E')) {
let sign = usize::from(matches!(at(i + 1), Some('+' | '-')));
if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) {
for _ in 0..=sign {
out.push(at(i).unwrap_or('0'));
i += 1;
}
while at(i).is_some_and(|d| d.is_ascii_digit()) {
out.push(at(i).unwrap_or('0'));
i += 1;
}
}
}
}
c if c.is_alphabetic() || c == '_' => {
let start = i;
while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') {
i += 1;
}
let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default();
let mut j = i;
while at(j).is_some_and(char::is_whitespace) {
j += 1;
}
let is_call = at(j) == Some('(');
match field(&word) {
Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)),
_ => out.push_str(&word),
}
}
c => {
out.push(c);
i += 1;
}
}
}
let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?;
let mut problem: Option<String> = None;
body.walk(&mut |e| {
if problem.is_some() {
return;
}
match e {
Expr::Name(n) if n.starts_with(PREFIX) => {}
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
_ => {}
}
});
match problem {
Some(p) => Err(p),
None => Ok(Compiled { body, refs }),
}
}
fn literal(v: &Value) -> Expr {
match v {
Value::Number(n) => Expr::Number(*n),
Value::Error(e) => Expr::Error(*e),
Value::Text(t) => Expr::Text(t.clone()),
Value::Bool(b) => Expr::Bool(*b),
Value::Empty => Expr::Number(0.0),
Value::Array(_) => literal(&v.scalar()),
}
}
/// Evaluates a compiled formula with the given field values (in `refs` order).
pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value {
let expr = if c.refs.is_empty() {
c.body.clone()
} else {
let mut call = Vec::with_capacity(c.refs.len() * 2 + 1);
for i in 0..c.refs.len() {
call.push(Expr::Name(placeholder(i)));
call.push(literal(args.get(i).unwrap_or(&Value::Empty)));
}
call.push(c.body.clone());
Expr::Call("LET".into(), call)
};
match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() {
Value::Empty => Value::Number(0.0),
v => v,
}
}
/// A PivotTable's calculated fields, compiled against its source headers.
pub struct CalcSet {
names: Vec<String>,
compiled: Vec<Result<Compiled, String>>,
}
impl CalcSet {
pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet {
let names: Vec<String> = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect();
let lookup = |n: &str| -> Option<String> {
let n = n.trim();
headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned()
};
let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect();
CalcSet { names, compiled }
}
pub fn index(&self, name: &str) -> Option<usize> {
self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim()))
}
/// The compile error of a calculated field, if any.
pub fn error(&self, i: usize) -> Option<&str> {
self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str)
}
/// Source fields a calculated field needs, directly or through other calculated fields.
pub fn source_refs(&self, i: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = vec![false; self.names.len()];
let mut stack = vec![i];
while let Some(j) = stack.pop() {
if !seen.get(j).copied().unwrap_or(true) {
if let Some(s) = seen.get_mut(j) {
*s = true;
}
if let Some(Ok(c)) = self.compiled.get(j) {
for r in &c.refs {
match self.index(r) {
Some(k) => stack.push(k),
None => {
if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) {
out.push(r.clone());
}
}
}
}
}
}
}
out
}
/// Whether a calculated field reaches itself through the fields it uses.
pub fn is_cyclic(&self, i: usize) -> bool {
let mut seen = vec![false; self.names.len()];
let mut stack: Vec<usize> = match self.compiled.get(i) {
Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(),
_ => vec![],
};
while let Some(j) = stack.pop() {
if j == i {
return true;
}
if seen.get(j).copied().unwrap_or(true) {
continue;
}
if let Some(s) = seen.get_mut(j) {
*s = true;
}
if let Some(Ok(c)) = self.compiled.get(j) {
stack.extend(c.refs.iter().filter_map(|r| self.index(r)));
}
}
false
}
/// The value of calculated field `i` for one report cell; `sum` gives a source field's sum
/// over that cell's records.
pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value {
let mut memo: Vec<Option<Value>> = vec![None; self.names.len()];
let mut active = vec![false; self.names.len()];
self.value_in(wb, i, sum, &mut memo, &mut active, 0)
}
fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option<Value>], active: &mut [bool], depth: usize) -> Value {
if let Some(Some(v)) = memo.get(i) {
return v.clone();
}
if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) {
return Value::Error(CellError::Ref);
}
let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) };
if let Some(a) = active.get_mut(i) {
*a = true;
}
let args: Vec<Value> = c
.refs
.iter()
.map(|r| match self.index(r) {
Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1),
None => sum(r),
})
.collect();
let v = eval(wb, c, &args);
if let Some(a) = active.get_mut(i) {
*a = false;
}
if let Some(m) = memo.get_mut(i) {
*m = Some(v.clone());
}
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(n: &str) -> Option<String> {
["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string())
}
#[test]
fn compiles_field_names() {
let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap();
assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]);
// A field named like a cell reference is still a field; functions stay functions.
let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap();
assert_eq!(c.refs, vec!["Q1"]);
let wb = Workbook::new();
assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0));
assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus"));
assert!(compile("=A1*2", &fields).is_err());
assert!(compile("='Nope'*2", &fields).is_err());
assert!(compile("=Sales*(", &fields).is_err());
assert!(compile("=", &fields).is_err());
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
}
#[test]
fn deep_and_cyclic_chains_are_bounded() {
// 300 calculated fields, each using the previous one, and a two-field cycle.
let mut pt = PivotTable::default();
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() });
for i in 1..300 {
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) });
}
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
let set = CalcSet::new(&pt, &["Sales".to_string()]);
let wb = Workbook::new();
let sum = |_: &str| Value::Number(10.0);
assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0));
assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0));
// Past the depth bound (the list itself is capped at MAX_CALC_FIELDS).
assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref));
assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields");
// A small cycle within the cap.
let mut small = PivotTable::default();
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
let set = CalcSet::new(&small, &["Sales".to_string()]);
assert!(set.is_cyclic(0));
assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref));
assert_eq!(set.source_refs(0), Vec::<String>::new());
}
}

View File

@ -52,6 +52,110 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() {
}
}
#[test]
fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
// #257: "Rp10,000.00" copied from Excel for the web / LibreOffice Calc pasted as text. Their
// clipboard HTML records the number (x:num, sdval); paste keeps it and shows the same text.
let mut s = s();
let text = "Rp10,000.00\tRp2,500.50\tLabel\n";
let html =
r#"<table><tr><td x:num="10000">Rp10,000.00</td><td sdval="2500.5" sdnum="1033;0;x"><font>Rp2,500.50</font></td><td>Label</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A1", "text": text, "html": html})).unwrap();
assert_eq!(v(&s, "A1"), Value::number(10000.0));
assert_eq!(v(&s, "B1"), Value::number(2500.5));
assert_eq!(v(&s, "C1"), Value::text("Label"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "Rp10,000.00");
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "Rp2,500.50");
// HTML that doesn't show the same text (left over from another copy) changes nothing; without
// HTML the text stays text, as typing it would in an en-US locale.
let stale = r#"<table><tr><td x:num="7">Rp7.00</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A3", "text": "Rp10,000.00", "html": stale})).unwrap();
s.execute("edit.paste", json!({"at": "A4", "text": "Rp10,000.00"})).unwrap();
assert_eq!(v(&s, "A3"), Value::text("Rp10,000.00"));
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
}
#[test]
fn editing_a_name_renames_it_in_formulas() {
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=1"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=Price*2"})).unwrap();
let err = s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=Sheet1!$A$1", "oldName": "Price"}));
assert!(err.is_err(), "renaming onto another existing name is refused");
s.execute("formulas.defineName", json!({"name": "Cost", "refersTo": "=Sheet1!$A$1", "comment": "unit", "oldName": "Price"})).unwrap();
let d = s.doc().unwrap();
let names: Vec<&str> = d.wb.names.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["Other", "Cost"]);
let b1 = d.wb.active().unwrap().cell(CellRef::parse("B1").unwrap()).and_then(|c| c.formula.clone()).unwrap();
assert_eq!(b1.text, "Cost*2");
assert_eq!(v(&s, "B1"), Value::number(42.0));
assert!(s.execute("formulas.defineName", json!({"name": "X", "oldName": "Missing"})).is_err());
}
#[test]
fn chart_recommendations_follow_the_data_shape() {
// #215: Recommended Charts lists chart types for the data; programmatic insert stays dialog-free.
let mut s = s();
let kinds = |s: &mut Session, range: &str| -> Vec<String> {
let r = s.execute("insert.chartRecommendations", json!({"range": range})).unwrap();
r.as_array().unwrap().iter().map(|x| format!("{}{}", x["type"].as_str().unwrap(), x["subtype"].as_str().unwrap())).collect()
};
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
assert_eq!(kinds(&mut s, "A1:B4"), ["column", "bar", "pie", "doughnut", "linemarkers"]);
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2], [2, 4.5], [3, 5.5], [4, 9]]})).unwrap();
assert_eq!(kinds(&mut s, "D1:E4").first().map(String::as_str), Some("scatter"), "two numeric columns: scatter first");
s.execute("range.setValues", json!({"range": "G1", "values": [["Q", "North", "South"], ["Q1", 1, -2], ["Q2", 3, 4]]})).unwrap();
let multi = kinds(&mut s, "G1:I3");
assert_eq!(multi.first().map(String::as_str), Some("column"));
assert!(!multi.iter().any(|k| k == "pie"), "several series: no pie");
assert!(s.execute("insert.chartRecommendations", json!({"range": "K1:K3"})).is_err(), "no numbers, no chart");
// Without a type the command inserts the first recommendation; with one, that type.
s.execute("insert.recommendedCharts", json!({"range": "D1:E4"})).unwrap();
s.execute("insert.recommendedCharts", json!({"range": "A1:B4", "type": "pie"})).unwrap();
let d = s.doc().unwrap();
let kinds: Vec<_> = d.wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_model::ChartKind::Scatter, gridcraft_model::ChartKind::Pie]);
assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog");
}
#[test]
fn chart_category_axis_from_blank_corner_and_select_data() {
// #287: numeric X values must be the category axis, not a series of their own.
let mut s = s();
let series = |s: &Session| -> Vec<(Option<String>, String)> {
let d = s.doc().unwrap();
d.wb.active().unwrap().charts.last().unwrap().series.iter().map(|x| (x.categories.clone(), x.values.clone())).collect()
};
// A blank top-left cell marks the first column as category labels, numbers or not.
s.execute("range.setValues", json!({"range": "A1", "values": [[null, "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap();
assert_eq!(series(&s), [(Some("Sheet1!$A$2:$A$4".to_string()), "Sheet1!$B$2:$B$4".to_string())]);
// With a "Year" header the year column is a series, as before; Select Data fixes it:
// Horizontal (Category) Axis Labels and Remove on the year series.
s.execute("range.setValues", json!({"range": "D1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
s.execute("insert.chart", json!({"range": "D1:E4", "type": "line"})).unwrap();
assert_eq!(series(&s).len(), 2);
assert!(s.execute("chart.selectData", json!({})).is_err(), "nothing to change");
assert!(s.execute("chart.selectData", json!({"categories": "not a range"})).is_err());
s.execute("chart.selectData", json!({"categories": "=D2:D4", "removeSeries": [0, 7]})).unwrap();
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
assert_eq!(data.categories, ["2020", "2021", "2022"]);
// The last series can't be removed; "" clears the labels.
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]);
}
#[test]
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
let mut s = s();
@ -452,6 +556,39 @@ fn fill_series() {
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
}
#[test]
fn ctrl_drag_fill_swaps_copy_and_series() {
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4"})).unwrap();
assert_eq!(v(&s, "A4"), Value::Number(7.0), "a plain drag copies a lone number");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "A2"), Value::Number(8.0));
assert_eq!(v(&s, "A4"), Value::Number(10.0));
// Upward too.
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B5", "target": "B3:B5", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "B3"), Value::Number(5.0));
// A two-number series, a list entry and text with a number are copied.
s.execute("range.setValues", json!({"range": "C1", "values": [[1], [3]]})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "C3"), Value::Number(1.0));
assert_eq!(v(&s, "C4"), Value::Number(3.0));
s.execute("cell.set", json!({"cell": "D1", "input": "Mon"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1", "target": "D1:D3", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "D3"), Value::from("Mon"));
s.execute("cell.set", json!({"cell": "E1", "input": "Item 9"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E1", "target": "E1:E2", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "E2"), Value::from("Item 9"));
// Plain text copies either way; an empty source clears.
s.execute("cell.set", json!({"cell": "F1", "input": "abc"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "F2"), Value::from("abc"));
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G3", "mode": "alternate"})).unwrap();
assert_eq!(v(&s, "G3"), Value::Empty);
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
@ -512,6 +649,63 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Turning the filter on with only the header row selected filters the data below it (#201).
#[test]
fn filter_on_a_selected_header_row_covers_the_data_below() {
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}),
)
.unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
let r = s.execute("data.filter", json!({})).unwrap();
assert_eq!(r["range"], "A1:B6");
let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap();
assert_eq!(r["hiddenRows"], 3);
// An explicit range is taken as given.
s.execute("data.filter", json!({})).unwrap();
let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap();
assert_eq!(r["range"], "A1:B1");
}
#[test]
fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
const PW: &str = "synthetic-pw-7319";
let mut s = s();
s.execute("automate.recordActions", json!({})).unwrap();
s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap();
// The password still protects the sheet; the refused attempt isn't journaled.
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
s.execute("review.unprotectSheet", json!({"password": PW})).unwrap();
s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap();
let journal = serde_json::to_string(&s.journal).unwrap();
assert!(!journal.contains(PW), "{journal}");
let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone());
assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true})));
assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({})));
// The recorded script is stored in the workbook, so it is saved with the file.
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap();
assert!(script.contains("review.protectSheet"), "{script}");
assert!(!script.contains(PW), "{script}");
// The journal still replays: protection without a password, then unprotection.
let mut r = self::s();
for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) {
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
if id == "review.protectSheet" {
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
}
}
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
}
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
@ -606,6 +800,27 @@ fn hidden_formulas_stay_hidden_on_protected_sheets() {
assert_eq!(r["results"][0]["value"], "7");
}
#[test]
fn case_sensitive_sort_puts_lowercase_first() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["b"], ["B"], ["Ab"], ["a"], ["aB"], ["A"]]})).unwrap();
s.execute("data.sort", json!({"range": "A1:A6", "header": false, "matchCase": true, "keys": [{"column": "A"}]})).unwrap();
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
assert_eq!(col, ["a", "A", "aB", "Ab", "b", "B"].map(Value::from));
}
#[test]
fn remove_duplicates_compares_shown_text() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Price"], ["2006-03-08", 1.01], ["2006-03-08", 1.02]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "mmm d, yyyy"})).unwrap();
s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0"})).unwrap();
let r = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "header": true})).unwrap();
assert_eq!(r["removed"], 0);
let r = s.execute("data.removeDuplicates", json!({"range": "B1:B3", "header": true})).unwrap();
assert_eq!(r["removed"], 1);
}
#[test]
fn formatting_and_styles() {
let mut s = s();
@ -1036,6 +1251,74 @@ fn every_command_survives_empty_params() {
}
}
fn fmt_of(s: &Session, a: &str) -> String {
let wb = &s.doc().unwrap().wb;
let sh = wb.active().unwrap();
wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
}
#[test]
fn checkboxes_keep_values_toggle_and_delete_in_two_passes() {
// Issue #196.
let mut s = s();
s.execute("cell.set", json!({"cell": "B1", "input": "hi"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
s.execute("insert.checkbox", json!({"range": "A1:B2"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Bool(false), "an empty cell starts unchecked");
assert_eq!(v(&s, "B1"), Value::from("hi"), "other values stay");
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Bool(true));
// Space over a selection toggles only checkbox cells; an emptied checkbox counts as FALSE.
s.execute("cell.set", json!({"cell": "A2", "input": ""})).unwrap();
s.execute("selection.set", json!({"range": "A2:C2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({})).unwrap();
assert_eq!(v(&s, "A2"), Value::Bool(true));
s.execute("selection.set", json!({"range": "C1"})).unwrap();
s.execute("cell.toggleCheckbox", json!({})).unwrap();
assert_eq!(v(&s, "C1"), Value::Bool(true), "a plain TRUE is not a checkbox");
// Delete: checked boxes are unchecked first, then the boxes go.
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Bool(false), Value::Bool(false)));
assert_eq!(fmt_of(&s, "A1"), "checkbox");
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Empty);
assert_ne!(fmt_of(&s, "A1"), "checkbox");
// Clear Formats removes the box and keeps the value (B2 was checked by the selection toggle).
s.execute("edit.clearFormats", json!({"range": "B2"})).unwrap();
assert_eq!(v(&s, "B2"), Value::Bool(true));
assert_ne!(fmt_of(&s, "B2"), "checkbox");
}
#[test]
fn checkboxes_survive_xlsx_as_cell_controls() {
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "0.5"})).unwrap();
reopen(&mut s);
assert_eq!(fmt_of(&s, "A1"), "checkbox");
assert_eq!(fmt_of(&s, "A2"), "checkbox");
assert_eq!(fmt_of(&s, "B1"), "General");
assert_eq!(v(&s, "A1"), Value::Bool(true));
assert_eq!(v(&s, "A2"), Value::Bool(false));
}
#[test]
fn file_options_reports_settings_and_asks_for_the_options_page() {
let mut s = Session::new();
// No workbook: nothing to report, but still no error.
assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null}));
s.take_ui_requests();
s.new_workbook();
s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap();
let before = s.active().unwrap().wb.clone();
let r = s.execute("file.options", json!({})).unwrap();
assert_eq!(r["calculation"]["mode"], "manual");
assert_eq!(r["calculation"]["iterative"], true);
assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options"));
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
@ -1633,3 +1916,117 @@ fn notes_comments_and_links_survive_a_json_save() {
assert_eq!(after.hyperlinks, before.hyperlinks);
assert_eq!(after.cells, before.cells);
}
/// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the
/// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235).
#[test]
fn icon_set_thresholds_from_files_pick_the_icon() {
use gridcraft_model::{CfRule, CfValueKind, CondFormat};
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[0], [10], [20], [30], [100]]})).unwrap();
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();
let icon = |s: &Session, a: &str| {
let wb = &s.doc().unwrap().wb;
crate::cf::CfCache::new().look(wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.icon).map(|(_, i)| i)
};
// Defaults: percent of the 0..100 span, so 30 is still the lowest icon.
assert_eq!([icon(&s, "A1"), icon(&s, "A4"), icon(&s, "A5")], [Some(0), Some(0), Some(2)]);
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
sh.cond_formats = vec![CondFormat {
ranges: vec![gridcraft_core::RangeRef::parse("A1:A5").unwrap()],
rule: CfRule::IconSet {
set: "3Arrows".into(),
thresholds: vec![CfValueKind::Percent(0.0), CfValueKind::Number(10.0), CfValueKind::Number(25.0)],
reverse: false,
show_value: true,
},
priority: 1,
stop_if_true: false,
}];
assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]);
}
#[test]
fn row_height_and_column_width_take_a_list_of_areas() {
// Issue #341: header drags on a multi-area selection size every selected line in one step.
let mut s = s();
s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap();
s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap();
let widths: Vec<f32> = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect();
let heights: Vec<f32> = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect();
for c in [1, 3, 4] {
assert_eq!(widths[c], 120.0, "column {c}");
}
for c in [0, 2, 5] {
assert_ne!(widths[c], 120.0, "column {c}");
}
for r in [1, 3, 4] {
assert_eq!(heights[r], 40.0, "row {r}");
}
for r in [0, 2, 5] {
assert_ne!(heights[r], 40.0, "row {r}");
}
assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err());
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing");
}
#[test]
fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() {
// Issue #348: Excel slides lined-up areas together; formulas travel as their values.
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}),
)
.unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap();
// Same rows, selected right to left: pasted left to right, B left out.
s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap();
let copied = s.execute("edit.copy", json!({})).unwrap();
assert_eq!(copied["text"], "a1\tc1\n1\t3\n");
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
assert_eq!(v(&s, "F1"), Value::text("a1"));
assert_eq!(v(&s, "G1"), Value::text("c1"));
assert_eq!(v(&s, "F2"), Value::Number(1.0));
assert_eq!(v(&s, "G2"), Value::Number(3.0));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none());
assert!(v(&s, "H1").is_empty());
assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2");
// The source is untouched.
assert_eq!(v(&s, "B1"), Value::text("b1"));
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some());
// Same columns: stacked, row 2 left out.
s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n");
s.execute("edit.paste", json!({"at": "F5"})).unwrap();
for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] {
assert_eq!(v(&s, cell), Value::text(want), "{cell}");
}
assert!(v(&s, "F8").is_empty());
// Whole columns are trimmed to the used rows like a single-area copy.
s.execute("edit.undo", json!({"steps": 2})).unwrap();
s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n");
}
#[test]
fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
s.execute("selection.set", json!({"range": "A1"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] {
s.execute("selection.set", json!({"range": range})).unwrap();
let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string();
assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}");
assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays");
}
// Paste Link can't point at a slid-together block.
s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
assert!(v(&s, "E1").is_empty());
}

View File

@ -0,0 +1,40 @@
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
use gridcraft_engine::Session;
use serde_json::json;
#[test]
fn selection_move_clamps_extreme_displacements() {
let mut session = Session::new();
session.new_workbook();
for (dr, dc, expected) in [
(i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)),
(0, i64::MAX, CellRef::new(1, MAX_COLS - 1)),
(i64::MAX, i64::MAX, CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)),
(i64::MIN, i64::MIN, CellRef::new(0, 0)),
(i64::MAX, i64::MIN, CellRef::new(MAX_ROWS - 1, 0)),
(i64::MIN, i64::MAX, CellRef::new(0, MAX_COLS - 1)),
(2, -1, CellRef::new(3, 0)),
] {
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap();
assert_eq!(session.doc().unwrap().selection.active, expected);
}
}
#[test]
fn selection_move_from_a_merged_cell_clamps_extreme_displacements() {
let mut session = Session::new();
session.new_workbook();
session.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
session.execute("home.mergeCells", json!({})).unwrap();
for (dr, dc, expected) in [
(i64::MAX, 0, CellRef::new(MAX_ROWS - 1, 1)),
(0, i64::MAX, CellRef::new(1, MAX_COLS - 1)),
(4_294_967_297, 0, CellRef::new(MAX_ROWS - 1, 1)),
(1, 0, CellRef::new(3, 1)),
] {
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
session.execute("selection.move", json!({"dr": dr, "dc": dc})).unwrap();
assert_eq!(session.doc().unwrap().selection.active, expected);
}
}

View File

@ -236,6 +236,13 @@ impl Parser {
{
return Ok(Expr::Unary(UnOp::Spill, Box::new(r)));
}
// Files write `@x` as `_xlfn.SINGLE(x)`.
if name == "SINGLE"
&& args.len() == 1
&& let Some(x) = args.pop()
{
return Ok(Expr::Unary(UnOp::At, Box::new(x)));
}
Ok(Expr::Call(name, args))
}
Tok::Sheet(a, b) => {

View File

@ -127,9 +127,9 @@ impl Criterion {
},
Operand::Text(pat) => match self.op {
Op::Eq | Op::Ne => {
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
let cell_text = match &v {
Value::Text(t) => Some(t.to_string()),
Value::Empty => Some(String::new()),
_ => None,
};
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
@ -298,6 +298,13 @@ mod tests {
assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
// Text patterns never match blank cells, but an empty string is text.
assert!(!m("*", Value::Empty));
assert!(m("*", Value::from("")));
assert!(m("<>*", Value::Empty));
assert!(!m("<>*", Value::from("")));
assert!(!m("?*", Value::Empty));
assert!(m("<>a*", Value::Empty));
}
#[test]

1005
crates/functions/src/ets.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
use gridcraft_core::date::{datetime_from_serial, days_in_month, serial_from_ymd};
use gridcraft_core::{CellError, DateSystem, Value};
use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num};
use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits};
use crate::{Arg, Ctx, FnSpec, VAR};
// ---------------------------------------------------------------------------------------------
@ -1099,6 +1099,270 @@ fn mduration(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(d / (1.0 + y / f))
}
// ---------------------------------------------------------------------------------------------
// Odd first and last coupon periods
// ---------------------------------------------------------------------------------------------
//
// An odd period is split into quasi-coupon periods: the regular coupon periods the security
// would have had, counted back from the first coupon (ODDF*) or forward from the last interest
// date (ODDL*). For quasi-period i, NL is its normal length, DC the days of it inside the odd
// period, A the accrued days of it before settlement and DSC the days of it from settlement on.
/// Most quasi-coupon periods an odd period may span (the date range allows ~32,400 quarters).
const MAX_QUASI: i64 = 40_000;
/// Normal length of the coupon period `[start, end]` under a basis (COUPDAYS semantics).
fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 {
match basis {
1 => end - start,
3 => 365.0 / freq,
_ => 360.0 / freq,
}
}
/// Days of `[from, to]` under a basis, or 0 when the span is empty.
fn span(from: f64, to: f64, basis: u32) -> R<f64> {
if to > from { day_count(from, to, basis) } else { Ok(0.0) }
}
/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share.
struct OddFirst {
/// Coupons after the first coupon up to maturity.
n: f64,
/// Whole quasi-coupon periods between settlement and the first coupon.
nq: f64,
/// DSC / NL of the quasi-period holding settlement.
frac: f64,
/// Σ DCi / NLi over the odd period.
dc: f64,
/// Σ Ai / NLi over the odd period.
acc: f64,
freq: f64,
}
fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R<OddFirst> {
if !(mat > first && first > settle && settle > issue) {
return Err(CellError::Num);
}
let anchor = ymd(first)?;
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
let step = (12.0 / freq) as i64;
let quasi = |k: i64| shift_months(anchor, -k * step, eom);
let (mut dc, mut acc) = (0.0, 0.0);
let (mut nq, mut frac) = (None, 0.0);
// Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k).
let mut k = 0;
loop {
if k >= MAX_QUASI {
return Err(CellError::Num);
}
let (end, start) = (quasi(k)?, quasi(k + 1)?);
let nl = normal_length(start, end, freq, basis);
if nl <= 0.0 {
return Err(CellError::Num);
}
let from = start.max(issue);
dc += span(from, end, basis)? / nl;
acc += span(from, settle.min(end), basis)? / nl;
if start <= settle && settle < end {
nq = Some(k as f64);
frac = span(settle, end, basis)? / nl;
}
if start <= issue {
break;
}
k += 1;
}
let nq = nq.ok_or(CellError::Num)?;
let n = coupons(first, mat, freq)?.num;
Ok(OddFirst { n, nq, frac, dc, acc, freq })
}
fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 {
let coupon = 100.0 * rate / t.freq;
let base = 1.0 + yld / t.freq;
let lead = t.nq + t.frac;
let mut p = red / base.powf(t.n + lead) + coupon * t.dc / base.powf(lead);
let mut k = 1.0;
while k <= t.n {
p += coupon / base.powf(k + lead);
k += 1.0;
}
p - coupon * t.acc
}
/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis].
fn odd_first_args(a: &[Arg]) -> R<(OddFirst, f64, f64, f64)> {
let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?);
let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?);
let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?);
if rate < 0.0 || red <= 0.0 {
return Err(CellError::Num);
}
Ok((odd_first(s, m, issue, first, f, basis)?, rate, x, red))
}
fn oddfprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (t, rate, yld, red) = odd_first_args(a)?;
if yld < 0.0 {
return Err(CellError::Num);
}
num_val(odd_first_price(&t, rate, yld, red))
}
fn oddfyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (t, rate, pr, red) = odd_first_args(a)?;
if pr <= 0.0 {
return Err(CellError::Num);
}
// The price falls as the yield rises: bracket the root, then bisect.
let g = |y: f64| odd_first_price(&t, rate, y, red) - pr;
let (mut lo, mut hi) = (-0.999_999 * t.freq, 1.0);
let mut guard = 0;
while g(hi) > 0.0 && guard < 60 {
hi *= 2.0;
guard += 1;
}
// NaN fails both comparisons.
if !(g(lo) >= 0.0 && g(hi) <= 0.0) {
return Err(CellError::Num);
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if g(mid) > 0.0 {
lo = mid;
} else {
hi = mid;
}
}
num_val(0.5 * (lo + hi))
}
/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period.
fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> {
if !(mat > settle && settle > last) {
return Err(CellError::Num);
}
let anchor = ymd(last)?;
let eom = anchor.2 == days_in_month(anchor.0, anchor.1);
let step = (12.0 / freq) as i64;
let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0);
// Walk forward from the last interest date: period k runs from quasi(k - 1) to quasi(k).
let mut start = last;
let mut k = 1;
loop {
if k > MAX_QUASI {
return Err(CellError::Num);
}
let end = shift_months(anchor, k * step, eom)?;
let nl = normal_length(start, end, freq, basis);
if nl <= 0.0 {
return Err(CellError::Num);
}
let stop = end.min(mat);
dc += span(start, stop, basis)? / nl;
acc += span(start, settle.min(stop), basis)? / nl;
dsc += span(settle.max(start), stop, basis)? / nl;
if end >= mat {
break;
}
start = end;
k += 1;
}
Ok((dc, acc, dsc))
}
/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis].
fn odd_last_args(a: &[Arg]) -> R<((f64, f64, f64), f64, f64, f64, f64)> {
let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?);
let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?);
let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?);
if rate < 0.0 || red <= 0.0 {
return Err(CellError::Num);
}
Ok((odd_last(s, m, last, f, basis)?, rate, x, red, f))
}
fn oddlprice(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a)?;
if yld < 0.0 {
return Err(CellError::Num);
}
let coupon = 100.0 * rate / f;
num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon)
}
fn oddlyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a)?;
if pr <= 0.0 {
return Err(CellError::Num);
}
let coupon = 100.0 * rate / f;
let paid = pr + acc * coupon;
if paid <= 0.0 || dsc <= 0.0 {
return Err(CellError::Num);
}
num_val((red + dc * coupon - paid) / paid * f / dsc)
}
// ---------------------------------------------------------------------------------------------
// Euro conversion
// ---------------------------------------------------------------------------------------------
/// The currencies EUROCONVERT documents: ISO code, the irrevocable conversion rate in units per
/// euro (Council Regulation (EC) No 2866/98 as amended) and the calculation precision in decimals.
const EURO_RATES: &[(&str, f64, i32)] = &[
("BEF", 40.3399, 0),
("LUF", 40.3399, 0),
("DEM", 1.95583, 2),
("ESP", 166.386, 0),
("FRF", 6.55957, 2),
("IEP", 0.787564, 2),
("ITL", 1936.27, 0),
("NLG", 2.20371, 2),
("ATS", 13.7603, 2),
("PTE", 200.482, 0),
("FIM", 5.94573, 2),
("GRD", 340.75, 0),
("SIT", 239.64, 2),
("EUR", 1.0, 2),
];
fn euro_currency(a: &[Arg], i: usize) -> R<(&'static str, f64, i32)> {
let code = text(a, i)?;
let code = code.trim();
EURO_RATES.iter().copied().find(|(c, _, _)| c.eq_ignore_ascii_case(code)).ok_or(CellError::Value)
}
/// EUROCONVERT(number, source, target, [full_precision], [triangulation_precision]).
fn euroconvert(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let x = num(a, 0)?;
let (src, src_rate, _) = euro_currency(a, 1)?;
let (tgt, tgt_rate, tgt_prec) = euro_currency(a, 2)?;
let full = opt_bool(a, 3, false)?;
let triangulation = if has(a, 4) {
let t = num(a, 4)?.trunc();
if t < 3.0 {
return Err(CellError::Value);
}
// Beyond 17 significant digits rounding changes nothing.
Some(t.min(17.0) as usize)
} else {
None
};
if src == tgt {
return num_val(x);
}
let mut euros = x / src_rate;
if src != "EUR"
&& let Some(t) = triangulation
{
euros = to_sig_digits(euros, t);
}
let out = euros * tgt_rate;
num_val(if full { out } else { round_half_away(out, tgt_prec) })
}
pub(crate) fn specs() -> Vec<FnSpec> {
vec![
f!(
@ -1440,6 +1704,56 @@ pub(crate) fn specs() -> Vec<FnSpec> {
"Modified duration of a security with periodic interest.",
mduration
),
f!(
"ODDFPRICE",
8,
9,
Financial,
S,
"ODDFPRICE(settlement, maturity, issue, first_coupon, rate, yld, redemption, frequency, [basis])",
"Price per 100 face value of a security with an odd first period.",
oddfprice
),
f!(
"ODDFYIELD",
8,
9,
Financial,
S,
"ODDFYIELD(settlement, maturity, issue, first_coupon, rate, pr, redemption, frequency, [basis])",
"Yield of a security with an odd first period.",
oddfyield
),
f!(
"ODDLPRICE",
7,
8,
Financial,
S,
"ODDLPRICE(settlement, maturity, last_interest, rate, yld, redemption, frequency, [basis])",
"Price per 100 face value of a security with an odd last period.",
oddlprice
),
f!(
"ODDLYIELD",
7,
8,
Financial,
S,
"ODDLYIELD(settlement, maturity, last_interest, rate, pr, redemption, frequency, [basis])",
"Yield of a security with an odd last period.",
oddlyield
),
f!(
"EUROCONVERT",
3,
5,
AddIn,
S,
"EUROCONVERT(number, source, target, [full_precision], [triangulation_precision])",
"Converts between the euro and the former euro member currencies at the fixed rates.",
euroconvert
),
]
}
@ -1599,6 +1913,102 @@ mod tests {
close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9);
}
fn number(v: Value) -> f64 {
match v {
Value::Number(x) => x,
other => panic!("expected a number, got {other:?}"),
}
}
#[test]
fn odd_periods() {
// The worked examples on Microsoft's function pages (results as printed there).
let first = |x: f64, rest: &[f64]| {
let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)];
a.extend(rest.iter().map(|v| n(*v)));
a
};
close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6);
close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005);
let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)];
close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88);
let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)];
close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005);
// Yield → price → yield, for a short and a long odd first period and a long odd last period.
for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] {
let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)];
let p = number(ev("ODDFPRICE", args(0.05)));
close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9);
}
let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)];
let p = number(ev("ODDLPRICE", last(0.07)));
close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9);
// Argument checks: frequency, basis, date order, negative yield, non-positive price.
is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num);
is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num);
is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num);
is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num);
is_err(
ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]),
CellError::Num,
);
is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num);
is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num);
is_err(
ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()),
CellError::Value,
);
}
#[test]
fn odd_periods_survive_hostile_input() {
// Quarterly quasi-periods across the whole date range, huge rates and yields: a value or
// an error, never a panic or a hang.
let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)];
for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] {
let _ = ev("ODDFPRICE", wide(x, y));
let _ = ev("ODDFYIELD", wide(x, y));
}
// The long walk itself works: settled early in an odd first period of ~32,000 quarters,
// a small yield round-trips.
let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)];
let p = number(ev("ODDFPRICE", long(0.0001)));
close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9);
let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)];
for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] {
let _ = ev("ODDLPRICE", last(x, y));
let _ = ev("ODDLYIELD", last(x, y));
}
is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num);
}
#[test]
fn euroconvert() {
// The examples on Microsoft's EUROCONVERT page.
let ec = |x: f64, s: &str, tg: &str, rest: Vec<Arg>| {
let mut a = vec![n(x), t(s), t(tg)];
a.extend(rest);
ev("EUROCONVERT", a)
};
close(ec(1.2, "DEM", "EUR", vec![]), 0.61);
close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152);
close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15);
close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616);
// Euro to a member currency rounds to the target's calculation precision (lira: 0).
close(ec(10.0, "eur", "ITL", vec![]), 19363.0);
close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564);
// Same currency returns the number untouched.
close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456);
// Unknown codes and a triangulation precision below 3 are #VALUE!.
is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value);
is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value);
// Hostile magnitudes give a number or #NUM!, never a panic.
is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num);
close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957);
}
#[test]
fn lifting() {
let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]);

View File

@ -65,6 +65,7 @@ pub(crate) mod database;
pub(crate) mod date;
pub(crate) mod dynamic;
pub(crate) mod engineering;
pub(crate) mod ets;
pub(crate) mod financial;
pub(crate) mod info;
pub(crate) mod lift;
@ -122,6 +123,8 @@ pub enum Category {
Compatibility,
Web,
Cube,
/// Excel's "Add-in and Automation" category (EUROCONVERT).
AddIn,
}
pub struct FnSpec {
@ -168,6 +171,7 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
for list in [
math::specs(),
stat::specs(),
ets::specs(),
text::specs(),
date::specs(),
lookup::specs(),

View File

@ -86,6 +86,17 @@ fn sum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(numbers(a)?.iter().sum())
}
/// PERCENTOF(data_subset, data_all) = SUM(data_subset) / SUM(data_all).
fn percentof(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let part = a.get(..1).ok_or(CellError::Value)?;
let all = a.get(1..2).ok_or(CellError::Value)?;
let (part, all): (f64, f64) = (numbers(part)?.iter().sum(), numbers(all)?.iter().sum());
if all == 0.0 {
return Err(CellError::Div0);
}
num_val(part / all)
}
fn sumsq(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(numbers(a)?.iter().map(|x| x * x).sum())
}
@ -1050,6 +1061,16 @@ fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
pub(crate) fn specs() -> Vec<FnSpec> {
vec![
f!("SUM", 1, VAR, MathTrig, A, "SUM(number1, [number2], ...)", "Adds all the numbers in the arguments.", sum),
f!(
"PERCENTOF",
2,
2,
MathTrig,
A,
"PERCENTOF(data_subset, data_all)",
"The sum of a subset as a fraction of the sum of all the values.",
percentof
),
f!("SUMSQ", 1, VAR, MathTrig, A, "SUMSQ(number1, [number2], ...)", "Adds the squares of the numbers.", sumsq),
f!(
"SUMPRODUCT",
@ -1278,6 +1299,18 @@ mod tests {
ev(name, vec![n(x), n(y)])
}
#[test]
fn percentof() {
// SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM.
let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]);
let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]);
close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4);
close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125);
is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0);
is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA);
is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value);
}
#[test]
fn sums() {
close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0);

View File

@ -403,6 +403,88 @@ fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
}
// ---------------------------------------------------------------------------------------------
// BAHTTEXT
const THAI_DIGITS: [&str; 10] = ["ศูนย์", "หนึ่ง", "สอง", "สาม", "สี่", "ห้า", "หก", "เจ็ด", "แปด", "เก้า"];
/// Units, tens, hundreds, thousands, ten thousands, hundred thousands.
const THAI_PLACES: [&str; 6] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน"];
/// Thai words for a group of up to six decimal digits (most significant first); empty for zero.
/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด.
fn thai_group(digits: &[u8]) -> String {
let n = digits.len();
let higher = digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0);
let mut out = String::new();
for (i, &d) in digits.iter().enumerate() {
let place = n - 1 - i;
match (place, d) {
(_, 0) => {}
(0, 1) if higher => out.push_str("เอ็ด"),
(1, 1) => out.push_str("สิบ"),
(1, 2) => out.push_str("ยี่สิบ"),
_ => {
out.push_str(THAI_DIGITS.get(d as usize).copied().unwrap_or_default());
out.push_str(THAI_PLACES.get(place).copied().unwrap_or_default());
}
}
}
out
}
/// Thai words for a whole number written in decimal digits: groups of six digits, each but the
/// last followed by ล้าน (so 10^12 is หนึ่งล้านล้าน).
fn thai_number(digits: &str) -> String {
let d: Vec<u8> = digits.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').skip_while(|x| *x == 0).collect();
if d.is_empty() {
return "ศูนย์".to_string();
}
let first = match d.len() % 6 {
0 => 6,
r => r,
};
let mut out = String::new();
let mut start = 0;
let mut len = first;
while let Some(group) = d.get(start..start + len) {
out.push_str(&thai_group(group));
start += len;
len = 6;
if start < d.len() {
out.push_str("ล้าน");
}
}
out
}
/// BAHTTEXT(number): the amount in Thai words with บาท, rounded to satang (สตางค์); a whole
/// number of baht ends in ถ้วน and a negative amount starts with ลบ.
fn bahttext(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let x = round_half_away(num(a, 0)?, 2);
if !x.is_finite() {
return Err(CellError::Num);
}
let fixed = format!("{:.2}", x.abs());
let (whole, frac) = fixed.split_once('.').unwrap_or((fixed.as_str(), "00"));
let baht = whole.bytes().any(|b| b != b'0');
let satang = frac.bytes().any(|b| b != b'0');
let mut out = String::new();
if x < 0.0 && (baht || satang) {
out.push_str("ลบ");
}
if baht || !satang {
out.push_str(&thai_number(whole));
out.push_str("บาท");
}
if satang {
out.push_str(&thai_number(frac));
out.push_str("สตางค์");
} else {
out.push_str("ถ้วน");
}
text_val(out)
}
fn t_fn(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
match scalar(arg(a, 0)?) {
Value::Text(t) => Ok(Value::Text(t)),
@ -762,6 +844,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
fixed
),
f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar),
f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext),
f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn),
f!(
"TEXTBEFORE",
@ -856,6 +939,30 @@ mod tests {
use crate::util::testutil::*;
#[test]
fn bahttext() {
let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]);
// Microsoft's example.
is_text(bt(1234.0), "หนึ่งพันสองร้อยสามสิบสี่บาทถ้วน");
// Tens, เอ็ด, satang, millions and beyond, sign and rounding to satang.
is_text(bt(0.0), "ศูนย์บาทถ้วน");
is_text(bt(21.25), "ยี่สิบเอ็ดบาทยี่สิบห้าสตางค์");
is_text(bt(0.5), "ห้าสิบสตางค์");
is_text(bt(101.0), "หนึ่งร้อยเอ็ดบาทถ้วน");
is_text(bt(1_000_000.0), "หนึ่งล้านบาทถ้วน");
is_text(bt(11_000_000.0), "สิบเอ็ดล้านบาทถ้วน");
is_text(bt(2_000_005_000_000.0), "สองล้านห้าล้านบาทถ้วน");
is_text(bt(1e12), "หนึ่งล้านล้านบาทถ้วน");
is_text(bt(-15.5), "ลบสิบห้าบาทห้าสิบสตางค์");
is_text(bt(1.999), "สองบาทถ้วน");
is_text(bt(0.005), "หนึ่งสตางค์");
is_text(bt(-0.001), "ศูนย์บาทถ้วน");
is_err(ev("BAHTTEXT", vec![t("abc")]), CellError::Value);
// Huge numbers give text (or #NUM! where rounding overflows), never a panic.
assert!(matches!(bt(-1e300), Value::Text(_)));
assert!(matches!(bt(f64::MAX), Value::Text(_) | Value::Error(CellError::Num)));
}
#[test]
fn concat_family() {
is_text(ev("CONCATENATE", vec![t("a"), n(1.5), b(true)]), "a1.5TRUE");

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
gridcraft-core = { workspace = true }
sha2 = { workspace = true }
gridcraft-formula = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }

View File

@ -40,6 +40,7 @@ mod tests {
threaded: true,
resolved: false,
visible: false,
dates: vec![],
},
);
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });

View File

@ -196,6 +196,18 @@ pub enum CfRule {
},
}
/// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5.
pub fn icon_count(set: &str) -> usize {
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize
}
/// Excel's default icon-set thresholds: one per icon, the first being the lower bound,
/// then even percent steps (`0, 33, 67` for three icons).
pub fn default_icon_thresholds(set: &str) -> Vec<CfValueKind> {
let k = icon_count(set);
(0..k).map(|i| CfValueKind::Percent((i as f64 * 100.0 / k as f64).round())).collect()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CondFormat {
pub ranges: Vec<RangeRef>,
@ -279,6 +291,10 @@ pub struct Comment {
pub threaded: bool,
pub resolved: bool,
pub visible: bool,
/// Threaded comments: when the thread (first entry) and each reply were written, ISO 8601 as
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dates: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -390,6 +406,10 @@ pub struct Chart {
/// Source range when created from a selection (for Switch Row/Column).
pub source: Option<String>,
pub by_rows: bool,
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
/// whenever the PivotTable is refreshed or its layout changes.
#[serde(default)]
pub pivot: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -549,6 +569,24 @@ pub struct SheetProtection {
pub autofilter: bool,
}
/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a
/// password (XLSX `<protectedRange>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EditRange {
pub title: String,
pub ranges: Vec<RangeRef>,
pub password: Option<crate::PasswordHash>,
/// Windows permissions (`securityDescriptor`), kept as read from the file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security_descriptor: Option<String>,
}
impl EditRange {
pub fn contains(&self, c: CellRef) -> bool {
self.ranges.iter().any(|r| r.contains(c))
}
}
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
/// convenience, not security).
pub fn password_hash(pw: &str) -> u64 {
@ -699,6 +737,18 @@ pub struct PivotTable {
pub show_headers: bool,
/// Area written by the last refresh.
pub last_range: Option<RangeRef>,
/// Calculated fields, usable in the Values area by name.
pub calculated_fields: Vec<PivotCalcField>,
}
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PivotCalcField {
pub name: String,
/// Formula text, starting with `=`.
pub formula: String,
}
impl Default for PivotTable {
@ -720,6 +770,7 @@ impl Default for PivotTable {
style: "PivotStyleLight16".into(),
show_headers: true,
last_range: None,
calculated_fields: vec![],
}
}
}

View File

@ -8,6 +8,7 @@ mod b64;
pub mod cell;
mod cellmap;
pub mod features;
pub mod password;
pub mod sheet;
pub mod store;
pub mod style;
@ -15,6 +16,7 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use password::PasswordHash;
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
pub use store::CellStore;
pub use style::{

View File

@ -0,0 +1,133 @@
//! Excel-compatible password hashes for protection (ECMA-376 Part 1 §18.2.29 and §18.3.1.66
//! attribute sets): the legacy 16-bit hash (`password="CC1A"`) and the iterated, salted hash
//! (`algorithmName`, `hashValue`, `saltValue`, `spinCount`). Protection is a convenience, not
//! security, but verification really checks the password: an unsupported variant is refused,
//! never accepted.
use serde::{Deserialize, Serialize};
use sha2::Digest;
/// Excel's default spin count for new passwords.
pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
/// Largest spin count accepted when verifying (the documented maximum); a file asking for more
/// would only make verification hang.
pub const MAX_SPIN_COUNT: u32 = 10_000_000;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PasswordHash {
/// The legacy 16-bit hash.
Legacy(u16),
/// An iterated, salted hash as stored in the file: algorithm name, base64 hash and salt.
Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 },
}
impl PasswordHash {
/// A SHA-512 hash with Excel's default spin count, for a new password.
pub fn new_sha512(password: &str, salt: &[u8]) -> PasswordHash {
let hash = iterated::<sha2::Sha512>(password, salt, DEFAULT_SPIN_COUNT);
PasswordHash::Iterated {
algorithm: "SHA-512".into(),
hash: crate::b64::encode(&hash),
salt: crate::b64::encode(salt),
spin_count: DEFAULT_SPIN_COUNT,
}
}
/// Whether `password` matches. `Err` when the hash can't be checked (unsupported algorithm,
/// broken base64, spin count over [`MAX_SPIN_COUNT`]).
pub fn verify(&self, password: &str) -> Result<bool, String> {
match self {
PasswordHash::Legacy(h) => Ok(legacy_hash(password) == *h),
PasswordHash::Iterated { algorithm, hash, salt, spin_count } => {
if algorithm.is_empty() {
return Err("the stored password hash is damaged".into());
}
if *spin_count > MAX_SPIN_COUNT {
return Err(format!("the password uses a spin count of {spin_count}, more than the {MAX_SPIN_COUNT} allowed"));
}
let (Some(want), Some(salt)) = (crate::b64::decode(hash), crate::b64::decode(salt)) else {
return Err("the stored password hash is damaged".into());
};
let got = match algorithm.to_ascii_uppercase().replace('_', "-").as_str() {
"SHA-512" => iterated::<sha2::Sha512>(password, &salt, *spin_count),
"SHA-384" => iterated::<sha2::Sha384>(password, &salt, *spin_count),
"SHA-256" => iterated::<sha2::Sha256>(password, &salt, *spin_count),
_ => return Err(format!("passwords hashed with {algorithm} aren't supported")),
};
Ok(got == want)
}
}
}
}
/// A 16-byte salt derived from `seed` (callers mix in what varies: names, a counter, the clock).
pub fn salt_from(seed: &[u8]) -> [u8; 16] {
let h = sha2::Sha512::digest(seed);
let mut out = [0u8; 16];
for (o, b) in out.iter_mut().zip(h.iter()) {
*o = *b;
}
out
}
/// The legacy 16-bit password hash: each UTF-16 unit contributes its low byte (its high byte
/// when the low one is zero), folded in from the last character with a 15-bit rotation, then
/// the length and the constant `0xCE4B`.
pub fn legacy_hash(password: &str) -> u16 {
let bytes: Vec<u16> = password.encode_utf16().map(|u| if u & 0xFF != 0 { u & 0xFF } else { u >> 8 }).collect();
let rotate = |v: u16| ((v >> 14) & 1) | ((v << 1) & 0x7FFF);
let mut h: u16 = 0;
for b in bytes.iter().rev() {
h = rotate(h) ^ b;
}
h = rotate(h) ^ (bytes.len() as u16);
h ^ 0xCE4B
}
/// H0 = H(salt + UTF-16LE password); Hn = H(Hn-1 + n as 32-bit little-endian), `spin` times.
fn iterated<D: Digest>(password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
let mut d = D::new();
d.update(salt);
for u in password.encode_utf16() {
d.update(u.to_le_bytes());
}
let mut h = d.finalize().to_vec();
for i in 0..spin {
let mut d = D::new();
d.update(&h);
d.update(i.to_le_bytes());
h = d.finalize().to_vec();
}
h
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_hash_matches_the_published_example() {
// The hash Excel writes for the password "password" (`password="83AF"`).
assert_eq!(legacy_hash("password"), 0x83AF);
assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap());
assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap());
assert_eq!(legacy_hash(""), 0xCE4B);
}
#[test]
fn iterated_hash_verifies_and_refuses_what_it_cannot_check() {
let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef");
assert!(h.verify("s3cret").unwrap());
assert!(!h.verify("s3cret ").unwrap());
assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512"));
let PasswordHash::Iterated { hash, salt, .. } = h else { return };
let other =
|algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count };
assert!(other("SHA-1", 10).verify("s3cret").is_err());
assert!(other("MD5", 10).verify("s3cret").is_err());
assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err());
assert!(!other("SHA-256", 2).verify("s3cret").unwrap());
let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 };
assert!(broken.verify("s3cret").is_err());
}
}

View File

@ -64,6 +64,9 @@ pub struct Sheet {
#[serde(default)]
pub pivots: Vec<PivotTable>,
pub protection: Option<SheetProtection>,
/// Allow Edit Ranges (meaningful while the sheet is protected).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edit_ranges: Vec<crate::EditRange>,
pub print: PrintSettings,
/// Last selection and scroll position, saved with the file.
pub view_active: CellRef,
@ -108,6 +111,7 @@ impl Sheet {
sparklines: vec![],
pivots: vec![],
protection: None,
edit_ranges: vec![],
print: PrintSettings::default(),
view_active: CellRef::default(),
view_top_left: CellRef::default(),

View File

@ -9,17 +9,70 @@ pub(crate) const MAX_DECIMALS: usize = 400;
/// The 15 significant digits of a positive finite number and the power of ten of the first
/// digit: `a ≈ d0.d1d2… × 10^exp`.
///
/// The value is rounded to the nearest 15-digit decimal. An exact tie at the 16th digit (the exact
/// decimal expansion is 15 digits, a 5, then only zeros, as for 1234567890123455 or
/// 123456789012345.5) goes toward zero, as Excel does: `1234567890123455` shows `1234567890123450`,
/// not `…460`. Excel observed (Windows 16.0.20430, TEXT()).
pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) {
let mut digits = [0u8; 15];
if !a.is_finite() || a <= 0.0 {
return (digits, 0);
}
let s = format!("{:.14e}", a);
let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
for (slot, b) in digits.iter_mut().zip(mant.bytes().filter(u8::is_ascii_digit)) {
*slot = b - b'0';
// 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds
// down, 6 or more up), so one format serves almost every number.
let (d16, exp) = significant_digits(a, 16);
for (slot, d) in digits.iter_mut().zip(&d16) {
*slot = *d;
}
(digits, exp.parse().unwrap_or(0))
match d16.get(15).copied().unwrap_or(0) {
0..=4 => (digits, exp),
5 if is_exact_tie(a) => (digits, exp),
5 => {
// Not a tie: round the exact value (the 16-digit rounding above may be a double rounding).
let (d15, exp15) = significant_digits(a, 15);
let mut out = [0u8; 15];
for (slot, d) in out.iter_mut().zip(&d15) {
*slot = *d;
}
(out, exp15)
}
_ => {
let mut exp = exp;
let mut carry = true;
for d in digits.iter_mut().rev() {
if *d == 9 {
*d = 0;
} else {
*d += 1;
carry = false;
break;
}
}
if carry {
digits = [0; 15];
digits[0] = 1;
exp += 1;
}
(digits, exp)
}
}
}
/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of
/// ten of the first.
fn significant_digits(a: f64, n: usize) -> (Vec<u8>, i32) {
let s = format!("{:.*e}", n.saturating_sub(1), a);
let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
(mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0))
}
/// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after.
fn is_exact_tie(a: f64) -> bool {
let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0));
// A cheap screen first (30 digits), then the exact expansion: a double has at most 767
// significant digits and Rust prints every one of them.
is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0)
}
/// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away

View File

@ -102,7 +102,9 @@ impl NumberFormat {
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
pub fn parse(code: &str) -> NumberFormat {
let trimmed = code.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") {
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
// value displays as General.
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" {
return NumberFormat {
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
};

View File

@ -421,3 +421,73 @@ fn fuzz_no_panic() {
}
}
}
// ---- 15 significant digits: an exact tie at the 16th digit goes toward zero ----
// Windows Excel 16.0.20430 TEXT() over the exact doubles below. An odd 15th digit is where
// round-half-even and toward-zero differ; an even one is where they agree.
#[test]
fn an_exact_tie_at_the_sixteenth_digit_goes_toward_zero() {
for (v, whole) in [
(1234567890123405.0, "1234567890123400"),
(1234567890123415.0, "1234567890123410"),
(1234567890123425.0, "1234567890123420"),
(1234567890123435.0, "1234567890123430"),
(1234567890123455.0, "1234567890123450"),
(1234567890123475.0, "1234567890123470"),
(1234567890123495.0, "1234567890123490"),
(6631679258820775.0, "6631679258820770"),
(8052991635850695.0, "8052991635850690"),
] {
assert_eq!(f("0", v), whole, "0 over {v}");
assert_eq!(f("0.00", v), format!("{whole}.00"), "0.00 over {v}");
assert_eq!(f("0", -v), format!("-{whole}"), "0 over -{v}");
}
assert_eq!(f("#,##0", 1234567890123455.0), "1,234,567,890,123,450");
assert_eq!(f("#,##0", -3267527853107975.0), "-3,267,527,853,107,970");
// A fraction is a tie as well, when it is exact in binary.
assert_eq!(f("0", 123456789012345.5), "123456789012345");
assert_eq!(f("0.00", 123456789012345.5), "123456789012345.00");
// Excel observed: no carry after fifteen 9s.
assert_eq!(f("0", 999999999999999.5), "999999999999999");
assert_eq!(f("0", -999999999999999.5), "-999999999999999");
assert_eq!(f("0", 4999999999999995.0), "4999999999999990");
assert_eq!(f("#,##0", -4999999999999995.0), "-4,999,999,999,999,990");
}
#[test]
fn only_exact_ties_go_toward_zero() {
// Just above and just below a tie round to the nearer 15-digit value (Excel observed).
assert_eq!(f("0", 1234567890123455.0 + 0.25), "1234567890123460");
assert_eq!(f("0", 1234567890123455.5), "1234567890123460");
assert_eq!(f("0", 1234567890123455.0 - 0.25), "1234567890123450");
// 16 digits that are not a tie, and the ordinary decimal cases, are untouched.
assert_eq!(f("0", 1234567890123456.0), "1234567890123460");
assert_eq!(f("0", 1234567890123454.0), "1234567890123450");
assert_eq!(f("0.00", 2.675), "2.68");
assert_eq!(f("0.00", 1.005), "1.01");
assert_eq!(f("0.0", 0.285), "0.3");
assert_eq!(f("0.0", 99999999999999.95), "100000000000000.0");
// Ten digits and a half are not a 16th-digit tie.
assert_eq!(f("0", 0.5), "1");
assert_eq!(f("0", 2.5), "3");
}
#[test]
fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() {
use crate::decimal::decimal_digits;
let (d, e) = decimal_digits(1234567890123455.0);
assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 15));
let (d, e) = decimal_digits(1234567890123456.0);
assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 6], 15));
let (d, e) = decimal_digits(123456789012345.5);
assert_eq!((d, e), ([1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5], 14));
// Excel observed: a tie after fifteen 9s does not carry (999999999999999.5 shows 999999999999999).
let (d, e) = decimal_digits(999999999999999.5);
assert_eq!((d, e), ([9; 15], 14));
let (d, e) = decimal_digits(4999999999999995.0);
assert_eq!((d, e), ([4, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9], 15));
// A 16th digit above 5 carries as usual.
let (d, e) = decimal_digits(999999999999999.6);
assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15));
}

View File

@ -16,6 +16,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
skrifa = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }

View File

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

View File

@ -0,0 +1,828 @@
//! File tab Backstage view (#174): a full-window page that replaces the ribbon, formula bar, grid
//! and sheet tabs until Esc or the back arrow returns to the sheet.
//!
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
//! Options); Save and Close act at once. Every action runs an existing command through
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched
//! until the user picks one.
use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::i18n::{self, Language};
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
/// Width of the navigation column.
const NAV_W: f32 = 196.0;
/// Width of the content column (pages are left-aligned like a document, not stretched).
const CONTENT_W: f32 = 760.0;
/// A Backstage page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
Home,
New,
Open,
Info,
SaveAs,
Print,
Export,
Options,
}
impl Page {
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options];
/// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take.
pub fn id(self) -> &'static str {
match self {
Page::Home => "home",
Page::New => "new",
Page::Open => "open",
Page::Info => "info",
Page::SaveAs => "saveAs",
Page::Print => "print",
Page::Export => "export",
Page::Options => "options",
}
}
pub fn parse(id: &str) -> Option<Page> {
let id = id.trim();
Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
}
/// English label (navigation entry and page heading); translated when drawn.
pub fn label(self) -> &'static str {
match self {
Page::Home => "Home",
Page::New => "New",
Page::Open => "Open",
Page::Info => "Info",
Page::SaveAs => "Save As",
Page::Print => "Print",
Page::Export => "Export",
Page::Options => "Options",
}
}
fn icon(self) -> Icon {
match self {
Page::Home => Icon::Home,
Page::New => Icon::NewFile,
Page::Open => Icon::OpenFolder,
Page::Info => Icon::Info,
Page::SaveAs => Icon::Save,
Page::Print => Icon::Print,
Page::Export => Icon::Share,
Page::Options => Icon::Settings,
}
}
}
/// Document properties shown on the Info page: (`file.properties` key, label).
const PROPERTIES: [(&str, &str); 6] = [
("title", "Title"),
("subject", "Subject"),
("author", "Author"),
("company", "Company"),
("keywords", "Keywords"),
("description", "Description"),
];
/// The open Backstage view.
#[derive(Clone, Debug)]
pub struct Backstage {
pub page: Page,
/// Info: property drafts while a field is being edited (committed when it loses focus).
props: [String; 6],
/// Print: the whole workbook rather than the active sheet.
pub print_all: bool,
/// Print: fit each sheet on one page.
pub fit_to_page: bool,
/// Print: the previewed page (0-based).
preview_page: usize,
/// Print: the preview for (document, revision, whole workbook, fit to page).
preview: Option<((u64, u64, bool, bool), Json)>,
}
impl Backstage {
pub fn new(page: Page) -> Backstage {
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None }
}
}
/// What a click asked for, applied after drawing.
#[derive(Default)]
struct Outcome {
close: bool,
page: Option<Page>,
}
/// Draws the Backstage view in place of the ribbon, formula bar, grid and sheet tabs.
pub fn show(app: &mut SheetApp, ui: &mut Ui) {
let Some(mut bs) = app.backstage.take() else { return };
let ctx = ui.ctx().clone();
// Esc inside a text field leaves the field; it closes the view only from outside one.
let typing = ctx.text_edit_focused();
let t = Tokens::get(&ctx);
let lang = app.ui.language;
let mut out = Outcome::default();
let (nav_bg, nav_text, nav_sel) =
if t.dark { (t.accent_soft, t.text, Color32::from_white_alpha(30)) } else { (t.accent, Color32::WHITE, Color32::from_black_alpha(46)) };
egui::Panel::left("backstage_nav")
.exact_size(NAV_W)
.resizable(false)
.frame(egui::Frame::NONE.fill(nav_bg).inner_margin(egui::Margin { left: 0, right: 0, top: 10, bottom: 10 }))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
if nav_item(ui, Icon::Back, lang.tr("Back"), false, nav_text, nav_sel).clicked() {
out.close = true;
}
ui.add_space(10.0);
for page in [Page::Home, Page::New, Page::Open] {
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
out.page = Some(page);
}
}
nav_separator(ui, nav_text);
if nav_item(ui, Page::Info.icon(), lang.tr("Info"), bs.page == Page::Info, nav_text, nav_sel).clicked() {
out.page = Some(Page::Info);
}
if nav_item(ui, Icon::Save, lang.tr("Save"), false, nav_text, nav_sel).clicked() {
save(app, &mut out);
}
for page in [Page::SaveAs, Page::Print, Page::Export] {
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
out.page = Some(page);
}
}
if nav_item(ui, Icon::Close, lang.tr("Close"), false, nav_text, nav_sel).clicked() {
// A cell still being edited is part of the workbook; Close keeps the unsaved prompt.
if app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.close", json!({}));
out.close = true;
}
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() {
out.page = Some(Page::Options);
}
});
});
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon)).show(ui, |ui| {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 32, top: 26, bottom: 32 }).show(ui, |ui| {
ui.set_max_width(CONTENT_W);
ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text));
ui.add_space(18.0);
match bs.page {
Page::Home => home(app, ui, &mut out),
Page::New => new_page(app, ui, &mut out),
Page::Open => open_page(app, ui, &mut out),
Page::Info => info(app, ui, &mut bs),
Page::SaveAs => save_as(app, ui, &mut out),
Page::Print => print(app, ui, &mut bs, &mut out),
Page::Export => export(app, ui, &mut out),
Page::Options => options(app, ui),
}
});
});
});
if !typing
&& app.dialog.is_none()
&& app.message.is_none()
&& !egui::Popup::is_any_open(&ctx)
&& ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
{
out.close = true;
}
if let Some(page) = out.page {
bs.page = page;
}
// A command run from a page may itself have opened (or replaced) the view; keep that.
if out.close {
app.backstage = None;
} else if app.backstage.is_none() {
app.backstage = Some(bs);
}
}
/// Save: saves a workbook that has a file and returns to the sheet; a new workbook goes to the
/// Save As page first, like Excel's.
fn save(app: &mut SheetApp, out: &mut Outcome) {
if !app.commit_edit(0, 0, false, false) {
return;
}
if app.session.active().is_some_and(|d| d.path.is_some()) {
app.run_or_alert("file.save", json!({}));
out.close = true;
} else {
out.page = Some(Page::SaveAs);
}
}
// ---------------------------------------------------------------- navigation
fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
let p = ui.painter();
if selected {
p.rect_filled(rect, 0.0, sel);
p.rect_filled(Rect::from_min_size(rect.min, vec2(4.0, rect.height())), 0.0, text);
} else if resp.hovered() {
p.rect_filled(rect, 0.0, sel.gamma_multiply(0.55));
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(17.0, 17.0)), icon, text);
p.text(pos2(rect.left() + 50.0, rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(14.0), text);
resp
}
fn nav_separator(ui: &mut Ui, text: Color32) {
let (rect, _) = ui.allocate_exact_size(vec2(NAV_W, 17.0), Sense::hover());
let y = rect.center().y;
ui.painter().line_segment([pos2(rect.left() + 20.0, y), pos2(rect.right() - 20.0, y)], Stroke::new(1.0, text.gamma_multiply(0.35)));
}
// ---------------------------------------------------------------- shared pieces
fn section(ui: &mut Ui, lang: Language, title: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(6.0);
ui.label(RichText::new(lang.tr(title)).font(theme::ui_bold(15.0)).color(t.text));
ui.add_space(8.0);
}
fn dim(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(text).font(theme::ui_font(12.5)).color(t.text_dim));
}
/// A new-workbook tile: a small sheet drawn in code over its name.
fn tile(ui: &mut Ui, label: &str, sample: Option<usize>) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
let p = ui.painter();
let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0));
p.rect_filled(thumb, 4.0, t.grid_bg);
let edge = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.ribbon_border) };
let inner = thumb.shrink(10.0);
let (rows, cols) = (7, 4);
let (rh, cw) = (inner.height() / rows as f32, inner.width() / cols as f32);
if let Some(i) = sample {
// A header band and a few "values" so each sample reads differently from a blank book.
p.rect_filled(Rect::from_min_size(inner.min, vec2(inner.width(), rh)), 0.0, t.accent_soft);
for r in 1..rows {
let len = ((r * (i + 2) * 7 + i * 3) % 10) as f32 / 10.0;
let bar = Rect::from_min_size(
pos2(inner.left() + cw + 3.0, inner.top() + r as f32 * rh + 3.0),
vec2((cw * 3.0 - 6.0) * (0.25 + 0.75 * len), rh - 6.0),
);
p.rect_filled(bar, 1.0, t.accent.gamma_multiply(0.75));
}
}
for r in 0..=rows {
let y = inner.top() + r as f32 * rh;
p.line_segment([pos2(inner.left(), y), pos2(inner.right(), y)], Stroke::new(1.0, t.gridline));
}
for c in 0..=cols {
let x = inner.left() + c as f32 * cw;
p.line_segment([pos2(x, inner.top()), pos2(x, inner.bottom())], Stroke::new(1.0, t.gridline));
}
p.rect_stroke(thumb, 4.0, edge, StrokeKind::Inside);
let galley = p.layout(label.to_string(), theme::ui_font(12.5), t.text, rect.width());
p.galley(pos2(rect.left(), thumb.bottom() + 6.0), galley, t.text);
resp
}
/// Blank workbook and the built-in samples.
fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
let lang = app.ui.language;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(18.0, 14.0);
if tile(ui, lang.tr("Blank workbook"), None).clicked() {
app.run_or_alert("file.new", json!({}));
out.close = true;
}
for (i, (name, title)) in gridcraft_engine::sample::SAMPLES.iter().enumerate() {
if tile(ui, title, Some(i)).clicked() {
app.run_or_alert("file.new", json!({"sample": name}));
out.close = true;
}
}
});
}
/// File name and folder of a recent path, split as text (nothing on disk is read).
fn split_path(path: &str) -> (String, String) {
let p = std::path::Path::new(path);
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
(name, folder)
}
/// Recent workbooks, newest first. Opening one is the user's explicit choice.
fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
if app.ui.recent.is_empty() {
dim(ui, lang.tr("No recent workbooks yet."));
return;
}
let width = ui.available_width().min(CONTENT_W);
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
let (name, folder) = split_path(&path);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, 4.0, t.hover);
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text);
p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
let resp = resp.on_hover_text(&path);
if resp.clicked() {
app.open_path(&path);
if app.session.active().and_then(|d| d.path.as_deref()) == Some(path.as_str()) {
out.close = true;
}
}
}
}
/// A large clickable row (icon, title, one-line description).
fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let width = ui.available_width().min(CONTENT_W);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, 4.0, t.hover);
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 24.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text);
p.text(pos2(rect.left() + 52.0, rect.center().y - 9.0), Align2::LEFT_CENTER, title, theme::ui_bold(13.5), t.text);
p.text(pos2(rect.left() + 52.0, rect.center().y + 10.0), Align2::LEFT_CENTER, detail, theme::ui_font(12.0), t.text_dim);
resp
}
/// A bordered square command button (Browse…, Print, Protect Workbook).
fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
let p = ui.painter();
p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon });
p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside);
icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 32.0), vec2(30.0, 30.0)), icon, t.text);
let galley = p.layout(label.to_string(), theme::ui_font(12.0), t.text, rect.width() - 8.0);
p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 56.0), galley, t.text);
resp
}
// ---------------------------------------------------------------- pages
fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
let lang = app.ui.language;
section(ui, lang, "New");
new_tiles(app, ui, out);
ui.add_space(22.0);
section(ui, lang, "Recent");
recent_list(app, ui, out, 8);
if app.ui.recent.len() > 8 {
ui.add_space(6.0);
if ui.link(lang.tr("More workbooks")).clicked() {
out.page = Some(Page::Open);
}
}
}
fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
new_tiles(app, ui, out);
}
fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
let lang = app.ui.language;
if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() {
let before = app.session.active().map(|d| d.uid);
app.open_dialog("open", json!({}));
// Desktop pickers return at once; the web picker delivers later (see `SheetApp::logic`).
if app.session.active().map(|d| d.uid) != before {
out.close = true;
}
}
ui.add_space(22.0);
section(ui, lang, "Recent");
recent_list(app, ui, out, usize::MAX);
}
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
let Some(d) = app.session.active() else {
dim(ui, lang.tr("No workbook is open."));
return;
};
let title = d.display_title();
let path = d.path.clone();
let sheets = d.wb.sheets.len();
let protected = d.wb.protected_structure;
let current: Vec<String> = {
let pr = &d.wb.props;
vec![pr.title.clone(), pr.subject.clone(), pr.author.clone(), pr.company.clone(), pr.keywords.clone(), pr.description.clone()]
};
ui.horizontal(|ui| {
icons::paint(ui.painter(), Rect::from_min_size(ui.cursor().min, vec2(28.0, 28.0)), Icon::Book, t.text);
ui.add_space(38.0);
ui.vertical(|ui| {
ui.label(RichText::new(&title).font(theme::ui_bold(16.0)).color(t.text));
match &path {
Some(p) => dim(ui, p),
None => dim(ui, lang.tr("Not saved yet")),
}
});
});
ui.add_space(18.0);
ui.horizontal(|ui| {
if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() {
app.run_or_alert("review.protectWorkbook", json!({"on": !protected}));
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(14.0);
ui.label(RichText::new(lang.tr("Protect Workbook")).font(theme::ui_bold(13.5)).color(t.text));
let text = if protected {
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted."
} else {
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted."
};
dim(ui, lang.tr(text));
});
});
ui.add_space(22.0);
section(ui, lang, "Properties");
egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| {
ui.label(RichText::new(lang.tr("Location")).color(t.text_dim));
ui.label(path.as_deref().unwrap_or(lang.tr("Not saved yet")));
ui.end_row();
ui.label(RichText::new(lang.tr("Sheets")).color(t.text_dim));
ui.label(sheets.to_string());
ui.end_row();
for (i, (key, label)) in PROPERTIES.iter().enumerate() {
let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue };
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
let id = ui.make_persistent_id(("backstage_prop", *key));
// Show the workbook's value unless the field is being edited (undo, another window).
if !ui.ctx().memory(|m| m.has_focus(id)) {
draft.clone_from(saved);
}
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0));
if resp.lost_focus() && draft != saved {
let mut params = serde_json::Map::new();
params.insert((*key).to_string(), json!(draft.clone()));
app.run_or_alert("file.properties", Json::Object(params));
}
ui.end_row();
}
});
}
fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
let lang = app.ui.language;
dim(ui, lang.tr("Save the workbook under another name or in another folder."));
ui.add_space(14.0);
// Desktop: the system Save dialog. Web: the workbook downloads as .xlsx.
let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" };
if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) {
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
app.open_dialog("saveAs", json!({}));
let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
if after != before || app.services.pick_save.is_none() {
out.close = true;
}
}
ui.add_space(18.0);
dim(ui, lang.tr("PDF, CSV and web pages are on the Export page."));
}
fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language;
let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else {
dim(ui, lang.tr("No workbook is open."));
return;
};
let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }});
if bs.fit_to_page {
params["fitToPage"] = json!(true);
}
let key = (uid, revision, bs.print_all, bs.fit_to_page);
if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) {
let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null);
bs.preview = Some((key, preview));
}
let pages: Vec<Json> = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(250.0);
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.print", params.clone());
out.close = true;
}
ui.add_space(16.0);
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| {
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
bs.print_all = all;
bs.preview_page = 0;
}
}
});
ui.add_space(8.0);
if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() {
bs.preview_page = 0;
}
ui.add_space(8.0);
if ui.link(lang.tr("Page Setup…")).clicked() {
app.open_dialog("pageSetup", json!({}));
}
});
ui.add_space(24.0);
ui.vertical(|ui| {
if pages.is_empty() {
dim(ui, lang.tr("Nothing to print."));
return;
}
let last = pages.len().saturating_sub(1);
bs.preview_page = bs.preview_page.min(last);
let Some(page) = pages.get(bs.preview_page) else { return };
// Paper proportions from the preview (points); hostile sizes fall back to Letter.
let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32;
let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32;
let scale = (520.0 / h).min(440.0 / w);
let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover());
let p = ui.painter();
p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow);
p.rect_filled(rect, 2.0, t.grid_bg);
p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
if let Some(d) = app.session.active() {
paint_page(p, rect, w, page, &d.wb, &t);
}
let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or("");
let range = page.get("range").and_then(Json::as_str).unwrap_or("");
ui.add_space(4.0);
dim(ui, &format!("{sheet} · {range}"));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() {
bs.preview_page = bs.preview_page.saturating_sub(1);
}
let text = lang
.tr("Page {page} of {pages}")
.replace("{page}", &(bs.preview_page + 1).to_string())
.replace("{pages}", &pages.len().to_string());
ui.label(text);
if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() {
bs.preview_page = (bs.preview_page + 1).min(last);
}
});
});
});
}
/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale).
const PREVIEW_ROWS: u32 = 200;
const PREVIEW_COLS: u32 = 60;
/// Draws one printed page in miniature: the cells' displayed text at the page's print scale,
/// inside the sheet's margins. `paper_w` is the page width in points.
fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) {
let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return };
let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return };
// Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel).
let per_pt = rect.width() / paper_w;
let print_scale =
page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0);
let per_px = per_pt * 0.75 * print_scale;
let margin =
|i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt;
let body = Rect::from_min_max(
pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)),
pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)),
);
if !body.is_positive() {
return;
}
let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0));
let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS));
let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS));
let mut y = body.top();
for row in range.start.row..=last_row {
let h = sh.row_height(row) * per_px;
if h <= 0.0 {
continue;
}
if y >= body.bottom() {
break;
}
let mut x = body.left();
for col in range.start.col..=last_col {
let w = sh.col_width(col) * per_px;
if w <= 0.0 {
continue;
}
if x >= body.right() {
break;
}
let cell = gridcraft_engine::core::CellRef::new(row, col);
let text = gridcraft_engine::display::cell_text(wb, sh, cell);
if !text.is_empty() {
let numeric = sh.value(cell).as_f64().is_some();
// Text runs on over empty neighbours, as on the sheet; numbers stay in their cell.
let mut span = w;
if !numeric {
let mut next = col.saturating_add(1);
while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() {
span += sh.col_width(next) * per_px;
next = next.saturating_add(1);
}
}
let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body);
let (anchor, at) =
if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) };
p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text);
}
x += w;
}
y += h;
}
}
fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
let lang = app.ui.language;
if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked()
&& export_as(app, "pdf")
{
out.close = true;
}
ui.add_space(14.0);
section(ui, lang, "Change File Type");
for (fmt, icon, title, detail) in [
("csv", Icon::TextColumns, "CSV (Comma delimited)", "Save the active sheet as comma-separated text."),
("html", Icon::Link, "Web Page (.html)", "Save the workbook as a web page."),
] {
if action_row(ui, icon, lang.tr(title), lang.tr(detail)).clicked() && export_as(app, fmt) {
out.close = true;
}
}
}
/// Exports the active workbook as `pdf`, `csv` or `html`: through the save picker on the desktop,
/// as a browser download on the web. Returns whether a file was written or handed over.
pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
if !app.commit_edit(0, 0, false, false) {
return false;
}
let id = match fmt {
"pdf" => "file.exportPdf",
"csv" => "file.exportCsv",
"html" => "file.exportHtml",
_ => return false,
};
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
if let Some(pick) = &app.services.pick_save {
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
// The picker may suggest a workbook extension; the export writes the chosen format.
let path = std::path::Path::new(&path).with_extension(fmt).to_string_lossy().into_owned();
return match app.run(id, json!({"path": path})) {
Ok(_) => true,
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
false
}
};
}
if app.services.download.is_none() {
app.message = Some(("GridCraft".into(), "Exporting needs a file picker, which this window doesn't have.".into()));
return false;
}
if fmt == "pdf" {
// Without a path the PDF comes back as bytes, which `SheetApp::run` hands to the browser.
return match app.run(id, json!({})) {
Ok(_) => true,
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
false
}
};
}
match app.session.run("file.saveBytes", json!({"format": fmt})) {
Ok(r) => {
let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode);
let name = r.get("name").and_then(Json::as_str).map_or_else(|| format!("{stem}.{fmt}"), str::to_string);
match (bytes, &app.services.download) {
(Some(b), Some(dl)) => {
dl(&name, &b);
true
}
_ => false,
}
}
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
false
}
}
}
fn options(app: &mut SheetApp, ui: &mut Ui) {
let lang = app.ui.language;
section(ui, lang, "Options|General");
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
let current = match mode {
"system" => "System",
"dark" => "Dark",
_ => "Light",
};
egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
app.run_or_alert("view.theme", json!({"mode": value}));
}
}
});
ui.end_row();
ui.label(lang.tr("Interface language"));
egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| {
for l in i18n::Language::ALL {
if ui.selectable_label(l == lang, l.name()).clicked() {
app.run_or_alert("app.language.set", json!({"language": l.code()}));
}
}
});
ui.end_row();
});
ui.add_space(6.0);
let mut formula_bar = app.session.prefs.formula_bar;
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
}
ui.add_space(18.0);
section(ui, lang, "Options|Save");
let mut autosave = app.grid.autosave();
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
app.grid.toggle_autosave();
}
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
ui.add_space(18.0);
section(ui, lang, "Formulas");
ui.label(lang.tr("Workbook calculation"));
use gridcraft_engine::model::CalcMode;
for (mode, id, label) in [
(CalcMode::Automatic, "automatic", "Automatic"),
(CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"),
(CalcMode::Manual, "manual", "Manual"),
] {
if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode {
app.run_or_alert("formulas.calculationOptions", json!({"mode": id}));
}
}
ui.add_space(4.0);
let mut iterative = calc.iterative;
if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() {
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
}
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_ids_round_trip_and_unknown_ids_are_refused() {
for page in Page::ALL {
assert_eq!(Page::parse(page.id()), Some(page));
}
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
assert_eq!(Page::parse("account"), None);
assert_eq!(Page::parse(""), None);
}
#[test]
fn recent_paths_split_without_touching_the_disk() {
assert_eq!(split_path("/home/me/Budget.xlsx"), ("Budget.xlsx".into(), "/home/me".into()));
assert_eq!(split_path("Budget.xlsx"), ("Budget.xlsx".into(), "".into()));
}
}

View File

@ -29,6 +29,21 @@ impl gridcraft_chart::Measure for EguiMeasure<'_> {
}
}
/// Paints `chart` (not necessarily on the sheet: the Recommended Charts preview) scaled to fit
/// `rect`.
pub fn paint_chart(p: &Painter, rect: Rect, wb: &Workbook, sheet: usize, chart: &gridcraft_engine::model::Chart) {
let (w, h) = (chart.anchor.width.max(1.0), chart.anchor.height.max(1.0));
let z = (rect.width() / w).min(rect.height() / h);
if !(z.is_finite() && z > 0.0) {
return;
}
let origin = rect.center() - vec2(w * z, h * z) / 2.0;
let data = gridcraft_chart::resolve(wb, sheet, chart);
let m = EguiMeasure { painter: p, z };
let prims = gridcraft_chart::render(chart, &data, w, h, &m);
paint_prims(&p.with_clip_rect(rect.intersect(p.clip_rect())), origin, &prims, z);
}
/// Paints chart primitives at `origin`, scaled by `z`.
pub fn paint_prims(p: &Painter, origin: Pos2, prims: &[Prim], z: f32) {
let pt = |x: f32, y: f32| pos2(origin.x + x * z, origin.y + y * z);
@ -188,12 +203,23 @@ pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
static TEXTURES: std::sync::LazyLock<Mutex<HashMap<(u32, usize), egui::TextureHandle>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
/// The shapes one floating object painted (indices into the painter's layer) and its logical
/// rectangle, so a right-to-left sheet can move it as a whole instead of mirroring it.
pub(crate) struct PaintedObject {
pub shapes: std::ops::Range<usize>,
pub rect: Rect,
}
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
let mut painted = Vec::new();
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
for sp in &sh.shapes {
let r = anchor_rect(geo, sh, &sp.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
let mut label = true;
let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT);
let line = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY);
match sp.kind {
@ -225,13 +251,13 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
p.add(PathShape::line(pts, PathStroke::new(w, col)));
}
continue;
label = false;
}
ShapeKind::Icon => {
if let Some(icon) = crate::icons::from_name(&sp.text) {
crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill });
}
continue;
label = false;
}
ShapeKind::Line | ShapeKind::Arrow => {
p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line));
@ -243,7 +269,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
}
}
}
if !sp.text.is_empty() && !app.editing_text_box(sp.id) {
if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) {
let job = egui::text::LayoutJob::simple(
sp.text.clone(),
FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())),
@ -254,15 +280,17 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 };
p.galley(pos, g, Color32::BLACK);
}
if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
selection_frame(p, r);
}
done(start, r);
}
for im in &sh.images {
let r = anchor_rect(geo, sh, &im.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
let key = (im.id, im.data.len());
let mut cache = TEXTURES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !cache.contains_key(&key)
@ -282,12 +310,14 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
if app.selected_chart == Some(im.id) {
selection_frame(p, r);
}
done(start, r);
}
for ch in &sh.charts {
let r = anchor_rect(geo, sh, &ch.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
// Soft shadow + white card.
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
p.rect_filled(r, 0.0, Color32::WHITE);
@ -297,17 +327,25 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
if app.selected_chart == Some(ch.id) {
selection_frame(p, r);
}
// The source-data highlight below is in cell space: mirrored with the cells.
done(start, r);
if app.selected_chart == Some(ch.id) {
// Highlight the source data like Excel does.
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
p.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
}
}
}
}
}
painted
}
fn selection_frame(p: &Painter, r: Rect) {

View File

@ -73,6 +73,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
"scroll": [v.scroll.x, v.scroll.y],
"editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})),
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
"selectedChart": app.selected_chart,
"mode": format!("{:?}", app.session.mode),
@ -121,7 +122,7 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option<egui::Pos2> {
let sh = d.wb.active()?;
let rect = app.grid.rect?;
let geo = crate::grid::Geo::new(sh, rect, app.view().scroll);
Some(geo.cell_rect(sh, c).center())
Some(geo.screen_cell_rect(sh, c).center())
}
fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) {
@ -168,13 +169,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, params) in [
("ui.ribbonTab", "Ribbon Tab", ""),
("view.formulaBar", "Formula Bar", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.zoom100", "100%", ""),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
@ -196,12 +197,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
app.ui.system_theme = false;
}
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
app.ui.formula_bar = v;
let _ = app.run("view.formulaBar", json!({"on": v}));
}
if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) {
app.ui.ribbon_collapsed = v;
}
ok(serde_json::to_value(&app.ui).unwrap_or_default())
let mut out = serde_json::to_value(&app.ui).unwrap_or_default();
if let Some(o) = out.as_object_mut() {
o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar));
}
ok(out)
}
"ui.dialog" => {
app.open_dialog(s("name").unwrap_or(""), p.clone());

View File

@ -4,7 +4,9 @@
//! Every dialog is also reachable programmatically: `ui.dialog {name}` opens one,
//! `ui.dialog.set {field, value}` edits a field and `ui.dialog.confirm` presses OK.
use egui::{Color32, Key, vec2};
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
use crate::icons::{self, Icon};
use serde_json::{Map, Value as Json, json};
use crate::SheetApp;
@ -13,6 +15,7 @@ use crate::theme;
#[derive(Clone, Debug, PartialEq)]
pub enum Field {
Text { key: &'static str, label: &'static str },
Password { key: &'static str, label: &'static str },
Number { key: &'static str, label: &'static str },
Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> },
Check { key: &'static str, label: &'static str },
@ -31,8 +34,12 @@ pub struct Dialog {
pub search: String,
pub list_index: usize,
pub result: Option<Json>,
/// "Show password" is ticked: password fields show their text.
pub show_password: bool,
picture_inbox: Option<crate::Inbox>,
picture_target: Option<(u64, usize)>,
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
back: Option<Box<Dialog>>,
}
impl Dialog {
@ -51,8 +58,10 @@ impl Dialog {
search: String::new(),
list_index: 0,
result: None,
show_password: false,
picture_inbox: None,
picture_target: None,
back: None,
}
}
@ -67,8 +76,10 @@ impl Dialog {
search: String::new(),
list_index: 0,
result: None,
show_password: false,
picture_inbox: None,
picture_target: None,
back: None,
}
}
@ -151,7 +162,7 @@ impl Dialog {
"Protect Sheet",
"review.protectSheet",
vec![
Text { key: "password", label: "Password (optional):" },
Password { key: "password", label: "Password (optional):" },
Note("Allow all users of this sheet to:".into()),
Check { key: "formatCells", label: "Format cells" },
Check { key: "formatColumns", label: "Format columns" },
@ -163,11 +174,49 @@ impl Dialog {
],
json!({}),
),
"allowEditRanges" => {
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
let rows: Vec<String> = list
.as_array()
.map(|a| {
a.iter()
.map(|r| {
let lock = match (r["password"].as_bool(), r["unlocked"].as_bool()) {
(Some(true), Some(true)) => " (password, unlocked)",
(Some(true), _) => " (password)",
_ => "",
};
format!("{}: {}{lock}", r["title"].as_str().unwrap_or(""), r["range"].as_str().unwrap_or(""))
})
.collect()
})
.unwrap_or_default();
let summary = if rows.is_empty() { "No ranges yet.".to_string() } else { rows.join("\n") };
Dialog::form(
"allowEditRanges",
"Allow Edit Ranges",
"review.allowEditRanges",
vec![
Note(format!("Ranges that stay editable when the sheet is protected:\n{summary}")),
Choice {
key: "action",
label: "Action",
options: vec![("add", "New"), ("modify", "Modify"), ("delete", "Delete"), ("unlock", "Unlock with password")],
},
Text { key: "title", label: "Title:" },
Text { key: "range", label: "Refers to cells:" },
Text { key: "password", label: "Range password:" },
Text { key: "newTitle", label: "New title (Modify):" },
Check { key: "removePassword", label: "Remove the password (Modify)" },
],
json!({"action": "add", "range": sel}),
)
}
"unprotectSheet" => Dialog::form(
"unprotectSheet",
"Unprotect Sheet",
"review.unprotectSheet",
vec![Text { key: "password", label: "Password:" }],
vec![Password { key: "password", label: "Password:" }],
json!({}),
),
"unhideSheet" => {
@ -317,6 +366,33 @@ impl Dialog {
json!({"header": true}),
),
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"analysisToolPak" => {
// Opened with a known `tool`, it goes straight to that tool's form.
let tool = params.get("tool").and_then(Json::as_str).unwrap_or("");
let mut d = Dialog::custom("analysisToolPak", "Data Analysis", json!({"tool": "", "pick": tool}));
if gridcraft_engine::cmd::analysis::TOOLS.iter().any(|(id, _)| *id == tool) {
d.values = toolpak_defaults(&sel, tool);
}
d.command = Some("data.analysisToolPak");
d
}
"solver" => {
// Starts from the sheet's saved model, else the active cell as objective.
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet));
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
for (k, x) in m {
v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() });
}
}
if let Some(v) = values.as_object_mut() {
v.insert("newOp".into(), json!("<="));
}
let mut d = Dialog::custom("solver", "Solver Parameters", values);
d.command = Some("data.solver");
d
}
"solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),
"subtotal" => Dialog::form(
"subtotal",
"Subtotal",
@ -353,18 +429,32 @@ impl Dialog {
],
json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}),
),
"defineName" => Dialog::form(
"defineName",
"New Name",
"formulas.defineName",
vec![
Text { key: "name", label: "Name:" },
Text { key: "scope", label: "Scope (Workbook or sheet name):" },
Text { key: "comment", label: "Comment:" },
Text { key: "refersTo", label: "Refers to:" },
],
json!({"scope": "Workbook", "refersTo": app.session.active().and_then(|d| d.wb.active().map(|s| format!("={}!{}", gridcraft_engine::formula::quote_sheet(&s.name), abs(&d.selection.current()))))}),
),
"defineName" => {
// Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and
// its current fields; New… and Define Name start from the selection.
let editing = params.get("oldName").and_then(Json::as_str).is_some();
let selection = app.session.active().and_then(|d| {
d.wb.active().map(|s| format!("={}!{}", gridcraft_engine::formula::quote_sheet(&s.name), abs(&d.selection.current())))
});
let mut defaults = json!({"scope": "Workbook", "refersTo": selection});
for key in ["name", "scope", "comment", "refersTo", "oldName", "oldScope"] {
if let Some(v) = params.get(key).filter(|v| v.is_string()) {
defaults[key] = v.clone();
}
}
Dialog::form(
"defineName",
if editing { "Edit Name" } else { "New Name" },
"formulas.defineName",
vec![
Text { key: "name", label: "Name:" },
Text { key: "scope", label: "Scope (Workbook or sheet name):" },
Text { key: "comment", label: "Comment:" },
Text { key: "refersTo", label: "Refers to:" },
],
defaults,
)
}
"dataValidation" => Dialog::form(
"dataValidation",
"Data Validation",
@ -480,6 +570,13 @@ impl Dialog {
d
}
"goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
"pivotCalculatedField" => Dialog::form(
"pivotCalculatedField",
"Insert Calculated Field",
"pivot.calculatedField",
vec![Text { key: "name", label: "Name:" }, Text { key: "formula", label: "Formula:" }],
json!({"pivot": p("pivot"), "name": p("name"), "formula": p("formula")}),
),
"goToSpecial" => Dialog::form(
"goToSpecial",
"Go To Special",
@ -517,6 +614,44 @@ impl Dialog {
};
d
}
"recommendedCharts" => {
// Insert › Recommended Charts: chart types for the selected data, with previews.
let recs = match app.session.run("insert.chartRecommendations", json!({})) {
Ok(r) => r,
Err(e) => {
app.message = Some(("Insert Chart".into(), crate::clean_error(&e)));
return None;
}
};
let mut d = Dialog::custom("recommendedCharts", "Insert Chart", json!({}));
d.tab = if p("tab").as_str() == Some("All Charts") { "All Charts".into() } else { "Recommended Charts".into() };
d.result = Some(recs);
select_chart(&mut d, 0);
d
}
"selectData" => {
// Chart Design › Select Data: data range, series to keep, category axis labels.
let id = p("chart").as_u64();
let found = app.session.active().and_then(|doc| {
let si = doc.wb.active_sheet;
let sh = doc.wb.sheet(si)?;
let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;
let data = gridcraft_chart::resolve(&doc.wb, si, ch);
let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();
let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();
Some((ch.source.clone().unwrap_or_default(), cats, names))
});
let Some((range, cats, names)) = found else {
app.message = Some(("Select Data".into(), "Select a chart first.".into()));
return None;
};
let eq = |t: &str| if t.is_empty() { String::new() } else { format!("={t}") };
Dialog::custom(
"selectData",
"Select Data Source",
json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),
)
}
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
"statistics" => {
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
@ -614,6 +749,15 @@ fn build_params(d: &Dialog) -> Json {
v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect());
}
}
"allowEditRanges" => {
if let Some(m) = v.as_object_mut() {
// Blank fields mean "unchanged"; the check box clears a password.
m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty()));
if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) {
m.insert("password".into(), json!(""));
}
}
}
"zoom" => {
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
&& d.values.get("percent").is_none()
@ -636,10 +780,12 @@ fn build_params(d: &Dialog) -> Json {
}
}
"defineName" => {
if d.values.get("scope").and_then(Json::as_str) == Some("Workbook")
&& let Some(m) = v.as_object_mut()
{
m.remove("scope");
if let Some(m) = v.as_object_mut() {
for key in ["scope", "oldScope"] {
if m.get(key).and_then(Json::as_str) == Some("Workbook") {
m.remove(key);
}
}
}
}
_ => {}
@ -659,6 +805,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
_ => 380.0,
};
let window = egui::Window::new(d.title.clone())
@ -677,10 +826,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"find" => find(app, ui, &mut d),
"goTo" => go_to(app, ui, &mut d, &mut confirm),
"sort" => sort(app, ui, &mut d, &mut confirm),
"nameManager" => name_manager(app, ui),
"nameManager" => name_manager(app, ui, &mut d, &mut open),
"manageRules" => manage_rules(app, ui),
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
@ -810,7 +964,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
if ui.button("Cancel").clicked() {
open = false;
}
if ui.button("Save").clicked() {
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) {
app.run_or_alert("file.save", json!({}));
if app.session.active().is_some_and(|x| !x.is_dirty()) {
app.run_or_alert("file.close", json!({"force": true}));
@ -877,14 +1032,36 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
if confirm && let Some(cmd) = d.command {
let params = build_params(&d);
match app.run(cmd, params) {
Ok(_) => open = false,
Ok(r) => {
open = false;
if d.name == "solver" {
app.open_dialog("solverResults", r);
}
}
// Data Analysis asks before replacing data in its output range.
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
d.values.insert("askOverwrite".into(), json!(true));
}
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
} else if confirm {
open = false;
}
if let Some(child) = app.dialog.as_mut() {
// The body opened another dialog (Name Manager › New…). It takes over; this one waits
// behind it and comes back when it closes, as Excel returns to the Name Manager. Go To
// hands over to Go To Special for good, as in Excel.
if open && d.name != "goTo" {
child.back = Some(Box::new(d));
} else if child.back.is_none() {
child.back = d.back.take();
}
return;
}
if open {
app.dialog = Some(d);
} else if let Some(back) = d.back.take() {
app.dialog = Some(*back);
}
}
@ -967,6 +1144,12 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
}
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
form_fields(ui, d);
ok_cancel(ui, confirm, open);
}
/// Draws a dialog's `fields`, editing its values.
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
for f in d.fields.clone() {
match f {
@ -989,6 +1172,17 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
}
});
}
Field::Password { key, label } => {
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
ui.vertical(|ui| {
ui.label(label);
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
d.values.insert(key.into(), json!(s));
}
ui.checkbox(&mut d.show_password, "Show password");
});
}
Field::Number { key, label } => {
ui.horizontal(|ui| {
ui.label(label);
@ -1022,7 +1216,6 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
ui.end_row();
}
});
ok_cancel(ui, confirm, open);
}
/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits
@ -1485,29 +1678,243 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
}
}
fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) {
fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null);
egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| {
ui.strong("Name");
ui.strong("Value");
ui.strong("Refers To");
ui.strong("Scope");
ui.end_row();
for n in list.as_array().cloned().unwrap_or_default() {
ui.label(n["name"].as_str().unwrap_or(""));
ui.label(n["value"].to_string().chars().take(30).collect::<String>());
ui.label(n["refersTo"].as_str().unwrap_or(""));
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"], "scope": n["scope"]}));
}
});
ui.end_row();
let names = list.as_array().cloned().unwrap_or_default();
let selected = names.get(d.list_index).cloned();
let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string();
ui.horizontal(|ui| {
if ui.button("New…").clicked() {
app.open_dialog("defineName", json!({}));
}
if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked()
&& let Some(n) = &selected
{
app.open_dialog(
"defineName",
json!({
"name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"),
"refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"),
}),
);
}
if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked()
&& let Some(n) = &selected
{
app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")}));
}
});
if ui.button("New…").clicked() {
app.open_dialog("defineName", json!({}));
ui.add_space(4.0);
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| {
ui.strong("Name");
ui.strong("Value");
ui.strong("Refers To");
ui.strong("Scope");
ui.end_row();
if names.is_empty() {
ui.label(egui::RichText::new("No names defined").italics());
ui.end_row();
}
for (i, n) in names.iter().enumerate() {
if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() {
d.list_index = i;
}
ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::<String>());
ui.label(field(n, "refersTo"));
ui.label(field(n, "scope"));
ui.end_row();
}
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(" Close ").clicked() {
*open = false;
}
});
}
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
const ALL_CHARTS: [(&str, &str, &str); 20] = [
("column", "", "Clustered Column"),
("column", "stacked", "Stacked Column"),
("column", "stacked100", "100% Stacked Column"),
("bar", "", "Clustered Bar"),
("bar", "stacked", "Stacked Bar"),
("bar", "stacked100", "100% Stacked Bar"),
("line", "", "Line"),
("line", "markers", "Line with Markers"),
("line", "stacked", "Stacked Line"),
("pie", "", "Pie"),
("doughnut", "", "Doughnut"),
("area", "", "Area"),
("area", "stacked", "Stacked Area"),
("scatter", "", "Scatter"),
("scatter", "lines", "Scatter with Lines"),
("radar", "", "Radar"),
("histogram", "", "Histogram"),
("waterfall", "", "Waterfall"),
("funnel", "", "Funnel"),
("combo", "", "Combo"),
];
/// The chart types listed on the dialog's current tab.
fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> {
if d.tab == "All Charts" {
return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect();
}
let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string();
d.result
.as_ref()
.and_then(Json::as_array)
.map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect())
.unwrap_or_default()
}
/// Selects entry `i` of the current tab: OK inserts it.
fn select_chart(d: &mut Dialog, i: usize) {
let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return };
d.list_index = i;
d.values.insert("type".into(), json!(t));
d.values.insert("subtype".into(), json!(sub));
}
fn chart_icon(t: &str) -> Icon {
match t {
"column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar,
"line" | "area" | "radar" => Icon::ChartLine,
"pie" | "doughnut" => Icon::ChartPie,
"scatter" | "bubble" => Icon::ChartScatter,
_ => Icon::Chart,
}
}
fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
ui.horizontal(|ui| {
for tab in ["Recommended Charts", "All Charts"] {
if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab {
d.tab = tab.to_string();
select_chart(d, 0);
}
}
});
ui.separator();
let choices = chart_choices(d);
let t = crate::theme::Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(190.0);
egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| {
for (i, (kind, _, label)) in choices.iter().enumerate() {
let resp = ui
.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text);
ui.selectable_label(i == d.list_index, label.as_str())
})
.inner;
if resp.clicked() {
select_chart(d, i);
}
if resp.double_clicked() {
select_chart(d, i);
*confirm = true;
}
}
});
});
ui.add_space(12.0);
ui.vertical(|ui| {
let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default();
ui.strong(label);
let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover());
ui.painter().rect_filled(rect, 2.0, Color32::WHITE);
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
if let Some(doc) = app.session.active()
&& let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, &params)
{
crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart);
}
});
});
ok_cancel(ui, confirm, open);
if *confirm {
*confirm = false;
let params =
json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
match app.run("insert.chart", params) {
Ok(r) => {
// The new chart is selected, as after inserting any object.
app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok());
*open = false;
}
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
}
}
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
ui.label(label);
let mut v = text(d, key);
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
d.values.insert(key.into(), json!(v));
}
};
edit(ui, d, "range", "Chart data range:");
ui.add_space(6.0);
ui.strong("Legend Entries (Series)");
let names: Vec<String> =
d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default();
let mut remove: Vec<u64> =
d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();
let kept = names.len().saturating_sub(remove.len());
egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
let i = i as u64;
let removed = remove.contains(&i);
ui.horizontal(|ui| {
let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() };
ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) });
if removed {
if ui.small_button("Keep").clicked() {
remove.retain(|x| *x != i);
}
} else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() {
remove.push(i);
}
});
}
});
d.values.insert("remove".into(), json!(remove));
ui.add_space(6.0);
edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");
ok_cancel(ui, confirm, open);
if *confirm {
*confirm = false;
let mut params = serde_json::Map::new();
params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null));
let range_changed = text(d, "range") != text(d, "range0");
if range_changed {
params.insert("range".into(), json!(text(d, "range")));
} else if !remove.is_empty() {
// A new range rebuilds the series; removals only apply to the current ones.
params.insert("removeSeries".into(), json!(remove));
}
if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {
params.insert("categories".into(), json!(text(d, "categories")));
}
if params.len() == 1 {
*open = false;
return;
}
match app.run("chart.selectData", Json::Object(params)) {
Ok(_) => *open = false,
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
}
}
@ -1607,6 +2014,282 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu
}
}
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
fn toolpak_fields(tool: &str) -> Vec<Field> {
use Field::*;
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
let labels = || Check { key: "labels", label: "Labels in first row" };
let range = || Text { key: "range", label: "Input Range:" };
let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }];
match tool {
"descriptive" => vec![
range(),
grouped(),
labels(),
Check { key: "summary", label: "Summary statistics" },
Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" },
Text { key: "kthLargest", label: "Kth Largest (blank for none):" },
Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" },
],
"histogram" => vec![
range(),
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
labels(),
Check { key: "pareto", label: "Pareto (sorted histogram)" },
Check { key: "cumulative", label: "Cumulative Percentage" },
],
"correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()],
"anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }],
"regression" => vec![
Text { key: "inputY", label: "Input Y Range:" },
Text { key: "inputX", label: "Input X Range:" },
labels(),
Check { key: "constantZero", label: "Constant is Zero" },
Text { key: "confidence", label: "Confidence Level (%):" },
Check { key: "residuals", label: "Residuals" },
Check { key: "standardizedResiduals", label: "Standardized Residuals" },
Check { key: "normalProbability", label: "Normal Probability" },
],
"movingAverage" => {
vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"exponentialSmoothing" => {
vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"fTest" => {
let mut v = two();
v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]);
v
}
_ => {
let mut v = two();
v.extend([
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
labels(),
Text { key: "alpha", label: "Alpha:" },
]);
v
}
}
}
/// The starting values of a tool's form: the selection as its input, a new sheet as output.
fn toolpak_defaults(sel: &str, tool: &str) -> Map<String, Json> {
let mut v = json!({
"tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "",
"groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "",
"interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0",
"outputTo": "newSheet", "output": "", "newSheet": "",
});
if tool == "regression" {
v["confidence"] = json!("95");
v["inputY"] = json!(sel);
}
v.as_object().cloned().unwrap_or_default()
}
/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak`
/// (through the dialog's command); a report that would replace data asks first.
fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
use gridcraft_engine::cmd::analysis::TOOLS;
if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) {
ui.label("The output range already holds data. Replace it with the report?");
ui.horizontal(|ui| {
if ui.button("Replace").clicked() {
d.values.remove("askOverwrite");
d.values.insert("overwrite".into(), json!(true));
*confirm = true;
}
if ui.button("Cancel").clicked() {
d.values.remove("askOverwrite");
}
});
return;
}
let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string();
let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else {
ui.label(egui::RichText::new("Analysis Tools").strong());
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
let mut chosen = false;
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (id, label) in TOOLS {
let r = ui.selectable_label(pick == *id, *label);
if r.clicked() {
d.values.insert("pick".into(), json!(id));
}
if r.double_clicked() {
chosen = true;
}
}
});
let mut ok = false;
ok_cancel(ui, &mut ok, open);
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
d.values = toolpak_defaults(&sel, &pick);
}
return;
};
d.title = label.to_string();
d.fields = toolpak_fields(&tool);
form_fields(ui, d);
ui.separator();
ui.label(egui::RichText::new("Output options").strong());
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
ui.horizontal(|ui| {
if ui.radio(to == choice, text_label).clicked() {
d.values.insert("outputTo".into(), json!(choice));
}
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
d.values.insert(key.into(), json!(t));
d.values.insert("outputTo".into(), json!(choice));
}
});
}
ok_cancel(ui, confirm, open);
}
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
/// command; the result opens Solver Results.
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
let mut t = text(d, k);
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
d.values.insert(k.into(), json!(t));
}
};
ui.horizontal(|ui| {
ui.label("Set Objective:");
edit(ui, d, "objective", 200.0);
});
let goal = text(d, "goal");
ui.horizontal(|ui| {
ui.label("To:");
for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] {
if ui.radio(goal == g, label).clicked() {
d.values.insert("goal".into(), json!(g));
}
}
edit(ui, d, "value", 80.0);
});
ui.label("By Changing Variable Cells:");
edit(ui, d, "variables", 420.0);
ui.label("Subject to the Constraints:");
let list: Vec<String> = d
.values
.get("constraints")
.and_then(Json::as_array)
.map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect())
.unwrap_or_default();
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_size(vec2(420.0, 90.0));
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
for (i, c) in list.iter().enumerate() {
if ui.selectable_label(d.list_index == i, c).clicked() {
d.list_index = i;
}
}
});
});
ui.horizontal(|ui| {
edit(ui, d, "newLhs", 110.0);
let op = text(d, "newOp");
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
for o in ["<=", "=", ">=", "int", "bin"] {
if ui.selectable_label(op == o, o).clicked() {
d.values.insert("newOp".into(), json!(o));
}
}
});
if !matches!(op.as_str(), "int" | "bin") {
edit(ui, d, "newRhs", 110.0);
}
if ui.button("Add").clicked() {
let lhs = text(d, "newLhs");
if !lhs.trim().is_empty() {
let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) };
let mut list = list.clone();
list.push(format!("{} {op}{rhs}", lhs.trim()));
d.values.insert("constraints".into(), json!(list));
d.values.insert("newLhs".into(), json!(""));
d.values.insert("newRhs".into(), json!(""));
}
}
if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() {
let mut list = list.clone();
if d.list_index < list.len() {
list.remove(d.list_index);
}
d.values.insert("constraints".into(), json!(list));
}
});
let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true);
if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() {
d.values.insert("nonNegative".into(), json!(nonneg));
}
let method = text(d, "method");
ui.horizontal(|ui| {
ui.label("Select a Solving Method:");
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
for m in ["grg", "simplex"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
}
});
});
ui.label(
egui::RichText::new(if method == "simplex" {
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
} else {
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
})
.small(),
);
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let solve =
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
if solve.clicked() && !text(d, "objective").trim().is_empty() {
*confirm = true;
}
if ui.button(" Close ").clicked() {
*open = false;
}
});
}
/// Solver Results: the outcome, and keeping or restoring the variable cells.
fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string();
ui.label(message);
let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false);
let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false);
if changed {
ui.add_space(4.0);
if ui.radio(!restore, "Keep Solver Solution").clicked() {
d.values.insert("restore".into(), json!(false));
}
if ui.radio(restore, "Restore Original Values").clicked() {
d.values.insert("restore".into(), json!(true));
}
}
let mut confirm = false;
ok_cancel(ui, &mut confirm, open);
if confirm {
// The solution (and the saved model) is one undo step.
if changed && restore {
app.run_or_alert("edit.undo", json!({}));
}
*open = false;
}
}
fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let r = d.result.clone().unwrap_or(Json::Null);
if let Some(e) = r.get("error").and_then(Json::as_str) {

View File

@ -112,6 +112,10 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
}
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(Key::Enter)) {
// The Enter that commits the Name Box is used up here. Left in the frame's input, the
// grid (drawn after the formula bar, and focused again now the box let go) would read
// it as its own Enter and move the active cell one row down from the jump target (#247).
ui.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Enter, .. })));
let target = text.trim().to_string();
if !target.is_empty() && target != shown {
// A new name for the selection, or a place to go.
@ -226,6 +230,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
} else {
(0, 1)
};
// Left/Right follow the screen on a right-to-left sheet.
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let dc = if rtl { -dc } else { dc };
if ed.can_point() {
ui.input_mut(|i| {
for k in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] {
@ -336,9 +343,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
ui.painter().galley(pos, g, Color32::WHITE);
}
// Autocomplete list and argument hint under the editor.
if mine && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
egui::Area::new(id.with("ac")).fixed_pos(below).order(egui::Order::Tooltip).show(ui.ctx(), |ui| {
// Areas have their own clip rect: keep cell helpers inside the worksheet.
let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() };
egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| {
if !ed.autocomplete.is_empty() {
let mut pick = None;

View File

@ -40,6 +40,11 @@ pub enum Drag {
Fill {
target: Option<RangeRef>,
},
/// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze.
Freeze {
cols: bool,
to: Option<u32>,
},
/// Moving the selection by its border (cut & paste).
Move {
grab: CellRef,
@ -108,6 +113,11 @@ pub struct Geo {
pub frozen_w: f32,
pub frozen_h: f32,
pub scroll: egui::Vec2,
/// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical"
/// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s
/// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by
/// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution).
pub rtl: bool,
/// Prefix sums over custom row heights / column widths, built once per frame.
ri: std::sync::Arc<LineIndex>,
ci: std::sync::Arc<LineIndex>,
@ -127,7 +137,21 @@ impl Geo {
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci }
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci }
}
/// Screen x for a logical x (and back): identity unless the sheet is right-to-left.
pub fn mirror_x(&self, x: f32) -> f32 {
if self.rtl { self.rect.left() + self.rect.right() - x } else { x }
}
pub fn mirror_pos(&self, p: Pos2) -> Pos2 {
pos2(self.mirror_x(p.x), p.y)
}
pub fn mirror_rect(&self, r: Rect) -> Rect {
if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r }
}
/// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets).
pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
self.mirror_rect(self.cell_rect(sh, c))
}
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
if col < self.fc {
@ -186,6 +210,33 @@ impl Geo {
let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1);
(c0, c1)
}
/// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`.
/// The panes share one coordinate system, so a range that is not clipped to these is also drawn
/// over the frozen rows/columns when it is scrolled under them.
pub fn range_panes(&self, r: RangeRef) -> Vec<Rect> {
let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h);
let mut xs = Vec::new();
if r.start.col < self.fc {
xs.push((self.cells.left(), split.x));
}
if r.end.col >= self.fc {
xs.push((split.x, self.cells.right()));
}
let mut ys = Vec::new();
if r.start.row < self.fr {
ys.push((self.cells.top(), split.y));
}
if r.end.row >= self.fr {
ys.push((split.y, self.cells.bottom()));
}
ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect()
}
/// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns.
pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool {
let r = self.cell_rect(sh, c);
(self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w)
|| (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h)
}
}
/// Resolved look of a cell for painting.
@ -294,7 +345,8 @@ fn display_text(
return (String::new(), None, false, None);
}
let code = st.num_fmt.as_str();
if code == "checkbox" {
// A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General.
if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
return (String::new(), None, false, None);
}
if code == "General"
@ -313,7 +365,14 @@ fn display_text(
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
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()));
// A right-to-left sheet has its vertical scrollbar on the left.
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let bar = 14.0_f32.min(available.width());
let rect = if rtl {
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
} else {
Rect::from_min_max(available.min, pos2(available.right() - bar, 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 };
@ -331,6 +390,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
} else if scroll_delta != egui::Vec2::ZERO {
let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE;
let mut d = scroll_delta;
// A horizontal swipe moves the content the way the fingers go, which is towards
// higher columns on a mirrored sheet.
if sh.right_to_left {
d.x = -d.x;
}
if modifiers.shift && d.x == 0.0 {
d = vec2(d.y, 0.0);
}
@ -370,9 +434,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
if let Some(v) = app.view_mut() {
*v = view;
}
app.grid.cells_rect = Some(geo.cells);
app.grid.cells_rect = Some(geo.mirror_rect(geo.cells));
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, t.grid_bg);
// Everything up to the headers is painted in logical (left-to-right) coordinates.
let mirror_from = painter.add(egui::Shape::Noop);
let mut cf = CfCache::new();
// Quadrants: (rows, cols, clip).
let (sr0, sr1) = geo.scroll_rows(sh);
@ -396,7 +462,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
let p = painter.with_clip_rect(*clip);
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t);
paint_selection(&p, &geo, sh, &sel, &t, app);
paint_selection(&p, &geo, sh, &sel, &t, app, *rows, *cols);
}
// Freeze lines.
if geo.fr > 0 {
@ -406,7 +472,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150)));
}
// Objects above cells.
crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" {
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
@ -414,6 +480,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
// Headers.
if sh.show_headings {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
}
if geo.rtl {
let mirror_to = painter.add(egui::Shape::Noop);
mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects);
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
@ -426,7 +497,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
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);
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
vertical_scrollbar(app, ui, sh, &geo, track_x, 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));
@ -435,8 +507,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
/// 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()));
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
}
@ -482,6 +554,59 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
}
}
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction
/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index
/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture
/// isn't flipped.
fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range<usize>, geo: &Geo, objects: &[crate::chartview::PaintedObject]) {
let axis = geo.rect.left() + geo.rect.right();
ctx.graphics_mut(|g| {
let list = g.entry(layer);
for i in range {
let object = objects.iter().find(|o| o.shapes.contains(&i));
list.mutate_shape(egui::layers::ShapeIdx(i), |cs| {
cs.clip_rect = geo.mirror_rect(cs.clip_rect);
match object {
Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)),
None => mirror_shape(&mut cs.shape, axis),
}
});
}
});
}
/// Reflect one shape across the vertical line `x = axis / 2`.
fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
use egui::Shape;
let flip = |p: &mut Pos2| p.x = axis - p.x;
match shape {
Shape::Noop | Shape::Callback(_) => {}
Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)),
Shape::Circle(c) => flip(&mut c.center),
Shape::Ellipse(e) => flip(&mut e.center),
Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip),
Shape::Path(p) => {
p.points.iter_mut().for_each(flip);
// Keep the preferred (clockwise) winding for filled polygons.
p.points.reverse();
}
Shape::Rect(r) => {
r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom()));
let c = r.corner_radius;
r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw };
}
Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip),
Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip),
_ => {
let b = shape.visual_bounding_rect();
if b.is_finite() {
shape.translate(vec2(axis - b.right() - b.left(), 0.0));
}
}
}
}
#[allow(clippy::too_many_arguments)]
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;
@ -582,7 +707,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet,
paint_cf_icon(p, r, set, *idx);
text_left += 15.0 * z;
}
if lk.style.num_fmt.as_str() == "checkbox" {
if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
let s = (11.0 * z * 1.2).clamp(8.0, 24.0);
let b = Rect::from_center_size(rect.center(), vec2(s, s));
let on = v == Value::Bool(true);
@ -901,21 +1026,40 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) {
}
}
fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) {
/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive).
/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the
/// scrolling pane would otherwise be drawn over the frozen pane as well.
fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool {
r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0
}
#[allow(clippy::too_many_arguments)]
fn paint_selection(
p: &Painter,
geo: &Geo,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
t: &Tokens,
app: &SheetApp,
rows: (u32, u32),
cols: (u32, u32),
) {
// Fill all areas except the active cell.
for r in &sel.ranges {
if !range_in_pane(*r, rows, cols) {
continue;
}
if r.is_single() && sel.ranges.len() == 1 {
continue;
}
let rect = geo.range_rect(sh, *r);
p.rect_filled(rect, 0.0, t.sel_fill);
}
let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active));
let active = match sh.merge_at(sel.active) {
Some(m) => geo.range_rect(sh, m),
None => active,
};
if !(sel.ranges.len() == 1 && sel.current().is_single()) {
let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active));
let active = geo.range_rect(sh, active_range);
let active_here = range_in_pane(active_range, rows, cols);
let cur_here = range_in_pane(sel.current(), rows, cols);
if active_here && !(sel.ranges.len() == 1 && sel.current().is_single()) {
p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0));
}
let cur = geo.range_rect(sh, sel.current());
@ -927,6 +1071,9 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
continue;
}
if !range_in_pane(r, rows, cols) {
continue;
}
let rr = geo.range_rect(sh, r);
let col = REF_COLORS[idx];
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
@ -936,18 +1083,22 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
}
}
}
p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
if sel.ranges.len() > 1 {
if cur_here {
p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
}
if active_here && sel.ranges.len() > 1 {
p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside);
}
// Fill handle (bottom-right of the current area).
if app.editor.is_none() {
if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) {
let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0));
p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE);
p.rect_filled(h, 0.0, t.sel_border);
}
// Fill-handle drag preview.
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag {
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag
&& range_in_pane(*tr, rows, cols)
{
let rr = geo.range_rect(sh, *tr);
p.extend(egui::Shape::dashed_line(
&[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()],
@ -959,17 +1110,24 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag {
let r = sel.current();
let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1));
let rr = geo.range_rect(sh, moved);
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
if range_in_pane(moved, rows, cols) {
let rr = geo.range_rect(sh, moved);
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
}
}
// Marching ants around the copied range.
if let Some(clip) = &app.session.clipboard
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
&& range_in_pane(clip.range, rows, cols)
{
let rr = geo.range_rect(sh, clip.range).expand(0.5);
let phase = app.grid.marching_phase;
let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()];
p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase));
// A multi-area copy marks each area as selected.
let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() };
for area in areas {
let rr = geo.range_rect(sh, *area).expand(0.5);
let phase = app.grid.marching_phase;
let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()];
p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase));
}
}
}
@ -1047,6 +1205,9 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel
// ------------------------------------------------------------------ interaction
/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it.
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() {
@ -1057,7 +1218,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
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());
// Hit-testing works in logical coordinates (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
let in_cells = pos.is_some_and(|p| geo.cells.contains(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top());
@ -1065,23 +1227,48 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
// The fill handle and the move border only react in the panes where they are painted, not
// over the frozen rows/columns that a scrolled selection lies under.
let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end));
let border_panes = geo.range_panes(sel.current());
let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p));
let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
let (freeze_cols, freeze_rows) = freeze_handles(geo);
// Hover cursors.
if resp.hovered()
&& let Some(p) = pos
&& (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. }))
{
let cols = match app.grid.drag {
Drag::Freeze { cols, .. } => cols,
_ => freeze_cols.contains(p),
};
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip));
}
} else if resp.hovered()
&& let Some(p) = pos
{
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some();
if near_col_edge {
let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id));
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
let near_object_corner = app.grid.drag == Drag::None && near_corner;
if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
} else if near_col_edge {
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
} else if near_row_edge {
ctx.set_cursor_icon(CursorIcon::ResizeRow);
} else if handle.contains(p) && app.editor.is_none() {
} else if on_handle(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) {
} else if in_cells && app.editor.is_none() && on_cur_border(p) {
ctx.set_cursor_icon(CursorIcon::Move);
} else if in_cells {
ctx.set_cursor_icon(CursorIcon::Cell);
@ -1109,10 +1296,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
.ink
.iter()
.map(|q| {
pos2(
geo.mirror_pos(pos2(
geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z,
geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z,
)
))
})
.collect();
ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
@ -1135,7 +1322,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
// Objects (charts, pictures, shapes) take clicks first.
if resp.drag_started() || primary_clicked {
if let Some(p) = pos
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos };
if let Some(p) = at
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_chart = Some(id);
@ -1151,7 +1340,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
if resp.drag_started() {
let a = object_anchor(sh, kind, id);
let resize = (p - rect.right_bottom()).length() < 10.0;
let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS;
if let Some(a) = a {
app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize };
}
@ -1191,7 +1380,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Press: decide the drag mode.
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 };
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
if freeze_cols.contains(p) || freeze_rows.contains(p) {
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
return;
}
if in_corner {
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
return;
@ -1231,7 +1425,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
// Filter dropdown buttons.
if is_filter_button(sh, geo, p) {
app.grid.filter_menu = Some((c.col, p));
app.grid.filter_menu = Some((c.col, geo.mirror_pos(p)));
return;
}
// Point mode while editing a formula: clicking inserts a reference.
@ -1258,11 +1452,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
return;
}
if handle.contains(p) {
if on_handle(p) {
app.grid.drag = Drag::Fill { target: None };
return;
}
if on_border(cur_rect, p) && resp.drag_started() {
if on_cur_border(p) && resp.drag_started() {
app.grid.drag = Drag::Move { grab: c, to: None };
return;
}
@ -1359,8 +1553,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let w = (orig + (p.x - start) / geo.z).max(0.0);
app.toast =
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
// Inside a selection of whole columns, every selected column takes the new width
// (Ctrl-selected areas included); otherwise only the dragged one.
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
sel.current().a1()
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
} else {
RangeRef::cols(col, col).a1()
};
@ -1371,13 +1567,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let h = (orig + (p.y - start) / geo.z).max(0.0);
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
format!("{}:{}", sel.current().start.row + 1, sel.current().end.row + 1)
sel.ranges
.iter()
.filter(|r| r.is_full_rows())
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
.collect::<Vec<_>>()
.join(",")
} else {
format!("{}:{}", row + 1, row + 1)
};
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
coalesce_last_undo(app);
}
Drag::Freeze { cols, .. } => {
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
}
Drag::Fill { .. } => {
let src = sel.current();
let target = fill_target(src, pc);
@ -1421,12 +1625,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
Drag::Fill { target: Some(t) } => {
let src = sel.current();
if t != src {
let mode = if mods.command { "copy" } else { "series" };
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
let mode = if mods.command { "alternate" } else { "series" };
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
}
Drag::Freeze { cols, to: Some(to) } => {
let (rows_now, cols_now) = (geo.fr, geo.fc);
let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) };
if (rows, cols) != (rows_now, cols_now) {
// A1 (nothing left frozen) unfreezes.
app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()}));
}
}
Drag::Move { to: Some(to), .. } => {
let src = sel.current();
if to != src.start {
@ -1442,7 +1655,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Double-click the fill handle.
if resp.double_clicked()
&& let Some(p) = pos
&& handle.contains(p)
&& on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
@ -1478,9 +1691,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) };
let _ = app.session.run("selection.set", json!({"range": range.a1()}));
}
app.grid.header_menu = Some((p, rows));
app.grid.header_menu = Some((geo.mirror_pos(p), rows));
} else {
app.grid.context_menu = Some(p);
app.grid.context_menu = Some(geo.mirror_pos(p));
}
}
@ -1568,6 +1781,60 @@ fn on_border(r: Rect, p: Pos2) -> bool {
outer.contains(p) && !inner.contains(p)
}
/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip
/// on the frozen columns' right edge, and one in the left part of the row-header strip on the
/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the
/// header edge keeps resizing the column or row.
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
let x = geo.cells.left() + geo.frozen_w;
let y = geo.cells.top() + geo.frozen_h;
let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0));
(cols, rows)
}
/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest
/// the pointer, limited so the frozen pane leaves room for the scrolling one.
fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
if cols {
if p.x <= geo.cells.left() {
return 0;
}
let c = geo.col_at(sh, p.x);
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_COLS - 1)
} else {
if p.y <= geo.cells.top() {
return 0;
}
let rr = geo.row_at(sh, p.y);
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_ROWS - 1)
}
}
/// Paints the freeze handles and, while one is dragged, where the freeze line will go.
fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) {
let (cols, rows) = freeze_handles(geo);
let bar = Color32::from_gray(150);
p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar);
p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar);
if let Drag::Freeze { cols, to: Some(to) } = *drag {
let stroke = Stroke::new(2.0, t.accent);
if cols {
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke);
} else {
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke);
}
}
}
fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
let c = geo.col_at(sh, x);
for cc in [c, c.saturating_sub(1)] {
@ -1608,6 +1875,22 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::mo
}
}
fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
let Some(id) = app.selected_chart else { return false };
let kind = if sh.charts.iter().any(|object| object.id == id) {
"chart"
} else if sh.images.iter().any(|object| object.id == id) {
"image"
} else if sh.shapes.iter().any(|object| object.id == id) {
"shape"
} else {
return false;
};
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_chart = None;
true
}
pub fn follow_link(app: &mut SheetApp, target: &str) {
if target.contains("://") || target.starts_with("mailto:") {
if let Some(f) = &app.services.open_url {
@ -1656,7 +1939,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
egui::Event::Cut => {
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})),
egui::Event::Paste(text) => app.paste_external(text),
egui::Event::Text(text) => {
if text.chars().all(|c| !c.is_control()) && !text.is_empty() {
app.begin_edit(Some(text), false);
@ -1666,8 +1949,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
return;
}
}
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
use egui::Key;
if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) {
continue;
}
let cmd = m.command;
let shift = m.shift;
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
@ -1677,8 +1963,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
match key {
Key::ArrowUp => mv(app, -1, 0),
Key::ArrowDown => mv(app, 1, 0),
Key::ArrowLeft => mv(app, 0, -1),
Key::ArrowRight => mv(app, 0, 1),
// Arrows follow the screen: on a right-to-left sheet Left goes to the next column.
Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }),
Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }),
Key::PageDown if !cmd => mv(app, page, 0),
Key::PageUp if !cmd => mv(app, -page, 0),
Key::PageDown => app.run_or_alert("sheet.next", json!({})),
@ -1708,7 +1995,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
}
Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }),
Key::F2 => app.begin_edit(None, false),
Key::Delete if delete_selected_object(app, sh) => {}
Key::Delete => app.run_or_alert("edit.clearContents", json!({})),
Key::Backspace if delete_selected_object(app, sh) => {}
Key::Backspace => {
// Excel for Mac: Delete clears the cell and starts editing it.
app.run_or_alert("edit.clearContents", json!({}));
@ -1751,15 +2040,24 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left());
let h = (size * 1.3 * lines + 4.0).max(rect.height());
let erect = Rect::from_min_size(rect.min, vec2(w, h));
// Grows to the left on a right-to-left sheet.
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
let fill = match st.fill.pattern {
PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE),
_ => Color32::WHITE,
};
ui.painter().rect_filled(erect, 0.0, fill);
ui.painter().rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
let quadrant = Rect::from_min_max(
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
);
let clip = ui.clip_rect().intersect(quadrant);
let painter = ui.painter().with_clip_rect(clip);
painter.rect_filled(erect, 0.0, fill);
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
let id = egui::Id::new("gridcraft.cell_editor");
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
child.set_clip_rect(clip);
crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0);
}
@ -1803,6 +2101,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
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 search_key = egui::Id::new(("filter_search", 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);
let response = area.show(ui.ctx(), |ui| {
@ -1819,19 +2118,32 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
ui.separator();
ui.label(egui::RichText::new("Filter").strong());
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);
// A search narrows the list; Select All and Apply then only cover the matches.
let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default();
let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY));
if !was_open {
field.request_focus();
}
let needle = search.trim().to_lowercase();
let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle);
let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b);
let mut all_new = all;
if ui.checkbox(&mut all_new, "(Select All)").changed() {
for c in checks.iter_mut() {
let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" };
if ui.checkbox(&mut all_new, label).changed() {
for c in checks.iter_mut().filter(|(v, _)| matches(v)) {
c.1 = all_new;
}
}
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for (v, on) in checks.iter_mut() {
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
ui.checkbox(on, v.as_str());
}
});
ui.ctx().data_mut(|d| d.insert_temp(key, checks.clone()));
ui.ctx().data_mut(|d| {
d.insert_temp(key, checks.clone());
d.insert_temp(search_key, search.clone());
});
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
ui.separator();
ui.horizontal(|ui| {
if ui.button("Clear Filter").clicked() {
@ -1853,7 +2165,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
});
});
if close || popup_dismissed(&response.response, was_open) {
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
ui.ctx().data_mut(|d| {
d.remove::<Vec<(String, bool)>>(key);
d.remove::<String>(search_key);
});
app.grid.filter_menu = None;
}
}
@ -1954,7 +2269,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
/// The dropdown arrow beside a cell with an in-cell list validation.
pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?;
if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown {
if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) {
return None;
}
let r = geo.cell_rect(sh, c);
@ -1986,10 +2301,11 @@ fn paint_overlays(
&& let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active)))
&& dv.show_input
&& !(dv.input_title.is_empty() && dv.input_message.is_empty())
&& !geo.hidden_under_frozen(sh, sel.active)
{
let r = geo.cell_rect(sh, sel.active);
let at = pos2(r.left() + 8.0, r.bottom() + 6.0);
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
ui.set_max_width(220.0);
if !dv.input_title.is_empty() {
@ -2007,22 +2323,26 @@ fn paint_overlays(
continue;
}
let r = geo.cell_rect(sh, *c);
if !p.clip_rect().intersects(r) {
if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) {
continue;
}
let at = pos2(r.right() + 10.0, r.top());
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
ui.set_max_width(240.0);
ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
for (a, t) in &cm.replies {
ui.separator();
ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
}
});
});
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
ui.set_max_width(240.0);
ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
for (a, t) in &cm.replies {
ui.separator();
ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
}
});
},
);
}
// Hyperlink tooltip.
if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c))
@ -2047,7 +2367,9 @@ fn paint_overlays(
let Some(t) = target else { continue };
let tr = geo.range_rect(sh, t);
if !t.is_single() {
p.rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
for pane in geo.range_panes(t) {
p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
}
}
let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) };
p.line_segment([from, to], Stroke::new(1.5, blue));
@ -2072,8 +2394,9 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
};
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
let r = geo.cell_rect(sh, c);
let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
let mut close = false;
let response = 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(at).pivot(pivot).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| {
@ -2223,3 +2546,67 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
}
p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside);
}
#[cfg(test)]
mod pane_tests {
use super::*;
fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
}
#[test]
fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() {
// Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10.
let frozen_cols = ((2, 20), (0, 1));
let frozen_rows = ((0, 1), (2, 10));
let corner = ((0, 1), (0, 1));
let scrolled = ((2, 20), (2, 10));
let sel = rng(5, 4, 7, 6);
assert!(range_in_pane(sel, scrolled.0, scrolled.1));
assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1));
assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1));
assert!(!range_in_pane(sel, corner.0, corner.1));
}
#[test]
fn selection_across_the_freeze_line_is_drawn_in_both_panes() {
let sel = rng(5, 0, 7, 6);
assert!(range_in_pane(sel, (2, 20), (0, 1)));
assert!(range_in_pane(sel, (2, 20), (2, 10)));
}
/// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns.
fn frozen_geo(sh: &mut Sheet) -> Geo {
sh.freeze = Some((2, 2));
let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0);
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll)
}
#[test]
fn overlays_skip_cells_scrolled_under_the_frozen_columns() {
let mut sh = Sheet::new("S");
let geo = frozen_geo(&mut sh);
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2)));
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4)));
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5)));
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0)));
let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0));
assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2)));
}
#[test]
fn ranges_are_clipped_to_the_panes_that_hold_them() {
let mut sh = Sheet::new("S");
let geo = frozen_geo(&mut sh);
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]);
assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2);
assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4);
assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]);
// The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown.
let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom();
assert!(handle.x < split.x);
assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle)));
}
}

View File

@ -9,6 +9,9 @@
use serde::{Deserialize, Serialize};
mod cs;
mod de;
mod es;
mod ko;
mod pt;
mod ru;
@ -26,10 +29,12 @@ pub enum Language {
Ru,
#[serde(rename = "pt")]
PtBr,
De,
Cs,
}
impl Language {
pub const ALL: [Self; 7] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr];
pub const ALL: [Self; 9] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
@ -41,6 +46,8 @@ impl Language {
Self::Ko => "한국어",
Self::Ru => "Русский",
Self::PtBr => "Português (Brasil)",
Self::De => "Deutsch",
Self::Cs => "Čeština",
}
}
@ -54,6 +61,8 @@ impl Language {
Self::Ko => "ko",
Self::Ru => "ru",
Self::PtBr => "pt",
Self::De => "de",
Self::Cs => "cs",
}
}
@ -70,6 +79,8 @@ impl Language {
"ko" => Some(Self::Ko),
"ru" => Some(Self::Ru),
"pt" => Some(Self::PtBr),
"de" => Some(Self::De),
"cs" => Some(Self::Cs),
_ => None,
}
}
@ -97,12 +108,15 @@ impl Language {
/// "Orientation") is keyed `context|label`: English shows only the part after the `|`.
pub fn tr(self, text: &str) -> &str {
let translated = match self {
Self::En | Self::Es => None,
Self::En => None,
Self::Es => es::translate(text),
Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated),
Self::Zh => zh::translate(text),
Self::Ko => ko::translate(text),
Self::Ru => ru::translate(text),
Self::PtBr => pt::translate(text),
Self::De => de::translate(text),
Self::Cs => cs::translate(text),
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
@ -257,6 +271,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Link", "リンク"),
("Symbol", "記号"),
("Sparklines", "スパークライン"),
("Checkbox", "チェックボックス"),
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
("Header & Footer", "ヘッダーとフッター"),
("Draw with ink", "インクで描画"),
("Eraser", "消しゴム"),
@ -272,6 +288,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Remove Page Break", "改ページの解除"),
("Reset All Page Breaks", "すべての改ページを解除"),
("Print\nTitles", "印刷タイトル"),
("Sheet\nRight-to-Left", "シートを右から左へ"),
("Sheet Right-to-Left", "シートを右から左へ"),
("Narrow", "狭い"),
("Wide", "広い"),
("Normal", "標準"),
@ -313,6 +331,9 @@ const JAPANESE: &[(&str, &str)] = &[
("Group", "グループ化"),
("Ungroup", "グループ解除"),
("Subtotal", "小計"),
("Data\nAnalysis", "データ分析"),
("Data Analysis", "データ分析"),
("Solver", "ソルバー"),
("What-If\nAnalysis", "What-If 分析"),
("Data Table…", "データ テーブル…"),
("Scenario Manager…", "シナリオ マネージャー…"),
@ -322,6 +343,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Threaded Comments", "スレッド化されたコメント"),
("Show\nComments", "コメントの表示"),
("Protect\nWorkbook", "ブックの保護"),
("Allow Edit\nRanges", "範囲の編集を許可"),
("Allow Edit Ranges", "範囲の編集を許可"),
("Unprotect\nSheet", "シート保護の解除"),
("Protect Sheet…", "シートの保護…"),
("Comments", "コメント"),
@ -345,6 +368,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Light", "ライト"),
("Dark", "ダーク"),
("Interface language", "表示言語"),
("Interface size", "表示サイズ"),
("Auto", "自動"),
("Drag to freeze columns", "ドラッグして列を固定"),
("Drag to freeze rows", "ドラッグして行を固定"),
("Command\nPalette", "コマンド パレット"),
("Agent\nControl", "エージェント制御"),
("Action\nJournal", "操作ジャーナル"),
@ -353,6 +380,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Add Chart\nElement", "グラフ要素を追加"),
("Change\nChart Type", "グラフの種類の変更"),
("Switch\nRow/Column", "行/列の切り替え"),
("Select\nData", "データの選択"),
("Data Labels: None", "データ ラベル: なし"),
("Data Labels: Show", "データ ラベル: 表示"),
("Legend: None", "凡例: なし"),
@ -416,6 +444,67 @@ const JAPANESE: &[(&str, &str)] = &[
("More\nFunctions", "その他の関数"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
// File › Backstage view.
("File", "ファイル"),
("Back", "戻る"),
("New", "新規"),
("Open", "開く"),
("Info", "情報"),
("Save", "上書き保存"),
("Save As", "名前を付けて保存"),
("Export", "エクスポート"),
("Close", "閉じる"),
("Options", "オプション"),
("Blank workbook", "空白のブック"),
("Recent", "最近使ったアイテム"),
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
("More workbooks", "その他のブック"),
("Browse…", "参照…"),
("Not saved yet", "まだ保存されていません"),
("Location", "場所"),
("Sheets", "シート"),
("Protect Workbook", "ブックの保護"),
(
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted.",
"シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。",
),
(
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted.",
"シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。",
),
("Properties", "プロパティ"),
("Title", "タイトル"),
("Subject", "件名"),
("Author", "作成者"),
("Company", "会社名"),
("Keywords", "キーワード"),
("Description", "説明"),
("No workbook is open.", "ブックが開かれていません。"),
("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"),
("Download (.xlsx)", "ダウンロード (.xlsx)"),
("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"),
("Print Active Sheets", "作業中のシートを印刷"),
("Print Entire Workbook", "ブック全体を印刷"),
("Fit Sheet on One Page", "シートを 1 ページに印刷"),
("Page Setup…", "ページ設定…"),
("Page {page} of {pages}", "{page} / {pages} ページ"),
("Nothing to print.", "印刷する内容がありません。"),
("Previous page", "前のページ"),
("Next page", "次のページ"),
("Create PDF", "PDF の作成"),
("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"),
("Change File Type", "ファイルの種類の変更"),
("CSV (Comma delimited)", "CSV (コンマ区切り)"),
("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"),
("Web Page (.html)", "Web ページ (.html)"),
("Save the workbook as a web page.", "ブックを Web ページとして保存します。"),
("Options|General", "全般"),
("Options|Save", "保存"),
("Show formula bar", "数式バーを表示する"),
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
("Workbook calculation", "ブックの計算"),
("Enable iterative calculation", "反復計算を行う"),
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
];
#[cfg(test)]
@ -434,22 +523,36 @@ mod tests {
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
}
assert_eq!(Language::Ja.tr("Data"), "データ");
assert_eq!(Language::Zh.tr("Data"), "数据");
assert_eq!(Language::Ko.tr("Data"), "데이터");
assert_eq!(Language::Ru.tr("Data"), "Данные");
assert_eq!(Language::PtBr.tr("Data"), "Dados");
assert_eq!(Language::Es.tr("Data"), "Datos");
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::De.tr("Data"), "Daten");
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::PtBr.tr("Date"), "Data");
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
// Czech "Data" is spelled as in English, so probe it with a label that differs.
assert_eq!(Language::Cs.tr("Data"), "Data");
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
// A `context|label` key shows only its label in English and splits the translation.
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
assert_eq!(Language::Ja.tr("Orientation"), "方向");
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
assert_eq!(Language::parse("xx"), None);
}
@ -476,10 +579,15 @@ mod tests {
assert_eq!(Language::from_tag("zh-Hans-CN"), Some(Language::Zh));
assert_eq!(Language::from_tag("ko-KR"), Some(Language::Ko));
assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru));
assert_eq!(Language::from_tag("de-DE"), None);
assert_eq!(Language::from_tag("de-DE"), Some(Language::De));
assert_eq!(Language::from_tag("de_AT"), Some(Language::De));
assert_eq!(Language::from_tag("de-CH"), Some(Language::De));
assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr));
assert_eq!(Language::parse("pt"), Some(Language::PtBr));
assert_eq!(Language::parse("pt-br"), Some(Language::PtBr));
assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs));
assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs));
assert_eq!(Language::parse("cs"), Some(Language::Cs));
}
#[test]

View File

@ -0,0 +1,345 @@
//! Czech translations for the interface strings in i18n.rs.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Vložit",
"Paste Values" => "Vložit hodnoty",
"Paste Formulas" => "Vložit vzorce",
"Paste Formatting" => "Vložit formátování",
"Transpose" => "Transponovat",
"Paste Link" => "Vložit propojení",
"Paste Special…" => "Vložit jinak…",
"Cut (⌘X)" => "Vyjmout (⌘X)",
"Copy (⌘C)" => "Kopírovat (⌘C)",
"Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)",
"Bold (⌘B)" => "Tučné (⌘B)",
"Italic (⌘I)" => "Kurzíva (⌘I)",
"Underline (⌘U)" => "Podtržení (⌘U)",
"Double Underline" => "Dvojité podtržení",
"Strikethrough" => "Přeškrtnutí",
"Underline" => "Podtržení",
"Increase Font Size" => "Zvětšit písmo",
"Decrease Font Size" => "Zmenšit písmo",
"Wrap Text" => "Zalamovat text",
"Merge & Center" => "Sloučit a vycentrovat",
"Merge Cells" => "Sloučit buňky",
"Merge Across" => "Sloučit po řádcích",
"Unmerge Cells" => "Zrušit sloučení buněk",
"Fill Color" => "Barva výplně",
"Font Color" => "Barva písma",
"Borders" => "Ohraničení",
"Top\nAlign" => "Zarovnat\nnahoru",
"Middle\nAlign" => "Zarovnat\ndoprostřed",
"Bottom\nAlign" => "Zarovnat\ndolů",
"Left" => "Doleva",
"Right" => "Doprava",
"Center" => "Zarovnat na střed",
"Horizontal" => "Vodorovně",
"Vertical Text" => "Svislý text",
"Decrease Indent" => "Zmenšit odsazení",
"Increase Indent" => "Zvětšit odsazení",
"Orientation" => "Orientace textu",
"Page Layout|Orientation" => "Orientace",
"Angle Counterclockwise" => "Natočit proti směru hodin",
"Angle Clockwise" => "Natočit po směru hodin",
"Rotate Text Up" => "Otočit text nahoru",
"Rotate Text Down" => "Otočit text dolů",
"Accounting Number Format" => "Účetnický formát čísla",
"Percent Style" => "Styl procent",
"Comma Style" => "Styl s oddělovačem tisíců",
"Increase Decimal" => "Přidat desetinné místo",
"Decrease Decimal" => "Odebrat desetinné místo",
"More Accounting Formats…" => "Další účetnické formáty…",
"Conditional\nFormatting" => "Podmíněné\nformátování",
"Conditional Formatting" => "Podmíněné formátování",
"Format\nas Table" => "Formátovat\njako tabulku",
"Cell\nStyles" => "Styly\nbuněk",
"Format Cells…" => "Formát buněk…",
"Format Cell Alignment…" => "Zarovnání buněk…",
"Format\nPane" => "Podokno\nformátu",
"Format" => "Formát",
"Insert" => "Vložit",
"Insert Cells…" => "Vložit buňky…",
"Delete" => "Odstranit",
"Delete Cells…" => "Odstranit buňky…",
"Insert Sheet Rows" => "Vložit řádky listu",
"Insert Sheet Columns" => "Vložit sloupce listu",
"Delete Sheet Rows" => "Odstranit řádky listu",
"Delete Sheet Columns" => "Odstranit sloupce listu",
"Row Height…" => "Výška řádku…",
"AutoFit Row Height" => "Přizpůsobit výšku řádku",
"Column Width…" => "Šířka sloupce…",
"AutoFit Column Width" => "Přizpůsobit šířku sloupce",
"Default Width…" => "Výchozí šířka…",
"Hide Rows" => "Skrýt řádky",
"Unhide Rows" => "Zobrazit řádky",
"Hide Columns" => "Skrýt sloupce",
"Unhide Columns" => "Zobrazit sloupce",
"Lock Cell" => "Zamknout buňku",
"Clear" => "Vymazat",
"Clear All" => "Vymazat vše",
"Clear Formats" => "Vymazat formáty",
"Clear Contents" => "Vymazat obsah",
"Clear Comments and Notes" => "Vymazat komentáře a poznámky",
"Clear Hyperlinks" => "Vymazat hypertextové odkazy",
"AutoSum" => "Automatický\nsoučet",
"AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)",
"Flash\nFill" => "Dynamické\ndoplňování",
"Flash Fill" => "Dynamické doplňování",
"Fill" => "Vyplnit",
"Clear Print Area" => "Vymazat oblast tisku",
"Find &\nSelect" => "Najít a\nvybrat",
"Find…" => "Najít…",
"Replace…" => "Nahradit…",
"Go To…" => "Přejít na…",
"Go To Special…" => "Přejít na jinak…",
"PivotTable" => "Kontingenční\ntabulka",
"Recommended\nCharts" => "Doporučené\ngrafy",
"Table" => "Tabulka",
"Pictures" => "Obrázky",
"Shapes" => "Obrazce",
"Icons" => "Ikony",
"Text\nBox" => "Textové\npole",
"Comment" => "Komentář",
"New\nComment" => "Nový\nkomentář",
"New Note" => "Nová poznámka",
"Show/Hide Note" => "Zobrazit/skrýt poznámku",
"Header &\nFooter" => "Záhlaví a\nzápatí",
"Text to\nColumns" => "Text do\nsloupců",
"Link" => "Odkaz",
"Symbol" => "Symbol",
"Sparklines" => "Minigrafy",
"Header & Footer" => "Záhlaví a zápatí",
"Draw with ink" => "Kreslit od ruky",
"Eraser" => "Guma",
"Ink to\nShape" => "Rukopis\nna obrazec",
"Select\nObjects" => "Vybrat\nobjekty",
"Themes" => "Motivy",
"Margins" => "Okraje",
"Size" => "Velikost",
"Print\nArea" => "Oblast\ntisku",
"Set Print Area" => "Nastavit oblast tisku",
"Breaks" => "Konce",
"Insert Page Break" => "Vložit konec stránky",
"Remove Page Break" => "Odebrat konec stránky",
"Reset All Page Breaks" => "Obnovit všechny konce stránek",
"Print\nTitles" => "Tisk\nnázvů",
"Narrow" => "Úzké",
"Wide" => "Široké",
"Normal" => "Normální",
"Portrait" => "Na výšku",
"Landscape" => "Na šířku",
"Custom Margins…" => "Vlastní okraje…",
"Insert\nFunction" => "Vložit\nfunkci",
"Name\nManager" => "Správce\nnázvů",
"Define Name" => "Definovat název",
"Use in Formula" => "Použít ve vzorci",
"Create from Selection" => "Vytvořit z výběru",
"Trace Precedents" => "Sledovat předchůdce",
"Trace Dependents" => "Sledovat následníky",
"Remove Arrows" => "Odebrat šipky",
"Show Formulas" => "Zobrazit vzorce",
"Error Checking" => "Kontrola chyb",
"Evaluate Formula" => "Vyhodnotit vzorec",
"Watch Window" => "Okno sledování",
"Calculation\nOptions" => "Možnosti\nvýpočtu",
"Calculate Now" => "Přepočítat",
"Calculate Sheet" => "Přepočítat list",
"Automatic" => "Automaticky",
"Automatic Except for Data Tables" => "Automaticky kromě tabulek dat",
"Manual" => "Ručně",
"Sort &\nFilter" => "Seřadit a\nfiltrovat",
"Sort" => "Seřadit",
"Sort A to Z" => "Seřadit od A do Z",
"Sort Z to A" => "Seřadit od Z do A",
"Custom Sort…" => "Vlastní řazení…",
"Filter" => "Filtr",
"Reapply" => "Použít znovu",
"Get Data\n(Text/CSV)" => "Načíst data\n(text/CSV)",
"Remove\nDuplicates" => "Odebrat\nduplicity",
"Data\nValidation" => "Ověření\ndat",
"Data Validation" => "Ověření dat",
"Data Validation…" => "Ověření dat…",
"Circle Invalid Data" => "Zakroužkovat neplatná data",
"Clear Validation" => "Vymazat ověření",
"Group" => "Seskupit",
"Ungroup" => "Zrušit seskupení",
"Subtotal" => "Mezisoučet",
"What-If\nAnalysis" => "Citlivostní\nanalýza",
"Data Table…" => "Tabulka dat…",
"Scenario Manager…" => "Správce scénářů…",
"Goal Seek…" => "Hledat řešení…",
"Spelling" => "Pravopis",
"Check\nAccessibility" => "Zkontrolovat\npřístupnost",
"Threaded Comments" => "Komentáře ve vláknech",
"Show\nComments" => "Zobrazit\nkomentáře",
"Protect\nWorkbook" => "Zamknout\nsešit",
"Unprotect\nSheet" => "Odemknout\nlist",
"Protect Sheet…" => "Zamknout list…",
"Comments" => "Komentáře",
"Notes" => "Poznámky",
"Page Break\nPreview" => "Náhled\nkonců stránek",
"Page\nLayout" => "Rozložení\nstránky",
"Formula Bar" => "Řádek vzorců",
"Gridlines" => "Mřížka",
"Headings" => "Záhlaví",
"Zoom" => "Lupa",
"Checkbox" => "Zaškrtávací políčko",
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
"Sheet Right-to-Left" => "List zprava doleva",
"Data\nAnalysis" => "Analýza\ndat",
"Data Analysis" => "Analýza dat",
"Solver" => "Řešitel",
"Allow Edit\nRanges" => "Povolit úpravy\noblastí",
"Allow Edit Ranges" => "Povolit úpravy oblastí",
"Interface size" => "Velikost rozhraní",
"Auto" => "Automaticky",
"Drag to freeze columns" => "Přetažením ukotvíte sloupce",
"Drag to freeze rows" => "Přetažením ukotvíte řádky",
"Select\nData" => "Vybrat\ndata",
"File" => "Soubor",
"Back" => "Zpět",
"New" => "Nový",
"Open" => "Otevřít",
"Info" => "Informace",
"Save" => "Uložit",
"Save As" => "Uložit jako",
"Export" => "Exportovat",
"Close" => "Zavřít",
"Options" => "Možnosti",
"Blank workbook" => "Prázdný sešit",
"Recent" => "Naposledy použité",
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
"More workbooks" => "Další sešity",
"Browse…" => "Procházet…",
"Not saved yet" => "Zatím neuloženo",
"Location" => "Umístění",
"Sheets" => "Listy",
"Protect Workbook" => "Zamknout sešit",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"Struktura listů je zamčená: listy nelze přidávat, přesouvat, přejmenovávat ani odstraňovat."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Zamkne strukturu listů, aby listy nebylo možné přidávat, přesouvat, přejmenovávat ani odstraňovat."
}
"Properties" => "Vlastnosti",
"Title" => "Název",
"Subject" => "Předmět",
"Author" => "Autor",
"Company" => "Společnost",
"Keywords" => "Klíčová slova",
"Description" => "Popis",
"No workbook is open." => "Není otevřený žádný sešit.",
"Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.",
"Download (.xlsx)" => "Stáhnout (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.",
"Print Active Sheets" => "Vytisknout aktivní listy",
"Print Entire Workbook" => "Vytisknout celý sešit",
"Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku",
"Page Setup…" => "Vzhled stránky…",
"Page {page} of {pages}" => "Stránka {page} z {pages}",
"Nothing to print." => "Není co tisknout.",
"Previous page" => "Předchozí stránka",
"Next page" => "Další stránka",
"Create PDF" => "Vytvořit PDF",
"Save the sheet as a PDF document that keeps its page layout." => "Uloží list jako dokument PDF se zachováním rozložení stránky.",
"Change File Type" => "Změnit typ souboru",
"CSV (Comma delimited)" => "CSV (oddělený čárkami)",
"Save the active sheet as comma-separated text." => "Uloží aktivní list jako text oddělený čárkami.",
"Web Page (.html)" => "Webová stránka (.html)",
"Save the workbook as a web page." => "Uloží sešit jako webovou stránku.",
"Options|General" => "Obecné",
"Options|Save" => "Uložit",
"Show formula bar" => "Zobrazit řádek vzorců",
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
"Enable iterative calculation" => "Povolit iterativní výpočet",
"Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.",
"100%" => "100 %",
"Zoom to\nSelection" => "Přiblížit\nna výběr",
"Freeze\nPanes" => "Ukotvit\npříčky",
"Freeze Panes" => "Ukotvit příčky",
"Freeze Top Row" => "Ukotvit horní řádek",
"Freeze First Column" => "Ukotvit první sloupec",
"Unfreeze Panes" => "Uvolnit příčky",
"Dark Mode" => "Tmavý režim",
"Display theme" => "Vzhled",
"System" => "Podle systému",
"Light" => "Světlý",
"Dark" => "Tmavý",
"Interface language" => "Jazyk rozhraní",
"Command\nPalette" => "Paleta\npříkazů",
"Agent\nControl" => "Řízení\nagenty",
"Action\nJournal" => "Deník\nakcí",
"About\nGridCraft" => "O aplikaci\nGridCraft",
"Share" => "Sdílet",
"Add Chart\nElement" => "Přidat prvek\ngrafu",
"Change\nChart Type" => "Změnit\ntyp grafu",
"Switch\nRow/Column" => "Prohodit\nřádky/sloupce",
"Data Labels: None" => "Popisky dat: žádné",
"Data Labels: Show" => "Popisky dat: zobrazit",
"Legend: None" => "Legenda: žádná",
"Legend: Bottom" => "Legenda: dole",
"Legend: Top" => "Legenda: nahoře",
"Legend: Right" => "Legenda: vpravo",
"Gridlines: None" => "Mřížka: žádná",
"Gridlines: Show" => "Mřížka: zobrazit",
"Chart\nTitle" => "Název\ngrafu",
"Delete\nChart" => "Odstranit\ngraf",
"Convert\nto Range" => "Převést\nna oblast",
"Table\nStyles" => "Styly\ntabulky",
"Top 10 Items" => "Prvních 10 položek",
"Top 10%" => "Prvních 10 %",
"Bottom 10 Items" => "Posledních 10 položek",
"Bottom 10%" => "Posledních 10 %",
"Above Average" => "Nad průměrem",
"Below Average" => "Pod průměrem",
"Clear Rules from Entire Sheet" => "Vymazat pravidla z celého listu",
"Clear Rules from Selected Cells" => "Vymazat pravidla z vybraných buněk",
"Constants" => "Konstanty",
"Data Bars" => "Datové pruhy",
"Color Scales" => "Barevné škály",
"Icon Sets" => "Sady ikon",
"Home" => "Domů",
"Draw" => "Kreslení",
"Page Layout" => "Rozložení stránky",
"Formulas" => "Vzorce",
"Data" => "Data",
"Review" => "Revize",
"View" => "Zobrazení",
"Automate" => "Automatizace",
"Table Design" => "Návrh tabulky",
"Chart Design" => "Návrh grafu",
"Ready" => "Připraveno",
"Enter" => "Zadat",
"Edit" => "Úpravy",
"Point" => "Výběr odkazu",
"Calculate" => "Přepočítat",
"Select destination and press Enter or choose Paste" => "Vyberte cíl a stiskněte Enter nebo zvolte Vložit",
"General" => "Obecný",
"Number" => "Číslo",
"Currency" => "Měna",
"Accounting" => "Účetnický",
"Date" => "Datum",
"Time" => "Čas",
"Percentage" => "Procenta",
"Fraction" => "Zlomek",
"Scientific" => "Vědecký",
"Text" => "Text",
"Special" => "Speciální",
"Custom" => "Vlastní",
"Short Date" => "Krátké datum",
"Long Date" => "Dlouhé datum",
"More Number Formats…" => "Další formáty čísel…",
"Financial" => "Finanční",
"Logical" => "Logické",
"Date &\nTime" => "Datum a\nčas",
"Lookup &\nReference" => "Vyhledávání\na odkazy",
"Math &\nTrig" => "Matematika a\ntrigonometrie",
"More\nFunctions" => "Další\nfunkce",
"Sheet Options|View" => "Zobrazit",
"Print" => "Tisknout",
_ => return None,
})
}

View File

@ -0,0 +1,348 @@
//! German translations for the interface strings in i18n.rs. Labels follow the wording German
//! spreadsheet users know; anything absent falls back to English.
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Einfügen",
"Paste Values" => "Werte einfügen",
"Paste Formulas" => "Formeln einfügen",
"Paste Formatting" => "Formatierung einfügen",
"Transpose" => "Transponieren",
"Paste Link" => "Verknüpfung einfügen",
"Paste Special…" => "Inhalte einfügen…",
"Cut (⌘X)" => "Ausschneiden (⌘X)",
"Copy (⌘C)" => "Kopieren (⌘C)",
"Format Painter (double-click to keep it on)" => "Format übertragen (Doppelklick zum Beibehalten)",
"Bold (⌘B)" => "Fett (⌘B)",
"Italic (⌘I)" => "Kursiv (⌘I)",
"Underline (⌘U)" => "Unterstrichen (⌘U)",
"Double Underline" => "Doppelt unterstrichen",
"Strikethrough" => "Durchgestrichen",
"Underline" => "Unterstrichen",
"Increase Font Size" => "Schriftgrad vergrößern",
"Decrease Font Size" => "Schriftgrad verkleinern",
"Wrap Text" => "Zeilenumbruch",
"Merge & Center" => "Verbinden und zentrieren",
"Merge Cells" => "Zellen verbinden",
"Merge Across" => "Zeilenweise verbinden",
"Unmerge Cells" => "Zellverbindung aufheben",
"Fill Color" => "Füllfarbe",
"Font Color" => "Schriftfarbe",
"Borders" => "Rahmen",
"Top\nAlign" => "Oben\nausrichten",
"Middle\nAlign" => "Vertikal\nzentrieren",
"Bottom\nAlign" => "Unten\nausrichten",
"Left" => "Links",
"Right" => "Rechts",
"Center" => "Zentriert",
"Horizontal" => "Horizontal",
"Vertical Text" => "Vertikaler Text",
"Decrease Indent" => "Einzug verkleinern",
"Increase Indent" => "Einzug vergrößern",
"Orientation" => "Ausrichtung",
"Angle Counterclockwise" => "Gegen den Uhrzeigersinn neigen",
"Angle Clockwise" => "Im Uhrzeigersinn neigen",
"Rotate Text Up" => "Text nach oben drehen",
"Rotate Text Down" => "Text nach unten drehen",
"Accounting Number Format" => "Buchhaltungszahlenformat",
"Percent Style" => "Prozentformat",
"Comma Style" => "Kommaformat",
"Increase Decimal" => "Dezimalstelle hinzufügen",
"Decrease Decimal" => "Dezimalstelle entfernen",
"More Accounting Formats…" => "Weitere Buchhaltungsformate…",
"Conditional\nFormatting" => "Bedingte\nFormatierung",
"Conditional Formatting" => "Bedingte Formatierung",
"Format\nas Table" => "Als Tabelle\nformatieren",
"Cell\nStyles" => "Zell-\nformate",
"Format Cells…" => "Zellen formatieren…",
"Format Cell Alignment…" => "Zellausrichtung…",
"Format\nPane" => "Format-\nbereich",
"Format" => "Format",
"Insert" => "Einfügen",
"Insert Cells…" => "Zellen einfügen…",
"Delete" => "Löschen",
"Delete Cells…" => "Zellen löschen…",
"Insert Sheet Rows" => "Blattzeilen einfügen",
"Insert Sheet Columns" => "Blattspalten einfügen",
"Delete Sheet Rows" => "Blattzeilen löschen",
"Delete Sheet Columns" => "Blattspalten löschen",
"Row Height…" => "Zeilenhöhe…",
"AutoFit Row Height" => "Zeilenhöhe automatisch anpassen",
"Column Width…" => "Spaltenbreite…",
"AutoFit Column Width" => "Spaltenbreite automatisch anpassen",
"Default Width…" => "Standardbreite…",
"Hide Rows" => "Zeilen ausblenden",
"Unhide Rows" => "Zeilen einblenden",
"Hide Columns" => "Spalten ausblenden",
"Unhide Columns" => "Spalten einblenden",
"Lock Cell" => "Zelle sperren",
"Clear" => "Löschen",
"Clear All" => "Alles löschen",
"Clear Formats" => "Formate löschen",
"Clear Contents" => "Inhalte löschen",
"Clear Comments and Notes" => "Kommentare und Notizen löschen",
"Clear Hyperlinks" => "Hyperlinks löschen",
"AutoSum" => "AutoSumme",
"AutoSum (⌘⇧T)" => "AutoSumme (⌘⇧T)",
"Flash\nFill" => "Blitz-\nvorschau",
"Flash Fill" => "Blitzvorschau",
"Fill" => "Ausfüllen",
"Clear Print Area" => "Druckbereich aufheben",
"Find &\nSelect" => "Suchen und\nAuswählen",
"Find…" => "Suchen…",
"Replace…" => "Ersetzen…",
"Go To…" => "Gehe zu…",
"Go To Special…" => "Inhalte auswählen…",
"PivotTable" => "PivotTable",
"Recommended\nCharts" => "Empfohlene\nDiagramme",
"Table" => "Tabelle",
"Pictures" => "Bilder",
"Shapes" => "Formen",
"Icons" => "Symbole",
"Text\nBox" => "Text-\nfeld",
"Comment" => "Kommentar",
"New\nComment" => "Neuer\nKommentar",
"New Note" => "Neue Notiz",
"Show/Hide Note" => "Notiz anzeigen/ausblenden",
"Header &\nFooter" => "Kopf- und\nFußzeile",
"Text to\nColumns" => "Text in\nSpalten",
"Link" => "Link",
"Symbol" => "Symbol",
"Sparklines" => "Sparklines",
"Header & Footer" => "Kopf- und Fußzeile",
"Draw with ink" => "Mit Freihand zeichnen",
"Eraser" => "Radierer",
"Ink to\nShape" => "Freihand in\nForm umwandeln",
"Select\nObjects" => "Objekte\nauswählen",
"Themes" => "Designs",
"Margins" => "Seitenränder",
"Size" => "Größe",
"Print\nArea" => "Druck-\nbereich",
"Set Print Area" => "Druckbereich festlegen",
"Breaks" => "Umbrüche",
"Insert Page Break" => "Seitenumbruch einfügen",
"Remove Page Break" => "Seitenumbruch entfernen",
"Reset All Page Breaks" => "Alle Seitenumbrüche zurücksetzen",
"Print\nTitles" => "Druck-\ntitel",
"Narrow" => "Schmal",
"Wide" => "Breit",
"Normal" => "Normal",
"Portrait" => "Hochformat",
"Landscape" => "Querformat",
"Custom Margins…" => "Benutzerdefinierte Seitenränder…",
"Insert\nFunction" => "Funktion\neinfügen",
"Name\nManager" => "Namens-\nmanager",
"Define Name" => "Namen definieren",
"Use in Formula" => "In Formel verwenden",
"Create from Selection" => "Aus Auswahl erstellen",
"Trace Precedents" => "Spur zum Vorgänger",
"Trace Dependents" => "Spur zum Nachfolger",
"Remove Arrows" => "Pfeile entfernen",
"Show Formulas" => "Formeln anzeigen",
"Error Checking" => "Fehlerüberprüfung",
"Evaluate Formula" => "Formel auswerten",
"Watch Window" => "Überwachungsfenster",
"Calculation\nOptions" => "Berechnungs-\noptionen",
"Calculate Now" => "Jetzt berechnen",
"Calculate Sheet" => "Blatt berechnen",
"Automatic" => "Automatisch",
"Automatic Except for Data Tables" => "Automatisch, außer für Datentabellen",
"Manual" => "Manuell",
"Sort &\nFilter" => "Sortieren und\nFiltern",
"Sort" => "Sortieren",
"Sort A to Z" => "Von A bis Z sortieren",
"Sort Z to A" => "Von Z bis A sortieren",
"Custom Sort…" => "Benutzerdefiniertes Sortieren…",
"Filter" => "Filtern",
"Reapply" => "Erneut anwenden",
"Get Data\n(Text/CSV)" => "Daten abrufen\n(Text/CSV)",
"Remove\nDuplicates" => "Duplikate\nentfernen",
"Data\nValidation" => "Daten-\nüberprüfung",
"Data Validation" => "Datenüberprüfung",
"Data Validation…" => "Datenüberprüfung…",
"Circle Invalid Data" => "Ungültige Daten einkreisen",
"Clear Validation" => "Datenüberprüfung löschen",
"Group" => "Gruppieren",
"Ungroup" => "Gruppierung aufheben",
"Subtotal" => "Teilergebnis",
"What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse",
"Data Table…" => "Datentabelle…",
"Scenario Manager…" => "Szenario-Manager…",
"Goal Seek…" => "Zielwertsuche…",
"Spelling" => "Rechtschreibung",
"Check\nAccessibility" => "Barrierefreiheit\nprüfen",
"Threaded Comments" => "Threadkommentare",
"Show\nComments" => "Kommentare\nanzeigen",
"Protect\nWorkbook" => "Arbeitsmappe\nschützen",
"Unprotect\nSheet" => "Blattschutz\naufheben",
"Protect Sheet…" => "Blatt schützen…",
"Comments" => "Kommentare",
"Notes" => "Notizen",
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
"Page\nLayout" => "Seiten-\nlayout",
"Formula Bar" => "Formelleiste",
"Gridlines" => "Gitternetzlinien",
"Headings" => "Überschriften",
"Zoom" => "Zoom",
"Checkbox" => "Kontrollkästchen",
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
"Sheet Right-to-Left" => "Blatt von rechts nach links",
"Data\nAnalysis" => "Daten-\nanalyse",
"Data Analysis" => "Datenanalyse",
"Solver" => "Solver",
"Allow Edit\nRanges" => "Bearbeiten von\nBereichen zulassen",
"Allow Edit Ranges" => "Bearbeiten von Bereichen zulassen",
"Drag to freeze columns" => "Ziehen, um Spalten zu fixieren",
"Drag to freeze rows" => "Ziehen, um Zeilen zu fixieren",
"Select\nData" => "Daten\nauswählen",
"File" => "Datei",
"Back" => "Zurück",
"New" => "Neu",
"Open" => "Öffnen",
"Info" => "Informationen",
"Save" => "Speichern",
"Save As" => "Speichern unter",
"Export" => "Exportieren",
"Close" => "Schließen",
"Options" => "Optionen",
"Blank workbook" => "Leere Arbeitsmappe",
"Recent" => "Zuletzt verwendet",
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
"More workbooks" => "Weitere Arbeitsmappen",
"Browse…" => "Durchsuchen…",
"Not saved yet" => "Noch nicht gespeichert",
"Location" => "Speicherort",
"Sheets" => "Blätter",
"Protect Workbook" => "Arbeitsmappe schützen",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"Die Blattstruktur ist gesperrt: Blätter können nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Sperrt die Blattstruktur, sodass Blätter nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden können."
}
"Properties" => "Eigenschaften",
"Title" => "Titel",
"Subject" => "Thema",
"Author" => "Autor",
"Company" => "Firma",
"Keywords" => "Stichwörter",
"Description" => "Beschreibung",
"No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.",
"Save the workbook under another name or in another folder." => {
"Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner."
}
"Download (.xlsx)" => "Herunterladen (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.",
"Print Active Sheets" => "Aktive Blätter drucken",
"Print Entire Workbook" => "Gesamte Arbeitsmappe drucken",
"Fit Sheet on One Page" => "Blatt auf einer Seite darstellen",
"Page Setup…" => "Seite einrichten…",
"Page {page} of {pages}" => "Seite {page} von {pages}",
"Nothing to print." => "Nichts zu drucken.",
"Previous page" => "Vorherige Seite",
"Next page" => "Nächste Seite",
"Create PDF" => "PDF erstellen",
"Save the sheet as a PDF document that keeps its page layout." => "Speichert das Blatt als PDF-Dokument mit unverändertem Seitenlayout.",
"Change File Type" => "Dateityp ändern",
"CSV (Comma delimited)" => "CSV (Trennzeichen-getrennt)",
"Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Kommas getrennten Text.",
"Web Page (.html)" => "Webseite (.html)",
"Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.",
"Options|General" => "Allgemein",
"Options|Save" => "Speichern",
"Show formula bar" => "Bearbeitungsleiste anzeigen",
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
"Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.",
"Interface size" => "Größe der Benutzeroberfläche",
"Auto" => "Automatisch",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom auf die\nAuswahl",
"Freeze\nPanes" => "Fenster\nfixieren",
"Freeze Panes" => "Fenster fixieren",
"Freeze Top Row" => "Oberste Zeile fixieren",
"Freeze First Column" => "Erste Spalte fixieren",
"Unfreeze Panes" => "Fixierung aufheben",
"Dark Mode" => "Dunkler Modus",
"Interface language" => "Anzeigesprache",
"Command\nPalette" => "Befehls-\npalette",
"Agent\nControl" => "Agenten-\nsteuerung",
"Action\nJournal" => "Aktions-\nprotokoll",
"About\nGridCraft" => "Über\nGridCraft",
"Share" => "Teilen",
"Add Chart\nElement" => "Diagrammelement\nhinzufügen",
"Change\nChart Type" => "Diagrammtyp\nändern",
"Switch\nRow/Column" => "Zeile/Spalte\nwechseln",
"Data Labels: None" => "Datenbeschriftungen: Keine",
"Data Labels: Show" => "Datenbeschriftungen: Anzeigen",
"Legend: None" => "Legende: Keine",
"Legend: Bottom" => "Legende: Unten",
"Legend: Top" => "Legende: Oben",
"Legend: Right" => "Legende: Rechts",
"Gridlines: None" => "Gitternetzlinien: Keine",
"Gridlines: Show" => "Gitternetzlinien: Anzeigen",
"Chart\nTitle" => "Diagramm-\ntitel",
"Delete\nChart" => "Diagramm\nlöschen",
"Convert\nto Range" => "In Bereich\nkonvertieren",
"Table\nStyles" => "Tabellen-\nformate",
"Top 10 Items" => "Obere 10 Elemente",
"Top 10%" => "Obere 10 %",
"Bottom 10 Items" => "Untere 10 Elemente",
"Bottom 10%" => "Untere 10 %",
"Above Average" => "Überdurchschnittlich",
"Below Average" => "Unterdurchschnittlich",
"Clear Rules from Entire Sheet" => "Regeln im gesamten Blatt löschen",
"Clear Rules from Selected Cells" => "Regeln in den ausgewählten Zellen löschen",
"Constants" => "Konstanten",
"Data Bars" => "Datenbalken",
"Color Scales" => "Farbskalen",
"Icon Sets" => "Symbolsätze",
"Home" => "Start",
"Draw" => "Zeichnen",
"Page Layout" => "Seitenlayout",
"Formulas" => "Formeln",
"Data" => "Daten",
"Review" => "Überprüfen",
"View" => "Ansicht",
"Automate" => "Automatisieren",
"Table Design" => "Tabellenentwurf",
"Chart Design" => "Diagrammentwurf",
"Ready" => "Bereit",
"Enter" => "Eingeben",
"Edit" => "Bearbeiten",
"Point" => "Zeigen",
"Calculate" => "Berechnen",
"Select destination and press Enter or choose Paste" => "Ziel auswählen und die Eingabetaste drücken oder „Einfügen“ wählen",
"General" => "Standard",
"Number" => "Zahl",
"Currency" => "Währung",
"Accounting" => "Buchhaltung",
"Date" => "Datum",
"Time" => "Uhrzeit",
"Percentage" => "Prozentsatz",
"Fraction" => "Bruch",
"Scientific" => "Wissenschaftlich",
"Text" => "Text",
"Special" => "Sonderformat",
"Custom" => "Benutzerdefiniert",
"Short Date" => "Kurzes Datum",
"Long Date" => "Langes Datum",
"More Number Formats…" => "Weitere Zahlenformate…",
"Financial" => "Finanzmathematik",
"Logical" => "Logisch",
"Date &\nTime" => "Datum &\nUhrzeit",
"Lookup &\nReference" => "Nachschlagen &\nVerweisen",
"Math &\nTrig" => "Mathematik &\nTrigonometrie",
"More\nFunctions" => "Weitere\nFunktionen",
"Page Layout|Orientation" => "Seitenausrichtung",
"Display theme" => "Anzeigedesign",
"System" => "System",
"Light" => "Hell",
"Dark" => "Dunkel",
"Sheet Options|View" => "Anzeige",
"Print" => "Drucken",
_ => return None,
})
}

View File

@ -0,0 +1,347 @@
//! Spanish translations for the interface strings in i18n.rs. Labels follow Excel's Spanish
//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back
//! to English. Formula names and separators are localized separately (`formula_locale`).
pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text {
"Paste" => "Pegar",
"Paste Values" => "Pegar valores",
"Paste Formulas" => "Pegar fórmulas",
"Paste Formatting" => "Pegar formato",
"Transpose" => "Transponer",
"Paste Link" => "Pegar vínculo",
"Paste Special…" => "Pegado especial…",
"Cut (⌘X)" => "Cortar (⌘X)",
"Copy (⌘C)" => "Copiar (⌘C)",
"Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)",
"Bold (⌘B)" => "Negrita (⌘B)",
"Italic (⌘I)" => "Cursiva (⌘I)",
"Underline (⌘U)" => "Subrayado (⌘U)",
"Double Underline" => "Subrayado doble",
"Strikethrough" => "Tachado",
"Underline" => "Subrayado",
"Increase Font Size" => "Aumentar tamaño de fuente",
"Decrease Font Size" => "Disminuir tamaño de fuente",
"Wrap Text" => "Ajustar texto",
"Merge & Center" => "Combinar y centrar",
"Merge Cells" => "Combinar celdas",
"Merge Across" => "Combinar horizontalmente",
"Unmerge Cells" => "Separar celdas",
"Fill Color" => "Color de relleno",
"Font Color" => "Color de fuente",
"Borders" => "Bordes",
"Top\nAlign" => "Alinear en la\nparte superior",
"Middle\nAlign" => "Alinear en\nel medio",
"Bottom\nAlign" => "Alinear en la\nparte inferior",
"Left" => "Izquierda",
"Right" => "Derecha",
"Center" => "Centro",
"Horizontal" => "Horizontal",
"Vertical Text" => "Texto vertical",
"Decrease Indent" => "Disminuir sangría",
"Increase Indent" => "Aumentar sangría",
"Orientation" => "Orientación",
"Angle Counterclockwise" => "Ángulo ascendente",
"Angle Clockwise" => "Ángulo descendente",
"Rotate Text Up" => "Girar texto hacia arriba",
"Rotate Text Down" => "Girar texto hacia abajo",
"Accounting Number Format" => "Formato de número de contabilidad",
"Percent Style" => "Estilo porcentual",
"Comma Style" => "Estilo de millares",
"Increase Decimal" => "Aumentar decimales",
"Decrease Decimal" => "Disminuir decimales",
"More Accounting Formats…" => "Más formatos de contabilidad…",
"Conditional\nFormatting" => "Formato\ncondicional",
"Conditional Formatting" => "Formato condicional",
"Format\nas Table" => "Dar formato\ncomo tabla",
"Cell\nStyles" => "Estilos\nde celda",
"Format Cells…" => "Formato de celdas…",
"Format Cell Alignment…" => "Alineación de celdas…",
"Format\nPane" => "Panel\nde formato",
"Format" => "Formato",
"Insert" => "Insertar",
"Insert Cells…" => "Insertar celdas…",
"Delete" => "Eliminar",
"Delete Cells…" => "Eliminar celdas…",
"Insert Sheet Rows" => "Insertar filas de hoja",
"Insert Sheet Columns" => "Insertar columnas de hoja",
"Delete Sheet Rows" => "Eliminar filas de hoja",
"Delete Sheet Columns" => "Eliminar columnas de hoja",
"Row Height…" => "Alto de fila…",
"AutoFit Row Height" => "Autoajustar alto de fila",
"Column Width…" => "Ancho de columna…",
"AutoFit Column Width" => "Autoajustar ancho de columna",
"Default Width…" => "Ancho predeterminado…",
"Hide Rows" => "Ocultar filas",
"Unhide Rows" => "Mostrar filas",
"Hide Columns" => "Ocultar columnas",
"Unhide Columns" => "Mostrar columnas",
"Lock Cell" => "Bloquear celda",
"Clear" => "Borrar",
"Clear All" => "Borrar todo",
"Clear Formats" => "Borrar formatos",
"Clear Contents" => "Borrar contenido",
"Clear Comments and Notes" => "Borrar comentarios y notas",
"Clear Hyperlinks" => "Borrar hipervínculos",
"AutoSum" => "Autosuma",
"AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)",
"Flash\nFill" => "Relleno\nrápido",
"Flash Fill" => "Relleno rápido",
"Fill" => "Rellenar",
"Clear Print Area" => "Borrar área de impresión",
"Find &\nSelect" => "Buscar y\nseleccionar",
"Find…" => "Buscar…",
"Replace…" => "Reemplazar…",
"Go To…" => "Ir a…",
"Go To Special…" => "Ir a Especial…",
"PivotTable" => "Tabla dinámica",
"Recommended\nCharts" => "Gráficos\nrecomendados",
"Table" => "Tabla",
"Pictures" => "Imágenes",
"Shapes" => "Formas",
"Icons" => "Iconos",
"Text\nBox" => "Cuadro\nde texto",
"Comment" => "Comentario",
"New\nComment" => "Nuevo\ncomentario",
"New Note" => "Nueva nota",
"Show/Hide Note" => "Mostrar u ocultar nota",
"Header &\nFooter" => "Encabezado y\npie de página",
"Text to\nColumns" => "Texto en\ncolumnas",
"Link" => "Vínculo",
"Symbol" => "Símbolo",
"Sparklines" => "Minigráficos",
"Header & Footer" => "Encabezado y pie de página",
"Draw with ink" => "Dibujar con lápiz",
"Eraser" => "Borrador",
"Ink to\nShape" => "Entrada de lápiz\na forma",
"Select\nObjects" => "Seleccionar\nobjetos",
"Themes" => "Temas",
"Margins" => "Márgenes",
"Size" => "Tamaño",
"Print\nArea" => "Área de\nimpresión",
"Set Print Area" => "Establecer área de impresión",
"Breaks" => "Saltos",
"Insert Page Break" => "Insertar salto de página",
"Remove Page Break" => "Quitar salto de página",
"Reset All Page Breaks" => "Restablecer todos los saltos de página",
"Print\nTitles" => "Imprimir\ntítulos",
"Narrow" => "Estrecho",
"Wide" => "Ancho",
"Normal" => "Normal",
"Portrait" => "Vertical",
"Landscape" => "Horizontal",
"Custom Margins…" => "Márgenes personalizados…",
"Insert\nFunction" => "Insertar\nfunción",
"Name\nManager" => "Administrador\nde nombres",
"Define Name" => "Definir nombre",
"Use in Formula" => "Utilizar en la fórmula",
"Create from Selection" => "Crear desde la selección",
"Trace Precedents" => "Rastrear precedentes",
"Trace Dependents" => "Rastrear dependientes",
"Remove Arrows" => "Quitar flechas",
"Show Formulas" => "Mostrar fórmulas",
"Error Checking" => "Comprobación de errores",
"Evaluate Formula" => "Evaluar fórmula",
"Watch Window" => "Ventana Inspección",
"Calculation\nOptions" => "Opciones\nde cálculo",
"Calculate Now" => "Calcular ahora",
"Calculate Sheet" => "Calcular hoja",
"Automatic" => "Automático",
"Automatic Except for Data Tables" => "Automático excepto en las tablas de datos",
"Manual" => "Manual",
"Sort &\nFilter" => "Ordenar\ny filtrar",
"Sort" => "Ordenar",
"Sort A to Z" => "Ordenar de A a Z",
"Sort Z to A" => "Ordenar de Z a A",
"Custom Sort…" => "Orden personalizado…",
"Filter" => "Filtro",
"Reapply" => "Volver a aplicar",
"Get Data\n(Text/CSV)" => "Obtener datos\n(texto/CSV)",
"Remove\nDuplicates" => "Quitar\nduplicados",
"Data\nValidation" => "Validación\nde datos",
"Data Validation" => "Validación de datos",
"Data Validation…" => "Validación de datos…",
"Circle Invalid Data" => "Rodear con un círculo datos no válidos",
"Clear Validation" => "Borrar círculos de validación",
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Subtotal" => "Subtotal",
"What-If\nAnalysis" => "Análisis de\nhipótesis",
"Data Table…" => "Tabla de datos…",
"Scenario Manager…" => "Administrador de escenarios…",
"Goal Seek…" => "Buscar objetivo…",
"Spelling" => "Ortografía",
"Check\nAccessibility" => "Comprobar\naccesibilidad",
"Threaded Comments" => "Comentarios en conversación",
"Show\nComments" => "Mostrar\ncomentarios",
"Protect\nWorkbook" => "Proteger\nlibro",
"Unprotect\nSheet" => "Desproteger\nhoja",
"Protect Sheet…" => "Proteger hoja…",
"Comments" => "Comentarios",
"Notes" => "Notas",
"Page Break\nPreview" => "Vista previa de\nsalto de página",
"Page\nLayout" => "Diseño\nde página",
"Formula Bar" => "Barra de fórmulas",
"Gridlines" => "Líneas de cuadrícula",
"Headings" => "Encabezados",
"Zoom" => "Zoom",
"Checkbox" => "Casilla",
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
"Data\nAnalysis" => "Análisis\nde datos",
"Data Analysis" => "Análisis de datos",
"Solver" => "Solver",
"Allow Edit\nRanges" => "Permitir editar\nrangos",
"Allow Edit Ranges" => "Permitir editar rangos",
"Drag to freeze columns" => "Arrastrar para inmovilizar columnas",
"Drag to freeze rows" => "Arrastrar para inmovilizar filas",
"Select\nData" => "Seleccionar\ndatos",
"File" => "Archivo",
"Back" => "Atrás",
"New" => "Nuevo",
"Open" => "Abrir",
"Info" => "Información",
"Save" => "Guardar",
"Save As" => "Guardar como",
"Export" => "Exportar",
"Close" => "Cerrar",
"Options" => "Opciones",
"Blank workbook" => "Libro en blanco",
"Recent" => "Recientes",
"No recent workbooks yet." => "Todavía no hay libros recientes.",
"More workbooks" => "Más libros",
"Browse…" => "Examinar…",
"Not saved yet" => "Aún no se ha guardado",
"Location" => "Ubicación",
"Sheets" => "Hojas",
"Protect Workbook" => "Proteger libro",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"La estructura de hojas está bloqueada: no se pueden agregar, mover, cambiar de nombre ni eliminar hojas."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Bloquea la estructura de hojas para que no se puedan agregar, mover, cambiar de nombre ni eliminar hojas."
}
"Properties" => "Propiedades",
"Title" => "Título",
"Subject" => "Asunto",
"Author" => "Autor",
"Company" => "Organización",
"Keywords" => "Palabras clave",
"Description" => "Descripción",
"No workbook is open." => "No hay ningún libro abierto.",
"Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.",
"Download (.xlsx)" => "Descargar (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.",
"Print Active Sheets" => "Imprimir hojas activas",
"Print Entire Workbook" => "Imprimir todo el libro",
"Fit Sheet on One Page" => "Ajustar hoja en una página",
"Page Setup…" => "Configurar página…",
"Page {page} of {pages}" => "Página {page} de {pages}",
"Nothing to print." => "No hay nada que imprimir.",
"Previous page" => "Página anterior",
"Next page" => "Página siguiente",
"Create PDF" => "Crear PDF",
"Save the sheet as a PDF document that keeps its page layout." => "Guarda la hoja como documento PDF conservando el diseño de página.",
"Change File Type" => "Cambiar el tipo de archivo",
"CSV (Comma delimited)" => "CSV (delimitado por comas)",
"Save the active sheet as comma-separated text." => "Guarda la hoja activa como texto separado por comas.",
"Web Page (.html)" => "Página web (.html)",
"Save the workbook as a web page." => "Guarda el libro como página web.",
"Options|General" => "General",
"Options|Save" => "Guardar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
"Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.",
"Interface size" => "Tamaño de la interfaz",
"Auto" => "Automático",
"100%" => "100%",
"Zoom to\nSelection" => "Ampliar\nselección",
"Freeze\nPanes" => "Inmovilizar\npaneles",
"Freeze Panes" => "Inmovilizar paneles",
"Freeze Top Row" => "Inmovilizar fila superior",
"Freeze First Column" => "Inmovilizar primera columna",
"Unfreeze Panes" => "Movilizar paneles",
"Dark Mode" => "Modo oscuro",
"Interface language" => "Idioma de la interfaz",
"Command\nPalette" => "Paleta de\ncomandos",
"Agent\nControl" => "Control\ndel agente",
"Action\nJournal" => "Registro\nde acciones",
"About\nGridCraft" => "Acerca de\nGridCraft",
"Share" => "Compartir",
"Add Chart\nElement" => "Agregar elemento\nde gráfico",
"Change\nChart Type" => "Cambiar tipo\nde gráfico",
"Switch\nRow/Column" => "Cambiar entre\nfilas y columnas",
"Data Labels: None" => "Etiquetas de datos: ninguna",
"Data Labels: Show" => "Etiquetas de datos: mostrar",
"Legend: None" => "Leyenda: ninguna",
"Legend: Bottom" => "Leyenda: abajo",
"Legend: Top" => "Leyenda: arriba",
"Legend: Right" => "Leyenda: derecha",
"Gridlines: None" => "Líneas de cuadrícula: ninguna",
"Gridlines: Show" => "Líneas de cuadrícula: mostrar",
"Chart\nTitle" => "Título\ndel gráfico",
"Delete\nChart" => "Eliminar\ngráfico",
"Convert\nto Range" => "Convertir\nen rango",
"Table\nStyles" => "Estilos\nde tabla",
"Top 10 Items" => "10 superiores",
"Top 10%" => "10% superior",
"Bottom 10 Items" => "10 inferiores",
"Bottom 10%" => "10% inferior",
"Above Average" => "Por encima del promedio",
"Below Average" => "Por debajo del promedio",
"Clear Rules from Entire Sheet" => "Borrar reglas de toda la hoja",
"Clear Rules from Selected Cells" => "Borrar reglas de las celdas seleccionadas",
"Constants" => "Constantes",
"Data Bars" => "Barras de datos",
"Color Scales" => "Escalas de color",
"Icon Sets" => "Conjuntos de iconos",
"Home" => "Inicio",
"Draw" => "Dibujar",
"Page Layout" => "Disposición de página",
"Formulas" => "Fórmulas",
"Data" => "Datos",
"Review" => "Revisar",
"View" => "Vista",
"Automate" => "Automatizar",
"Table Design" => "Diseño de tabla",
"Chart Design" => "Diseño de gráfico",
"Ready" => "Listo",
"Enter" => "Introducir",
"Edit" => "Modificar",
"Point" => "Señalar",
"Calculate" => "Calcular",
"Select destination and press Enter or choose Paste" => "Seleccione el destino y presione Entrar o elija Pegar",
"General" => "General",
"Number" => "Número",
"Currency" => "Moneda",
"Accounting" => "Contabilidad",
"Date" => "Fecha",
"Time" => "Hora",
"Percentage" => "Porcentaje",
"Fraction" => "Fracción",
"Scientific" => "Científica",
"Text" => "Texto",
"Special" => "Especial",
"Custom" => "Personalizada",
"Short Date" => "Fecha corta",
"Long Date" => "Fecha larga",
"More Number Formats…" => "Más formatos de número…",
"Financial" => "Financieras",
"Logical" => "Lógicas",
"Date &\nTime" => "Fecha\ny hora",
"Lookup &\nReference" => "Búsqueda y\nreferencia",
"Math &\nTrig" => "Matemáticas y\ntrigonométricas",
"More\nFunctions" => "Más\nfunciones",
"Page Layout|Orientation" => "Orientación",
"Display theme" => "Tema de la interfaz",
"System" => "Sistema",
"Light" => "Claro",
"Dark" => "Oscuro",
"Sheet Options|View" => "Ver",
"Print" => "Imprimir",
_ => return None,
})
}

View File

@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Link" => "링크",
"Symbol" => "기호",
"Sparklines" => "스파크라인",
"Checkbox" => "확인란",
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
"Header & Footer" => "머리글 및 바닥글",
"Draw with ink" => "잉크로 그리기",
"Eraser" => "지우개",
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Page Break" => "페이지 나누기 제거",
"Reset All Page Breaks" => "모든 페이지 나누기 다시 설정",
"Print\nTitles" => "인쇄\n제목",
"Sheet\nRight-to-Left" => "시트\n오른쪽에서 왼쪽으로",
"Sheet Right-to-Left" => "시트 오른쪽에서 왼쪽으로",
"Narrow" => "좁게",
"Wide" => "넓게",
"Normal" => "보통",
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "그룹",
"Ungroup" => "그룹 해제",
"Subtotal" => "부분합",
"Data\nAnalysis" => "데이터 분석",
"Data Analysis" => "데이터 분석",
"Solver" => "해 찾기",
"What-If\nAnalysis" => "가상 분석",
"Data Table…" => "데이터 표…",
"Scenario Manager…" => "시나리오 관리자…",
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Threaded Comments" => "스레드 댓글",
"Show\nComments" => "댓글\n표시",
"Protect\nWorkbook" => "통합 문서\n보호",
"Allow Edit\nRanges" => "범위 편집\n허용",
"Allow Edit Ranges" => "범위 편집 허용",
"Unprotect\nSheet" => "시트 보호\n해제",
"Protect Sheet…" => "시트 보호…",
"Comments" => "댓글",
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "틀 고정 취소",
"Dark Mode" => "다크 모드",
"Interface language" => "인터페이스 언어",
"Interface size" => "인터페이스 크기",
"Auto" => "자동",
"Drag to freeze columns" => "끌어서 열 고정",
"Drag to freeze rows" => "끌어서 행 고정",
"Command\nPalette" => "명령\n팔레트",
"Agent\nControl" => "에이전트\n제어",
"Action\nJournal" => "작업\n기록",
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Add Chart\nElement" => "차트\n요소 추가",
"Change\nChart Type" => "차트 종류\n변경",
"Switch\nRow/Column" => "행/열\n전환",
"Select\nData" => "데이터\n선택",
"Data Labels: None" => "데이터 레이블: 없음",
"Data Labels: Show" => "데이터 레이블: 표시",
"Legend: None" => "범례: 없음",
@ -268,6 +282,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "어둡게",
"Sheet Options|View" => "화면",
"Print" => "인쇄",
// File › Backstage view.
"File" => "파일",
"Back" => "뒤로",
"New" => "새로 만들기",
"Open" => "열기",
"Info" => "정보",
"Save" => "저장",
"Save As" => "다른 이름으로 저장",
"Export" => "내보내기",
"Close" => "닫기",
"Options" => "옵션",
"Blank workbook" => "새 통합 문서",
"Recent" => "최근 항목",
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
"More workbooks" => "더 많은 통합 문서",
"Browse…" => "찾아보기…",
"Not saved yet" => "아직 저장되지 않음",
"Location" => "위치",
"Sheets" => "시트",
"Protect Workbook" => "통합 문서 보호",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"시트 구조가 잠겨 있습니다. 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없습니다."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"시트 구조를 잠가 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없게 합니다."
}
"Properties" => "속성",
"Title" => "제목",
"Subject" => "주제",
"Author" => "만든 이",
"Company" => "회사",
"Keywords" => "키워드",
"Description" => "설명",
"No workbook is open." => "열려 있는 통합 문서가 없습니다.",
"Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.",
"Download (.xlsx)" => "다운로드(.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.",
"Print Active Sheets" => "활성 시트 인쇄",
"Print Entire Workbook" => "전체 통합 문서 인쇄",
"Fit Sheet on One Page" => "한 페이지에 시트 맞추기",
"Page Setup…" => "페이지 설정…",
"Page {page} of {pages}" => "{page}/{pages} 페이지",
"Nothing to print." => "인쇄할 내용이 없습니다.",
"Previous page" => "이전 페이지",
"Next page" => "다음 페이지",
"Create PDF" => "PDF 만들기",
"Save the sheet as a PDF document that keeps its page layout." => "페이지 레이아웃을 유지한 채 시트를 PDF 문서로 저장합니다.",
"Change File Type" => "파일 형식 변경",
"CSV (Comma delimited)" => "CSV(쉼표로 분리)",
"Save the active sheet as comma-separated text." => "활성 시트를 쉼표로 구분된 텍스트로 저장합니다.",
"Web Page (.html)" => "웹 페이지(.html)",
"Save the workbook as a web page." => "통합 문서를 웹 페이지로 저장합니다.",
"Options|General" => "일반",
"Options|Save" => "저장",
"Show formula bar" => "수식 입력줄 표시",
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
"Enable iterative calculation" => "반복 계산 사용",
"Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.",
_ => return None,
})
}

View File

@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Link" => "Vínculo",
"Symbol" => "Símbolo",
"Sparklines" => "Minigráficos",
"Checkbox" => "Caixa de Seleção",
"Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas",
"Header & Footer" => "Cabeçalho e Rodapé",
"Draw with ink" => "Desenhar com Tinta",
"Eraser" => "Borracha",
@ -124,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Page Break" => "Remover Quebra de Página",
"Reset All Page Breaks" => "Redefinir Todas as Quebras de Página",
"Print\nTitles" => "Imprimir\nTítulos",
"Sheet\nRight-to-Left" => "Planilha da Direita\npara a Esquerda",
"Sheet Right-to-Left" => "Planilha da Direita para a Esquerda",
"Narrow" => "Estreita",
"Wide" => "Ampla",
"Normal" => "Normal",
@ -165,6 +169,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Subtotal" => "Subtotal",
"Data\nAnalysis" => "Análise de Dados",
"Data Analysis" => "Análise de Dados",
"Solver" => "Solver",
"What-If\nAnalysis" => "Análise de\nHipóteses",
"Data Table…" => "Tabela de Dados…",
"Scenario Manager…" => "Gerenciador de Cenários…",
@ -174,6 +181,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Threaded Comments" => "Comentários Encadeados",
"Show\nComments" => "Mostrar\nComentários",
"Protect\nWorkbook" => "Proteger\nPasta de Trabalho",
"Allow Edit\nRanges" => "Permitir Edição\nde Intervalos",
"Allow Edit Ranges" => "Permitir Edição de Intervalos",
"Unprotect\nSheet" => "Desproteger\nPlanilha",
"Protect Sheet…" => "Proteger Planilha…",
"Comments" => "Comentários",
@ -193,6 +202,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "Descongelar Painéis",
"Dark Mode" => "Modo Escuro",
"Interface language" => "Idioma da interface",
"Interface size" => "Tamanho da interface",
"Auto" => "Automático",
"Drag to freeze columns" => "Arraste para congelar colunas",
"Drag to freeze rows" => "Arraste para congelar linhas",
"Command\nPalette" => "Paleta de\nComandos",
"Agent\nControl" => "Controle\ndo Agente",
"Action\nJournal" => "Registro\nde Ações",
@ -201,6 +214,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Add Chart\nElement" => "Adicionar\nElemento de Gráfico",
"Change\nChart Type" => "Alterar\nTipo de Gráfico",
"Switch\nRow/Column" => "Alternar\nLinha/Coluna",
"Select\nData" => "Selecionar\nDados",
"Data Labels: None" => "Rótulos de Dados: Nenhum",
"Data Labels: Show" => "Rótulos de Dados: Exibir",
"Legend: None" => "Legenda: Nenhuma",
@ -269,6 +283,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Escuro",
"Sheet Options|View" => "Exibir",
"Print" => "Imprimir",
// File › Backstage view.
"File" => "Arquivo",
"Back" => "Voltar",
"New" => "Novo",
"Open" => "Abrir",
"Info" => "Informações",
"Save" => "Salvar",
"Save As" => "Salvar como",
"Export" => "Exportar",
"Close" => "Fechar",
"Options" => "Opções",
"Blank workbook" => "Pasta de trabalho em branco",
"Recent" => "Recentes",
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
"More workbooks" => "Mais pastas de trabalho",
"Browse…" => "Procurar…",
"Not saved yet" => "Ainda não salvo",
"Location" => "Local",
"Sheets" => "Planilhas",
"Protect Workbook" => "Proteger pasta de trabalho",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"A estrutura das planilhas está bloqueada: não é possível adicionar, mover, renomear nem excluir planilhas."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Bloqueie a estrutura das planilhas para que não possam ser adicionadas, movidas, renomeadas nem excluídas."
}
"Properties" => "Propriedades",
"Title" => "Título",
"Subject" => "Assunto",
"Author" => "Autor",
"Company" => "Empresa",
"Keywords" => "Palavras-chave",
"Description" => "Descrição",
"No workbook is open." => "Nenhuma pasta de trabalho está aberta.",
"Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.",
"Download (.xlsx)" => "Baixar (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.",
"Print Active Sheets" => "Imprimir planilhas ativas",
"Print Entire Workbook" => "Imprimir toda a pasta de trabalho",
"Fit Sheet on One Page" => "Ajustar planilha em uma página",
"Page Setup…" => "Configurar página…",
"Page {page} of {pages}" => "Página {page} de {pages}",
"Nothing to print." => "Nada para imprimir.",
"Previous page" => "Página anterior",
"Next page" => "Próxima página",
"Create PDF" => "Criar PDF",
"Save the sheet as a PDF document that keeps its page layout." => "Salve a planilha como um documento PDF que mantém o layout da página.",
"Change File Type" => "Alterar tipo de arquivo",
"CSV (Comma delimited)" => "CSV (separado por vírgulas)",
"Save the active sheet as comma-separated text." => "Salve a planilha ativa como texto separado por vírgulas.",
"Web Page (.html)" => "Página da Web (.html)",
"Save the workbook as a web page." => "Salve a pasta de trabalho como uma página da Web.",
"Options|General" => "Geral",
"Options|Save" => "Salvar",
"Show formula bar" => "Mostrar barra de fórmulas",
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
_ => return None,
})
}

View File

@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Link" => "Ссылка",
"Symbol" => "Символ",
"Sparklines" => "Спарклайны",
"Checkbox" => "Флажок",
"Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки",
"Header & Footer" => "Верхний и нижний колонтитулы",
"Draw with ink" => "Рисование от руки",
"Eraser" => "Ластик",
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Page Break" => "Удалить разрыв страницы",
"Reset All Page Breaks" => "Сбросить все разрывы страниц",
"Print\nTitles" => "Печатать\nзаголовки",
"Sheet\nRight-to-Left" => "Лист\nсправа налево",
"Sheet Right-to-Left" => "Лист справа налево",
"Narrow" => "Узкие",
"Wide" => "Широкие",
"Normal" => "Обычные",
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Группировать",
"Ungroup" => "Разгруппировать",
"Subtotal" => "Промежуточный итог",
"Data\nAnalysis" => "Анализ данных",
"Data Analysis" => "Анализ данных",
"Solver" => "Поиск решения",
"What-If\nAnalysis" => "Анализ «что\nесли»",
"Data Table…" => "Таблица данных…",
"Scenario Manager…" => "Диспетчер сценариев…",
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Threaded Comments" => "Комментарии с ветками",
"Show\nComments" => "Показать\nкомментарии",
"Protect\nWorkbook" => "Защитить\nкнигу",
"Allow Edit\nRanges" => "Разрешить\nизменение диапазонов",
"Allow Edit Ranges" => "Разрешить изменение диапазонов",
"Unprotect\nSheet" => "Снять защиту\nлиста",
"Protect Sheet…" => "Защитить лист…",
"Comments" => "Комментарии",
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "Снять закрепление областей",
"Dark Mode" => "Тёмный режим",
"Interface language" => "Язык интерфейса",
"Interface size" => "Размер интерфейса",
"Auto" => "Авто",
"Drag to freeze columns" => "Перетащите, чтобы закрепить столбцы",
"Drag to freeze rows" => "Перетащите, чтобы закрепить строки",
"Command\nPalette" => "Палитра\nкоманд",
"Agent\nControl" => "Управление\nагентом",
"Action\nJournal" => "Журнал\nдействий",
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Add Chart\nElement" => "Добавить элемент\nдиаграммы",
"Change\nChart Type" => "Изменить тип\nдиаграммы",
"Switch\nRow/Column" => "Поменять\nстроки/столбцы",
"Select\nData" => "Выбрать\nданные",
"Data Labels: None" => "Подписи данных: нет",
"Data Labels: Show" => "Подписи данных: показать",
"Legend: None" => "Легенда: нет",
@ -268,6 +282,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Тёмная",
"Sheet Options|View" => "На экране",
"Print" => "Печать",
// File › Backstage view.
"File" => "Файл",
"Back" => "Назад",
"New" => "Создать",
"Open" => "Открыть",
"Info" => "Сведения",
"Save" => "Сохранить",
"Save As" => "Сохранить как",
"Export" => "Экспорт",
"Close" => "Закрыть",
"Options" => "Параметры",
"Blank workbook" => "Пустая книга",
"Recent" => "Последние",
"No recent workbooks yet." => "Недавних книг пока нет.",
"More workbooks" => "Другие книги",
"Browse…" => "Обзор…",
"Not saved yet" => "Ещё не сохранено",
"Location" => "Расположение",
"Sheets" => "Листы",
"Protect Workbook" => "Защитить книгу",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
"Структура листов заблокирована: листы нельзя добавлять, перемещать, переименовывать или удалять."
}
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
"Заблокировать структуру листов, чтобы их нельзя было добавлять, перемещать, переименовывать или удалять."
}
"Properties" => "Свойства",
"Title" => "Название",
"Subject" => "Тема",
"Author" => "Автор",
"Company" => "Организация",
"Keywords" => "Ключевые слова",
"Description" => "Описание",
"No workbook is open." => "Нет открытых книг.",
"Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.",
"Download (.xlsx)" => "Скачать (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».",
"Print Active Sheets" => "Напечатать активные листы",
"Print Entire Workbook" => "Напечатать всю книгу",
"Fit Sheet on One Page" => "Вписать лист на одну страницу",
"Page Setup…" => "Параметры страницы…",
"Page {page} of {pages}" => "Страница {page} из {pages}",
"Nothing to print." => "Нечего печатать.",
"Previous page" => "Предыдущая страница",
"Next page" => "Следующая страница",
"Create PDF" => "Создать PDF",
"Save the sheet as a PDF document that keeps its page layout." => "Сохранить лист как PDF-документ с сохранением разметки страниц.",
"Change File Type" => "Изменить тип файла",
"CSV (Comma delimited)" => "CSV (разделители — запятые)",
"Save the active sheet as comma-separated text." => "Сохранить активный лист как текст с разделителями-запятыми.",
"Web Page (.html)" => "Веб-страница (.html)",
"Save the workbook as a web page." => "Сохранить книгу как веб-страницу.",
"Options|General" => "Общие",
"Options|Save" => "Сохранение",
"Show formula bar" => "Показывать строку формул",
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
"Enable iterative calculation" => "Включить итеративные вычисления",
"Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.",
_ => return None,
})
}

View File

@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Link" => "链接",
"Symbol" => "符号",
"Sparklines" => "迷你图",
"Checkbox" => "复选框",
"Insert checkboxes in the selected cells" => "在所选单元格中插入复选框",
"Header & Footer" => "页眉和页脚",
"Draw with ink" => "墨迹绘制",
"Eraser" => "橡皮擦",
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Remove Page Break" => "删除分页符",
"Reset All Page Breaks" => "重置所有分页符",
"Print\nTitles" => "打印\n标题",
"Sheet\nRight-to-Left" => "工作表\n从右到左",
"Sheet Right-to-Left" => "工作表从右到左",
"Narrow" => "窄",
"Wide" => "宽",
"Normal" => "普通",
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "组合",
"Ungroup" => "取消组合",
"Subtotal" => "分类汇总",
"Data\nAnalysis" => "数据分析",
"Data Analysis" => "数据分析",
"Solver" => "规划求解",
"What-If\nAnalysis" => "假设分析",
"Data Table…" => "数据表…",
"Scenario Manager…" => "方案管理器…",
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Threaded Comments" => "线程式批注",
"Show\nComments" => "显示\n批注",
"Protect\nWorkbook" => "保护\n工作簿",
"Allow Edit\nRanges" => "允许编辑\n区域",
"Allow Edit Ranges" => "允许编辑区域",
"Unprotect\nSheet" => "撤销\n工作表保护",
"Protect Sheet…" => "保护工作表…",
"Comments" => "批注",
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "取消冻结窗格",
"Dark Mode" => "深色模式",
"Interface language" => "界面语言",
"Interface size" => "界面大小",
"Auto" => "自动",
"Drag to freeze columns" => "拖动以冻结列",
"Drag to freeze rows" => "拖动以冻结行",
"Command\nPalette" => "命令\n面板",
"Agent\nControl" => "智能体\n控制",
"Action\nJournal" => "操作\n日志",
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Add Chart\nElement" => "添加图表\n元素",
"Change\nChart Type" => "更改\n图表类型",
"Switch\nRow/Column" => "切换\n行/列",
"Select\nData" => "选择\n数据",
"Data Labels: None" => "数据标签:无",
"Data Labels: Show" => "数据标签:显示",
"Legend: None" => "图例:无",
@ -268,6 +282,61 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "深色",
"Sheet Options|View" => "屏幕",
"Print" => "打印",
// File › Backstage view.
"File" => "文件",
"Back" => "返回",
"New" => "新建",
"Open" => "打开",
"Info" => "信息",
"Save" => "保存",
"Save As" => "另存为",
"Export" => "导出",
"Close" => "关闭",
"Options" => "选项",
"Blank workbook" => "空白工作簿",
"Recent" => "最近",
"No recent workbooks yet." => "还没有最近使用的工作簿。",
"More workbooks" => "更多工作簿",
"Browse…" => "浏览…",
"Not saved yet" => "尚未保存",
"Location" => "位置",
"Sheets" => "工作表",
"Protect Workbook" => "保护工作簿",
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "工作表结构已锁定:无法添加、移动、重命名或删除工作表。",
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "锁定工作表结构,使工作表无法被添加、移动、重命名或删除。",
"Properties" => "属性",
"Title" => "标题",
"Subject" => "主题",
"Author" => "作者",
"Company" => "公司",
"Keywords" => "关键词",
"Description" => "说明",
"No workbook is open." => "没有打开的工作簿。",
"Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。",
"Download (.xlsx)" => "下载 (.xlsx)",
"PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。",
"Print Active Sheets" => "打印活动工作表",
"Print Entire Workbook" => "打印整个工作簿",
"Fit Sheet on One Page" => "将工作表调整为一页",
"Page Setup…" => "页面设置…",
"Page {page} of {pages}" => "第 {page} 页,共 {pages} 页",
"Nothing to print." => "没有可打印的内容。",
"Previous page" => "上一页",
"Next page" => "下一页",
"Create PDF" => "创建 PDF",
"Save the sheet as a PDF document that keeps its page layout." => "将工作表另存为保留页面布局的 PDF 文档。",
"Change File Type" => "更改文件类型",
"CSV (Comma delimited)" => "CSV (逗号分隔)",
"Save the active sheet as comma-separated text." => "将活动工作表另存为逗号分隔的文本。",
"Web Page (.html)" => "网页 (.html)",
"Save the workbook as a web page." => "将工作簿另存为网页。",
"Options|General" => "常规",
"Options|Save" => "保存",
"Show formula bar" => "显示编辑栏",
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
"Workbook calculation" => "工作簿计算",
"Enable iterative calculation" => "启用迭代计算",
"Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。",
_ => return None,
})
}

View File

@ -80,6 +80,7 @@ pub enum Icon {
TextBox,
Symbol,
Sparkline,
Checkbox,
Function,
Name,
Trace,
@ -101,6 +102,7 @@ pub enum Icon {
Theme,
Margins,
Orient,
SheetRtl,
Paper,
PrintArea,
Script,
@ -108,9 +110,17 @@ pub enum Icon {
Share,
Search,
Settings,
/// Light/dark appearance: a circle, its right half filled.
Appearance,
Sheet,
Book,
Folder,
/// File › Backstage navigation (line art, drawn in the navigation colour).
Back,
NewFile,
OpenFolder,
Info,
Print,
}
struct G<'a> {
@ -415,6 +425,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
g.line(&[(12.0, 4.0), (4.0, 12.0)], c, 1.3);
}
Icon::Check => g.line(&[(3.0, 8.5), (6.5, 12.0), (13.0, 4.5)], c, 1.3),
Icon::Checkbox => {
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4);
}
Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, false, true),
Icon::Plus => {
g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3);
@ -574,6 +588,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE));
}
Icon::SheetRtl => {
// A sheet with an arrow running right to left.
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4);
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE);
}
Icon::PrintArea => {
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4);
@ -596,6 +618,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
g.circle(8.0, 8.0, 5.0, None, Some(c));
g.circle(8.0, 8.0, 2.0, Some(c), None);
}
Icon::Appearance => {
g.circle(8.0, 8.0, 5.5, None, Some(c));
let half: Vec<(f32, f32)> = (0..=12)
.map(|i| {
let a = std::f32::consts::PI * (i as f32 / 12.0 - 0.5);
(8.0 + 5.5 * a.cos(), 8.0 + 5.5 * a.sin())
})
.collect();
g.poly(&half, c);
}
Icon::Book => {
g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None);
g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None);
@ -605,6 +637,29 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
Icon::Folder => {
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW);
}
Icon::Back => {
g.circle(8.0, 8.0, 6.5, None, Some(c));
g.line(&[(11.5, 8.0), (4.8, 8.0)], c, 1.2);
g.line(&[(7.5, 5.3), (4.8, 8.0), (7.5, 10.7)], c, 1.2);
}
Icon::NewFile => {
g.line(&[(9.5, 1.5), (3.5, 1.5), (3.5, 14.5), (12.5, 14.5), (12.5, 4.5), (9.5, 1.5), (9.5, 4.5), (12.5, 4.5)], c, 1.1);
}
Icon::OpenFolder => {
g.line(&[(13.0, 6.5), (13.0, 4.5), (7.5, 4.5), (6.0, 3.0), (1.5, 3.0), (1.5, 13.0)], c, 1.1);
g.line(&[(1.5, 13.0), (4.0, 7.0), (15.0, 7.0), (12.5, 13.0), (1.5, 13.0)], c, 1.1);
}
Icon::Info => {
g.circle(8.0, 8.0, 6.5, None, Some(c));
g.line(&[(8.0, 7.0), (8.0, 11.8)], c, 1.3);
g.circle(8.0, 4.8, 0.8, Some(c), None);
}
Icon::Print => {
g.line(&[(4.5, 5.5), (4.5, 1.5), (11.5, 1.5), (11.5, 5.5)], c, 1.1);
g.line(&[(4.0, 11.5), (1.5, 11.5), (1.5, 5.5), (14.5, 5.5), (14.5, 11.5), (12.0, 11.5)], c, 1.1);
g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1);
g.circle(12.3, 7.6, 0.6, Some(c), None);
}
}
}

View File

@ -0,0 +1,239 @@
//! Excel keyboard shortcuts beyond the plain Ctrl/Cmd letters in [`crate::ribbon::shortcut`]:
//! number-format and border combinations, F-keys, Alt/Option combinations and the Excel for Mac
//! variants. Every entry maps to an existing command or dialog. `docs/ui-parity.md` holds the
//! audit against Microsoft's published Windows and Mac shortcut lists.
use egui::{Key, Modifiers};
use gridcraft_engine::core::{CellRef, RangeRef};
use gridcraft_engine::model::Sheet;
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::grid::Geo;
/// What a shortcut does.
#[derive(Clone, Debug, PartialEq)]
pub enum Shortcut {
/// Run a command with these parameters.
Run(&'static str, Json),
/// Open a dialog.
Dialog(&'static str, Json),
/// Start editing with the current date or time (Ctrl+; and Ctrl+Shift+;).
Date,
Time,
/// Ctrl+' / Ctrl+Shift+": the formula / value of the cell above, ready to edit.
FormulaAbove,
ValueAbove,
/// Extend the selection to A1, to the last used cell, or to the start of the row.
ExtendToStart,
ExtendToEnd,
ExtendToRowStart,
/// Alt+Page Down / Alt+Page Up: one screen right / left.
ScreenRight,
ScreenLeft,
/// Excel for Mac Control+U: edit the active cell (like F2).
Edit,
/// Shift+F10: the cell context menu.
ContextMenu,
/// Alt+Down: the active cell's drop-down list.
DropDown,
/// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out.
Zoom(i32),
}
fn run(id: &'static str) -> Shortcut {
Shortcut::Run(id, json!({}))
}
fn number_format(code: &str) -> Shortcut {
Shortcut::Run("home.numberFormat", json!({"code": code}))
}
fn borders(preset: &str) -> Shortcut {
Shortcut::Run("home.borders", json!({"preset": preset}))
}
/// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US
/// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel
/// for Mac variants where they differ.
pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> {
use Key::*;
let k = physical.unwrap_or(key);
// Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac,
// and Alt/Option alone.
let ctrl = m.ctrl && !m.alt;
let cmd = m.command && !m.alt;
let alt = m.alt && !m.ctrl && !m.command;
let ctrl_alt = m.alt && (m.ctrl || m.command);
let plain = !m.ctrl && !m.command && !m.alt;
let s = m.shift;
Some(match k {
// Ctrl+Shift+~ ! @ # $ % ^ & _ : number formats and borders (Windows and Mac).
Backtick if ctrl && s => Shortcut::Run("home.numberFormat", json!({"format": "General"})),
Num1 if ctrl && s => number_format("#,##0.00"),
Num2 if ctrl && s => number_format("h:mm AM/PM"),
Num3 if ctrl && s => number_format("d-mmm-yy"),
Num4 if ctrl && s => number_format("$#,##0.00_);($#,##0.00)"),
Num5 if ctrl && s => number_format("0%"),
Num6 if ctrl && s => number_format("0.00E+00"),
Num7 if ctrl && s => borders("outside"),
Minus if ctrl && s => borders("none"),
Num8 if ctrl && s => run("selection.currentRegion"),
Semicolon if ctrl && s => Shortcut::Time,
Quote if ctrl && s => Shortcut::ValueAbove,
Quote if ctrl => Shortcut::FormulaAbove,
// Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;.
Semicolon if mac && ctrl => Shortcut::Date,
Semicolon if mac && cmd && !s => Shortcut::Time,
// Ctrl+2..5 (Windows).
Num2 if ctrl && !mac => run("home.bold"),
Num3 if ctrl && !mac => run("home.italic"),
Num4 if ctrl && !mac => run("home.underline"),
Num5 if ctrl && !mac => run("home.strikethrough"),
// Letters that differ between Windows and Mac.
L if (cmd || ctrl) && s => run("data.filter"),
L if cmd && mac => run("home.alignLeft"),
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
U if ctrl && mac && !s => Shortcut::Edit,
L if cmd => run("insert.table"),
E if cmd && mac && !s => run("home.alignCenter"),
E if ctrl && mac && !s => run("edit.flashFill"),
F if cmd && s && !mac => Shortcut::Dialog("formatCells", json!({"tab": "Font"})),
G if cmd && s && !mac => Shortcut::Dialog("statistics", json!({})),
H if cmd && s && mac => Shortcut::Dialog("find", json!({"replace": true})),
R if cmd && s && mac => Shortcut::Dialog("sort", json!({})),
K if cmd && s && mac => run("data.group"),
J if cmd && s && mac => run("data.ungroup"),
P if cmd && !s => run("file.print"),
Period if cmd && s => run("home.increaseFontSize"),
Comma if cmd && s => run("home.decreaseFontSize"),
Space if cmd && s => run("edit.selectAll"),
Home if cmd && s => Shortcut::ExtendToStart,
End if cmd && s => Shortcut::ExtendToEnd,
Home if plain && s => Shortcut::ExtendToRowStart,
// Ctrl+Alt / Cmd+Option combinations.
V if ctrl_alt || (mac && m.ctrl && m.command) => Shortcut::Dialog("pasteSpecial", json!({})),
Equals | Plus if ctrl_alt => Shortcut::Zoom(10),
Minus if ctrl_alt && !mac => Shortcut::Zoom(-10),
F9 if ctrl_alt => run("formulas.calculateNow"),
R if ctrl_alt && mac => run("view.collapseRibbon"),
Num0 if ctrl_alt && mac => borders("outside"),
Minus if ctrl_alt && mac => borders("none"),
ArrowLeft if ctrl_alt && mac => borders("left"),
ArrowRight if ctrl_alt && mac => borders("right"),
ArrowUp if ctrl_alt && mac => borders("top"),
ArrowDown if ctrl_alt && mac => borders("bottom"),
// Alt / Option alone.
Equals if alt && !s && !mac => run("formulas.autoSum"),
ArrowDown if alt && !s => Shortcut::DropDown,
PageDown if alt => Shortcut::ScreenRight,
PageUp if alt => Shortcut::ScreenLeft,
ArrowRight if alt && s && !mac => run("data.group"),
ArrowLeft if alt && s && !mac => run("data.ungroup"),
ArrowRight if alt && !s && mac => run("sheet.next"),
ArrowLeft if alt && !s && mac => run("sheet.previous"),
F1 if alt && s => run("home.insertSheet"),
F1 if alt => run("insert.chart"),
F10 if alt && s => Shortcut::Dialog("errorChecking", json!({})),
// Function keys.
F1 if cmd && !s => run("view.collapseRibbon"),
F2 if (cmd || ctrl) && s => Shortcut::Dialog("comment", json!({"threaded": true})),
F2 if plain && s => Shortcut::Dialog("comment", json!({"threaded": false})),
F3 if cmd && s => run("formulas.createFromSelection"),
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
F4 if cmd && !s => run("file.close"),
F5 if plain && s => Shortcut::Dialog("find", json!({})),
F5 if plain => Shortcut::Dialog("goTo", json!({})),
F7 if plain && !s => Shortcut::Dialog("spelling", json!({})),
F9 if plain && s => run("formulas.calculateSheet"),
F10 if plain && s => Shortcut::ContextMenu,
F11 if plain && s => run("home.insertSheet"),
F12 if plain && !s => run("file.saveAs"),
_ => return None,
})
}
/// Handles a key press in the grid. Returns whether it was a shortcut.
pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~`
// on a German layout) is text, not a shortcut.
if !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) {
return false;
}
let Some(shortcut) = lookup(key, physical, m, mac) else { return false };
let (active, anchor) = app.session.active().map(|d| (d.selection.active, d.selection.anchor)).unwrap_or_default();
match shortcut {
Shortcut::Run(id, params) => {
app.run_or_alert(id, params);
app.grid.ensure_visible = true;
}
Shortcut::Dialog(name, params) => app.open_dialog(name, params),
Shortcut::Date | Shortcut::Time => {
let now = gridcraft_engine::calc::now_serial();
let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") };
let text = app
.session
.active()
.map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text)
.unwrap_or_default();
edit_with(app, text);
}
Shortcut::FormulaAbove | Shortcut::ValueAbove => {
let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true };
let text = if shortcut == Shortcut::FormulaAbove {
sh.cell(above).map(|c| c.input_text()).unwrap_or_default()
} else {
app.session
.active()
.and_then(|d| sh.value_ref(above).map(|v| gridcraft_engine::display::format(v, "General", &d.wb).text))
.unwrap_or_default()
};
edit_with(app, text);
}
Shortcut::ExtendToStart => extend(app, anchor, active, CellRef::new(0, 0)),
Shortcut::ExtendToEnd => {
let end = sh.used_range().map(|u| u.end).unwrap_or_default();
extend(app, anchor, active, end);
}
Shortcut::ExtendToRowStart => extend(app, anchor, active, CellRef::new(active.row, 0)),
Shortcut::ScreenRight | Shortcut::ScreenLeft => {
let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0);
let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64;
let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols };
let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift}));
app.grid.ensure_visible = true;
}
Shortcut::Edit => app.begin_edit(None, false),
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
Shortcut::DropDown => {
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
app.grid.list_picker = Some(active);
}
}
Shortcut::Zoom(step) => {
let percent = (i32::from(sh.zoom).saturating_add(step)).clamp(10, 400);
app.run_or_alert("view.zoom", json!({"percent": percent}));
}
}
true
}
/// Starts editing the active cell with `text` and the caret at its end.
fn edit_with(app: &mut SheetApp, text: String) {
app.begin_edit(Some(text), false);
if let Some(ed) = app.editor.as_mut() {
ed.caret = ed.text.chars().count();
}
}
/// Selects `anchor`..`to`, keeping the anchor and the active cell.
fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) {
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(anchor, to).a1(), "active": active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
app.grid.ensure_visible = true;
}

View File

@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod backstage;
mod border_preview;
mod cell_pictures;
pub mod chartview;
@ -18,6 +19,7 @@ mod formula_locale;
pub mod grid;
pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
pub mod panes;
pub mod pivot_pane;
@ -45,7 +47,6 @@ pub struct UiState {
pub system_theme: bool,
pub ribbon_tab: String,
pub ribbon_collapsed: bool,
pub formula_bar: bool,
pub formula_bar_expanded: bool,
pub status_bar: bool,
pub recent: Vec<String>,
@ -55,6 +56,25 @@ pub struct UiState {
/// rather than failing the whole `UiState`.
#[serde(deserialize_with = "i18n::lenient")]
pub language: i18n::Language,
/// Interface size in percent on top of the desktop's scale (100 = Auto), applied as egui's
/// zoom factor so the ribbon, menus, dialogs and grid grow together. Kept within
/// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100.
#[serde(deserialize_with = "lenient_interface_scale")]
pub interface_scale: u16,
}
pub const INTERFACE_SCALE_MIN: u16 = 75;
pub const INTERFACE_SCALE_MAX: u16 = 200;
/// A requested interface size, rounded and clamped to the supported range; `None` when it isn't
/// a finite number.
pub fn clamp_interface_scale(percent: f64) -> Option<u16> {
percent.is_finite().then(|| percent.round().clamp(INTERFACE_SCALE_MIN as f64, INTERFACE_SCALE_MAX as f64) as u16)
}
fn lenient_interface_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u16, D::Error> {
let v = Json::deserialize(d)?;
Ok(v.as_f64().and_then(clamp_interface_scale).unwrap_or(100))
}
impl Default for UiState {
@ -64,11 +84,11 @@ impl Default for UiState {
system_theme: false,
ribbon_tab: "Home".into(),
ribbon_collapsed: false,
formula_bar: true,
formula_bar_expanded: false,
status_bar: true,
recent: vec![],
language: i18n::Language::En,
interface_scale: 100,
}
}
}
@ -84,6 +104,11 @@ impl UiState {
}
}
/// The zoom factor for the interface size preference.
pub fn zoom_factor(&self) -> f32 {
clamp_interface_scale(self.interface_scale as f64).unwrap_or(100) as f32 / 100.0
}
pub fn theme_mode(&self) -> &'static str {
if self.system_theme {
"system"
@ -115,6 +140,10 @@ pub struct Services {
/// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
/// write (including any asynchronous fallback); an immediate error uses egui's text path.
pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
/// Read the HTML flavour of the system clipboard when the user pastes text, so cells the
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
/// the host pastes the plain text alone.
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
@ -141,6 +170,8 @@ pub struct SheetApp {
pub editor: Option<editor::EditState>,
pub text_box_editor: Option<text_box::EditState>,
pub dialog: Option<dialogs::Dialog>,
/// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs).
pub backstage: Option<backstage::Backstage>,
pub message: Option<(String, String)>,
pub toast: Option<(String, f64)>,
pub services: Services,
@ -173,6 +204,7 @@ impl SheetApp {
editor: None,
text_box_editor: None,
dialog: None,
backstage: None,
message: None,
toast: None,
services,
@ -250,7 +282,10 @@ impl SheetApp {
/// their existing internal clipboard behavior and do not write to the host clipboard.
pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) {
// Only ask the engine for HTML when the host can publish it.
let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return };
let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) {
Ok(result) => result,
Err(e) => return self.alert(e),
};
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
if let Some(html) = result.get("html").and_then(Json::as_str)
&& let Some(copy_html) = &mut self.services.copy_html
@ -263,6 +298,17 @@ impl SheetApp {
ctx.copy_text(text.to_string());
}
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
/// host can read it.
pub fn paste_external(&mut self, text: String) {
let html = self.services.paste_html.as_mut().and_then(|read| read());
let params = match html {
Some(html) => json!({"text": text, "html": html}),
None => json!({"text": text}),
};
self.run_or_alert("edit.paste", params);
}
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
fn deliver_pdf(&self, result: &Json) {
if result.get("path").is_some() {
@ -279,11 +325,16 @@ impl SheetApp {
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
pub fn run_or_alert(&mut self, id: &str, params: Json) {
if let Err(e) = self.run(id, params) {
if e.contains("is not available right now") {
self.toast = Some((e, now_ms()));
} else {
self.message = Some(("GridCraft".into(), clean_error(&e)));
}
self.alert(e);
}
}
/// Shows a command error: a toast for an unavailable command, else a message box.
fn alert(&mut self, e: String) {
if e.contains("is not available right now") {
self.toast = Some((e, now_ms()));
} else {
self.message = Some(("GridCraft".into(), clean_error(&e)));
}
}
@ -294,10 +345,6 @@ impl SheetApp {
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
Ok(Json::Null)
}
"view.formulaBar" => {
self.ui.formula_bar = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar);
Ok(json!({"on": self.ui.formula_bar}))
}
"view.collapseRibbon" => {
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
Ok(Json::Null)
@ -319,7 +366,21 @@ impl SheetApp {
}
Ok(json!({"mode": self.ui.theme_mode()}))
}
"view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())),
"view.interfaceScale" => {
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
let percent = match p.get("percent") {
Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100),
Some(v) => v.as_f64().and_then(clamp_interface_scale),
None => None,
};
match percent {
Some(v) => {
self.ui.interface_scale = v;
Ok(json!({"percent": v}))
}
None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")),
}
}
"app.language.set" => {
// `{"language": "ja"}`; `code` is accepted as an alias.
let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or("");
@ -328,7 +389,7 @@ impl SheetApp {
self.ui.language = l;
Ok(json!({"language": l}))
}
None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")),
None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")),
}
}
"app.language.english" => {
@ -339,6 +400,22 @@ impl SheetApp {
self.ui.language = i18n::Language::Ja;
Ok(json!({"language": i18n::Language::Ja}))
}
"ui.backstage" => {
// `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet.
match p.get("page").and_then(Json::as_str).map(str::trim) {
Some("close" | "none" | "") => self.backstage = None,
Some(id) => match backstage::Page::parse(id) {
Some(page) => self.open_backstage(page),
None => {
return Some(Err(format!(
"unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)"
)));
}
},
None => self.open_backstage(backstage::Page::Home),
}
Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}))
}
"ui.dialog" => {
let name = p.get("name").and_then(Json::as_str).unwrap_or("");
self.open_dialog(name, p.clone());
@ -407,10 +484,28 @@ impl SheetApp {
dl(&name, &b);
}
}
// File › Options (`file.options`) and the File tab itself open the Backstage view.
"options" => self.open_backstage(backstage::Page::Options),
"backstage" => {
let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home);
self.open_backstage(page);
}
other => self.dialog = dialogs::Dialog::open(self, other, params),
}
}
/// Opens the File tab's Backstage view on `page`, committing a cell still being edited first
/// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet.
pub fn open_backstage(&mut self, page: backstage::Page) {
if !self.commit_edit(0, 0, false, false) {
return;
}
match &mut self.backstage {
Some(b) => b.page = page,
None => self.backstage = Some(backstage::Backstage::new(page)),
}
}
pub fn open_path(&mut self, path: &str) {
match self.run("file.open", json!({"path": path})) {
Ok(_) => {
@ -567,6 +662,16 @@ impl SheetApp {
ctx.request_repaint();
}
self.effective_dark = ctx.theme() == egui::Theme::Dark;
// Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom
// keys stay off (they would also swallow GridCraft's shortcuts).
if ctx.options(|o| o.zoom_with_keyboard) {
ctx.options_mut(|o| o.zoom_with_keyboard = false);
}
let zoom = self.ui.zoom_factor();
if (ctx.zoom_factor() - zoom).abs() > 0.001 {
ctx.set_zoom_factor(zoom);
ctx.request_repaint();
}
control::poll(self, ctx);
// Files read asynchronously (web file picker, dropped files).
let arrived: Vec<(String, Vec<u8>)> = self
@ -577,8 +682,10 @@ impl SheetApp {
.unwrap_or_default();
for (name, bytes) in arrived {
let b64 = gridcraft_engine::io::base64_encode(&bytes);
if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) {
self.message = Some(("GridCraft".into(), clean_error(&e)));
match self.run("file.open", json!({"name": name, "base64": b64})) {
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
Ok(_) => self.backstage = None,
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
@ -605,23 +712,30 @@ impl SheetApp {
// The language the widgets translate with this frame (see `i18n::current`).
i18n::set_current(&ctx, self.ui.language);
let t = theme::Tokens::get(&ctx);
self.ribbon_keys(&ctx);
if self.backstage.is_none() {
self.ribbon_keys(&ctx);
}
ribbon::title_bar(self, ui);
ribbon::show(self, ui);
if self.ui.formula_bar {
formula_bar::show(self, ui);
if self.backstage.is_some() {
// File tab: the Backstage view replaces everything below the title bar.
backstage::show(self, ui);
} else {
ribbon::show(self, ui);
if self.session.prefs.formula_bar {
formula_bar::show(self, ui);
}
if self.ui.status_bar {
tabs::status_bar(self, ui);
}
tabs::sheet_tabs(self, ui);
pivot_pane::show(self, ui);
panes::show(self, ui);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| {
let g0 = now_ms();
grid::show(self, ui);
self.perf.grid_ms = now_ms() - g0;
});
}
if self.ui.status_bar {
tabs::status_bar(self, ui);
}
tabs::sheet_tabs(self, ui);
pivot_pane::show(self, ui);
panes::show(self, ui);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| {
let g0 = now_ms();
grid::show(self, ui);
self.perf.grid_ms = now_ms() - g0;
});
dialogs::show(self, &ctx);
widgets::message_box(self, &ctx);
widgets::toast(self, &ctx);

View File

@ -152,8 +152,8 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
};
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
ui.collapsing("Chart Title", |ui| {
let mut title = chart.title.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut title).lost_focus() {
let field = egui::Id::new(("gridcraft.chart_title", id));
if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) {
app.run_or_alert("chart.set", json!({"chart": id, "title": title}));
}
});
@ -195,17 +195,17 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut gl, "Gridlines").changed() {
app.run_or_alert("chart.set", json!({"chart": id, "gridlines": gl}));
}
let mut xt = chart.x_title.clone().unwrap_or_default();
ui.horizontal(|ui| {
ui.label("Horizontal axis title:");
if ui.text_edit_singleline(&mut xt).lost_focus() {
let field = egui::Id::new(("gridcraft.chart_x_title", id));
if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) {
app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }}));
}
});
let mut yt = chart.y_title.clone().unwrap_or_default();
ui.horizontal(|ui| {
ui.label("Vertical axis title:");
if ui.text_edit_singleline(&mut yt).lost_focus() {
let field = egui::Id::new(("gridcraft.chart_y_title", id));
if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) {
app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }}));
}
});
@ -250,12 +250,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
});
});
ui.collapsing("Size", |ui| {
let mut w = chart.anchor.width;
let mut h = chart.anchor.height;
// Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away.
let draft = egui::Id::new(("gridcraft.chart_size_draft", id));
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height));
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0));
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0));
if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() {
app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h}));
ui.data_mut(|d| d.remove::<(f32, f32)>(draft));
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h}));
}
} else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, (w, h)));
} else {
ui.data_mut(|d| d.remove::<(f32, f32)>(draft));
}
});
}

View File

@ -136,6 +136,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
if ui.button("Refresh").clicked() {
app.run_or_alert("pivot.refresh", json!({"pivot": name}));
}
if ui.button("Calculated Field…").clicked() {
app.run_or_alert("pivot.calculatedField", json!({"pivot": name}));
}
egui::ComboBox::from_id_salt("pv_layout").selected_text("Report Layout").show_ui(ui, |ui| {
for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] {
if ui.button(l).clicked() {

View File

@ -11,12 +11,33 @@ use crate::widgets::{big_button, color_palette, icon_button, small_button, split
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the
/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take
/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit
/// vertically centred in it, with the controls beside them (#170).
const MAC: bool = cfg!(target_os = "macos");
pub const MAC_TITLE_BAR: f32 = 28.0;
/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the
/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the
/// gap after them from shrinking).
const MAC_LIGHT: f32 = 12.0;
const MAC_LIGHT_PITCH: f32 = 20.0;
const MAC_LIGHTS_LEFT: f32 = 10.0;
/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights
/// keep from the top of the window.
pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
/// Leave room for the macOS traffic lights when the content extends into the title bar.
pub const TITLE_LEFT_PAD: f32 = if cfg!(target_os = "macos") { 76.0 } else { 8.0 };
pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 };
const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 };
/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar.
const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 };
const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below");
pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
let ch = TITLE_CONTROL_H;
egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
let rect = ui.max_rect();
// Dragging the empty title area moves the window.
let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag());
@ -41,19 +62,21 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
app.grid.toggle_autosave();
}
ui.add_space(6.0);
if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() {
if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)).clicked() {
app.open_dialog("start", json!({}));
}
if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() {
if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked()
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
&& app.commit_edit(0, 0, false, false)
{
app.run_or_alert("file.save", json!({}));
}
let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty());
let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty());
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, 26.0)).clicked() && can_undo
{
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo {
app.run_or_alert("edit.undo", json!({}));
}
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, 26.0));
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch));
egui::Popup::menu(&undo_list).show(|ui| {
let labels: Vec<String> =
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default();
@ -66,11 +89,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
}
}
});
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, 26.0)).clicked() && can_redo
{
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo {
app.run_or_alert("edit.redo", json!({}));
}
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, 26.0));
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch));
egui::Popup::menu(&more).show(|ui| {
for (label, id) in [
("New Workbook", "file.new"),
@ -97,7 +119,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, 26.0)).clicked() {
if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)).clicked() {
app.open_dialog("commandSearch", json!({}));
}
let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true);
@ -110,9 +132,19 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
}
}
});
if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, 26.0)).clicked() {
if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)).clicked() {
app.open_dialog("comments", json!({}));
}
// Quick light/dark switch (#194); right-click offers System as well. The same
// choice lives in File › Display theme and the View tab.
let lang = app.ui.language;
let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0));
let dark = ui.ctx().theme() == egui::Theme::Dark;
theme.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, dark, lang.tr("Dark Mode")));
if theme.clicked() {
act(app, "view.darkMode", json!({}));
}
theme.context_menu(|ui| theme_choices(app, ui));
});
});
});
@ -207,30 +239,27 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
});
}
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
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();
}
let label = egui::RichText::new(app.ui.language.tr("File")).font(theme::ui_font(13.5)).color(t.accent);
let file = ui.add(egui::Button::new(label).frame(false).min_size(vec2(46.0, 30.0)));
if file.clicked() {
app.open_backstage(crate::backstage::Page::Home);
}
}
/// System / Light / Dark, as in the View tab's Display theme box (`view.theme`).
fn theme_choices(app: &mut SheetApp, ui: &mut Ui) {
let lang = app.ui.language;
let mode = app.ui.theme_mode();
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.radio(mode == value, lang.tr(label)).clicked() {
act(app, "view.theme", json!({"mode": value}));
ui.close();
}
});
}
}
fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
@ -323,7 +352,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
}
if paste.clicked() {
if let Some(text) = app.grid.system_clipboard.take() {
act(app, "edit.paste", json!({"text": text}));
app.paste_external(text);
} else {
act(app, "edit.paste", json!({}));
}
@ -332,10 +361,13 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
// Keep a keyboard-opened menu anchored to the button when a pointer click
// cancels keytips, so its items remain in place and can still be clicked.
let anchor_id = paste.id.with("keytip_anchor");
// Query response state before taking the egui context write lock.
// Calling secondary_clicked() inside data_mut can deadlock.
let paste_secondary_clicked = paste.secondary_clicked();
let anchored = ui.ctx().data_mut(|data| {
let anchored = if paste_keys {
true
} else if paste.secondary_clicked() {
} else if paste_secondary_clicked {
false
} else {
data.get_temp::<bool>(anchor_id).unwrap_or(false)
@ -1143,7 +1175,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
});
sep(ui);
if big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false).clicked() {
act(app, "insert.recommendedCharts", json!({}));
app.open_dialog("recommendedCharts", json!({}));
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
@ -1228,6 +1260,11 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
}
});
sep(ui);
// Cell Controls.
if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() {
act(app, "insert.checkbox", json!({}));
}
sep(ui);
if big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false).clicked() {
app.open_dialog("insertLink", json!({}));
}
@ -1390,6 +1427,15 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
act(app, "pageLayout.printHeadings", json!({"on": p}));
}
});
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false);
if rtl {
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
}
if r.clicked() {
act(app, "view.rightToLeft", json!({"on": !rtl}));
}
}
fn formulas(app: &mut SheetApp, ui: &mut Ui) {
@ -1580,6 +1626,14 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("subtotal", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
app.open_dialog("analysisToolPak", json!({}));
}
sep(ui);
if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() {
app.open_dialog("solver", json!({}));
}
}
fn review(app: &mut SheetApp, ui: &mut Ui) {
@ -1618,6 +1672,9 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() {
act(app, "review.protectWorkbook", json!({}));
}
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
app.open_dialog("allowEditRanges", json!({}));
}
}
fn view(app: &mut SheetApp, ui: &mut Ui) {
@ -1636,9 +1693,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true));
let lang = app.ui.language;
ui.vertical(|ui| {
let mut fb = app.ui.formula_bar;
let mut fb = app.session.prefs.formula_bar;
if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() {
app.ui.formula_bar = fb;
act(app, "view.formulaBar", json!({"on": fb}));
}
let mut g = s.0;
if ui.checkbox(&mut g, lang.tr("Gridlines")).changed() {
@ -1654,7 +1711,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("zoom", json!({}));
}
if big_button(ui, Icon::Search, "100%", "Zoom to 100%", false).clicked() {
act(app, "view.zoom", json!({"percent": 100}));
act(app, "view.zoom100", json!({}));
}
if big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false).clicked() {
let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0)));
@ -1691,6 +1748,22 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
});
});
sep(ui);
// Interface size (issue #230): scales the whole window on top of the desktop's scale.
ui.vertical(|ui| {
ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim));
let current = app.ui.interface_scale;
let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") };
egui::ComboBox::from_id_salt("ui_interface_size")
.width(120.0)
.selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5)))
.show_ui(ui, |ui| {
for v in [100, 75, 90, 110, 125, 150, 175, 200] {
if ui.selectable_label(v == current, text(v)).clicked() {
act(app, "view.interfaceScale", json!({"percent": v}));
}
}
});
});
// Interface language: settings live in the View tab, like Excel's Options. The label follows
// the current language so it stays readable; the choices are always shown in their own script.
ui.vertical(|ui| {
@ -1733,9 +1806,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) {
};
ui.vertical(|ui| {
ui.label(egui::RichText::new("Table Name:").small());
let mut name = tname.clone();
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(120.0));
if r.lost_focus() && name != tname {
let field = egui::Id::new(("gridcraft.table_name", &tname));
if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0))
&& name != tname
{
act(app, "table.rename", json!({"table": tname, "name": name}));
}
});
@ -1799,6 +1873,9 @@ fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false).clicked() {
act(app, "chart.switchRowColumn", json!({"chart": id}));
}
if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() {
app.open_dialog("selectData", json!({"chart": id}));
}
let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true);
egui::Popup::menu(&ct).show(|ui| {
for (label, k, s) in [
@ -1910,3 +1987,16 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
fn unused(_: &Tokens, _: Icon) {
let _ = icons::GREEN;
}
#[cfg(test)]
mod title_bar_tests {
use super::*;
#[test]
fn mac_title_bar_centres_and_clears_the_traffic_lights() {
// The lights are centred in the bar, and content keeps the same gap after them.
let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT;
assert_eq!(MAC_CONTENT_LEFT - lights_end, margin);
}
}

View File

@ -129,7 +129,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
None
}
});
let rect = crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells);
let rect = geo.mirror_rect(crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells));
if !rect.is_positive() {
ed.rect = None;
app.text_box_editor = Some(ed);

View File

@ -166,10 +166,28 @@ pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
)
}
/// Whether epaint can open face `index` of this font file and it maps characters to glyphs.
/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font
/// file we did not ship is checked first with the same parser and the same call epaint makes
/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a
/// collection index past the end is rejected instead of crashing the app (#226). A face whose
/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused
/// too and the next candidate gets its turn.
pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
use skrifa::MetadataProvider;
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map())
}
/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is
/// skipped with a warning and the next candidate is tried.
#[cfg(not(target_arch = "wasm32"))]
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
for (p, index) in paths {
if let Ok(bytes) = std::fs::read(p) {
if !font_parses(&bytes, *index) {
log::warn!("skipping font {p} (face {index}): not a font file egui can read");
continue;
}
let mut fd = FontData::from_owned(bytes);
fd.index = *index;
return Some(fd);
@ -178,6 +196,21 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
None
}
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
for key in &bad {
log::warn!("dropping font {key:?}: egui can't parse it");
fonts.font_data.remove(key);
}
if !bad.is_empty() {
for chain in fonts.families.values_mut() {
chain.retain(|k| !bad.contains(k));
}
}
}
#[cfg(target_arch = "wasm32")]
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
None
@ -340,6 +373,7 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
fonts.families.insert(FontFamily::Proportional, ui);
}
drop_unparseable_fonts(&mut fonts);
fonts
}
@ -587,7 +621,7 @@ mod tests {
assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] {
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] {
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
}
}
@ -613,13 +647,15 @@ mod tests {
// Real render check, per script: the proportional family (what cells and the UI use) must
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
let cases: [(&str, Vec<(String, u32)>, &str); 6] = [
let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
("sys-cjk-hangul", cjk_hangul(), "한글"),
("sys-thai", thai(), "ทดสอบ"),
("sys-hebrew", hebrew(), "עברית"),
("ui", candidates(UI), "Русский"),
// Czech interface labels: every Czech accented letter, in both cases.
("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
];
for language in [Language::En, Language::Ja] {
let ctx = egui::Context::default();
@ -650,4 +686,51 @@ mod tests {
}
}
}
#[test]
fn unparseable_font_files_are_rejected_not_fatal() {
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
assert!(font_parses(&real, 0), "egui's own font must parse");
assert!(!font_parses(b"", 0));
assert!(!font_parses(&[0xAB; 64], 0));
assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused");
assert!(!font_parses(&real, 3), "a single-face file has no face 3");
// A bad face that slipped into the definitions is dropped (key and family entries), and the
// context builds its fonts without panicking.
let mut fonts = font_definitions();
fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0])));
for chain in fonts.families.values_mut() {
chain.push("sys-hebrew".into());
}
drop_unparseable_fonts(&mut fonts);
assert!(!fonts.font_data.contains_key("sys-hebrew"));
assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew")));
let ctx = egui::Context::default();
ctx.set_fonts(fonts);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("Hebrew עברית");
});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn try_load_skips_a_broken_candidate() {
// A candidate that exists but is not a readable font must not win over a good one behind it.
let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let bad = dir.join("broken.ttf");
let good = dir.join("good.ttf");
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
let _ = std::fs::write(&good, &real);
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -125,6 +125,25 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option<Color32>, tip: &str)
(m.on_hover_text(tr(ui, tip)).clicked(), a)
}
/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is
/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit).
pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f32>) -> Option<String> {
let width = width.unwrap_or(ui.spacing().text_edit_width);
let draft = id.with("draft");
let mut text = ui.data_mut(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| current.to_string());
let r = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(width));
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(draft));
return (!ui.input(|i| i.key_pressed(egui::Key::Escape))).then_some(text);
}
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, text));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
}
None
}
/// Our colour palette (theme row + tints + standard colours), returns a picked hex colour,
/// `Some("none")` for No Fill/Automatic.
pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {

View File

@ -0,0 +1,119 @@
//! Resizing a chart by dragging its bottom-right corner or with the Format Chart Area Size fields, through real input.
use egui::{Event, Key, Modifiers, PointerButton, accesskit::Role, pos2, vec2};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn fixture() -> (Harness, u32) {
let mut session = Session::new();
session.new_workbook();
session.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
session.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap();
session.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap();
let id = chart["chart"].as_u64().unwrap() as u32;
let mut h = egui_kittest::Harness::builder().with_size(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, id)
}
fn size(h: &Harness, id: u32) -> (f32, f32) {
let a = h.state().session.doc().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor;
(a.width, a.height)
}
fn corner(h: &Harness, id: u32) -> egui::Pos2 {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
let a = sh.charts.iter().find(|c| c.id == id).unwrap().anchor;
let min = geo.cell_rect(sh, CellRef::parse("D3").unwrap()).min + vec2(a.dx, a.dy) * geo.z;
min + vec2(a.width, a.height) * geo.z
}
fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
#[test]
fn dragging_the_corner_handle_resizes_the_chart() {
let (mut h, id) = fixture();
let (w0, h0) = size(&h, id);
let start = corner(&h, id);
// Select the chart first, as the user does before grabbing a handle.
let inside = start - vec2(40.0, 40.0);
h.input_mut().events.push(Event::PointerMoved(inside));
h.run_steps(2);
button(&mut h, inside, true);
button(&mut h, inside, false);
assert_eq!(h.state().selected_chart, Some(id));
h.input_mut().events.push(Event::PointerMoved(start));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe, "the corner shows a resize cursor");
button(&mut h, start, true);
// The pointer moves well past the drag threshold in one step, like a quick drag.
let end = start + vec2(60.0, 30.0);
h.input_mut().events.push(Event::PointerMoved(pos2(end.x, end.y)));
h.run_steps(3);
button(&mut h, end, false);
let (w1, h1) = size(&h, id);
assert!(w1 > w0 + 40.0 && h1 > h0 + 20.0, "chart grew from {w0}x{h0} to {w1}x{h1}");
}
/// Opens Format Chart Area for the chart and expands its Size section; returns the Width field's rect.
fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect {
h.state_mut().selected_chart = Some(id);
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "Size").click();
h.run_steps(2);
h.get_all_by_role(Role::SpinButton).next().unwrap().rect()
}
#[test]
fn typing_a_width_in_the_size_field_resizes_the_chart() {
let (mut h, id) = fixture();
let (_, h0) = size(&h, id);
let field = open_size_fields(&mut h, id);
h.input_mut().events.push(Event::PointerMoved(field.center()));
button(&mut h, field.center(), true);
button(&mut h, field.center(), false);
// Clicking the field selects its text; each keystroke must survive the next frame.
for digit in ["6", "4", "0"] {
h.input_mut().events.push(Event::Text(digit.into()));
h.run_steps(2);
}
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(size(&h, id), (640.0, h0));
}
#[test]
fn dragging_the_size_field_resizes_the_chart() {
let (mut h, id) = fixture();
let (w0, _) = size(&h, id);
let field = open_size_fields(&mut h, id);
h.input_mut().events.push(Event::PointerMoved(field.center()));
button(&mut h, field.center(), true);
for step in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(field.center() + vec2(10.0 * step as f32, 0.0)));
h.run_steps(1);
}
button(&mut h, field.center() + vec2(60.0, 0.0), false);
let (w1, _) = size(&h, id);
assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}");
}

View File

@ -83,3 +83,36 @@ fn programmatic_copy_does_not_write_to_the_system_clipboard() {
assert_eq!(*calls.borrow(), 0);
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
}
#[test]
fn pasting_reads_the_clipboard_html_for_the_numbers_behind_the_text() {
// #257: currency text from another spreadsheet stays a number when the host offers the HTML.
let mut h = harness(Services {
paste_html: Some(Box::new(|| Some(r#"<table><tr><td x:num="10000">Rp10,000.00</td></tr></table>"#.to_string()))),
..Default::default()
});
h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap();
h.input_mut().events.push(Event::Paste("Rp10,000.00".into()));
h.run_steps(2);
let value = h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(4, 3))));
assert_eq!(value, Some(gridcraft_engine::core::Value::number(10000.0)));
}
#[test]
fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
// Issue #348: lined-up areas copy as one block; others get Excel's message instead of nothing.
let mut h = harness(Services::default());
h.state_mut().session.execute("selection.set", json!({"range": "B1:B2,A1:A2"})).unwrap();
h.input_mut().events.push(Event::Copy);
h.step();
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n")));
assert!(h.state().message.is_none());
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2));
h.state_mut().session.execute("selection.set", json!({"range": "A1,B2"})).unwrap();
h.input_mut().events.push(Event::Copy);
h.step();
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections."));
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
}

View File

@ -0,0 +1,81 @@
//! Text fields over workbook values (chart titles, table name) keep what the user types until they commit it.
use egui::{Event, Key, Modifiers, accesskit::Role, vec2};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = egui_kittest::Harness::builder().with_size(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 session() -> Session {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
session
}
/// Clicks the field, replaces its text with `text` and presses Enter.
fn retype(h: &mut Harness, field: egui::Rect, text: &str) {
h.input_mut().events.push(Event::PointerMoved(field.center()));
h.run_steps(1);
h.get_all_by_role(Role::TextInput).find(|n| n.rect() == field).unwrap().click();
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
// One keystroke per frame, as a person types: each must survive the next frame.
for c in text.chars() {
h.input_mut().events.push(Event::Text(c.to_string()));
h.run_steps(2);
}
h.key_press(Key::Enter);
h.run_steps(2);
}
#[test]
fn typing_a_chart_title_in_format_chart_area_sets_it() {
let mut session = session();
session.execute("selection.set", json!({"range": "A1:B3"})).unwrap();
let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3"})).unwrap();
let id = chart["chart"].as_u64().unwrap() as u32;
let mut h = harness(session);
h.state_mut().selected_chart = Some(id);
h.state_mut().grid.pane = Some("formatChart".into());
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "Chart Title").click();
h.run_steps(2);
// The pane sits on the right; the formula bar and name box are further left.
let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > 850.0).unwrap();
retype(&mut h, field, "Sales");
let title = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().title.clone();
assert_eq!(title.as_deref(), Some("Sales"));
}
#[test]
fn typing_a_table_name_on_the_table_design_tab_renames_the_table() {
let mut session = session();
session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
session.execute("selection.set", json!({"range": "A2"})).unwrap();
let mut h = harness(session);
h.state_mut().ui.ribbon_tab = "Table Design".into();
h.run_steps(2);
let label = h.get_by_label("Table Name:").rect();
let below_label = |r: &egui::Rect| r.top() >= label.bottom() - 2.0 && (r.left() - label.left()).abs() < 20.0;
let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(below_label).unwrap();
retype(&mut h, field, "Sales");
let name = h.state().session.active().unwrap().wb.active().unwrap().tables[0].name.clone();
assert_eq!(name, "Sales");
}

View File

@ -1,4 +1,4 @@
//! The shared ribbon exposes basic file operations without requiring a native menu.
//! The File tab opens the Backstage view, which exposes the file operations without a native menu.
use std::sync::{Arc, Mutex};
@ -21,15 +21,26 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
h
}
/// Opens File (the Backstage view) and clicks a navigation entry or page button.
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();
if h.state().backstage.is_none() {
h.get_by_label("File").click();
h.run_steps(3);
}
click(h, label);
}
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
h.run_steps(3);
}
fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> {
h.state().backstage.as_ref().map(|b| b.page.id())
}
#[test]
fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() {
fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
let opened = Arc::new(Mutex::new(0));
let capture = opened.clone();
let mut h = harness(Services {
@ -42,39 +53,85 @@ fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() {
h.state_mut().ui.ribbon_tab = "Data".into();
h.state_mut().ui.ribbon_collapsed = true;
h.run_steps(2);
choose(&mut h, "New Workbook");
h.get_by_label("File").click();
h.run_steps(3);
assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home");
choose(&mut h, "New");
assert_eq!(page(&h), Some("new"));
click(&mut h, "Blank 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!(h.state().backstage.is_none(), "creating a workbook returns to the sheet");
choose(&mut h, "Open");
click(&mut h, "Browse…");
assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker");
assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().backstage.is_none(), "Esc returns to the sheet");
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 downloads = Arc::new(Mutex::new(Vec::<(String, 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() });
let mut h = harness(Services {
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), 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…");
assert!(h.state().editor.is_none(), "opening File commits the cell draft");
assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As");
click(&mut h, "Download (.xlsx)");
assert!(h.state().backstage.is_none());
choose(&mut h, "Save As");
click(&mut h, "Download (.xlsx)");
choose(&mut h, "Export");
click(&mut h, "CSV (Comma delimited)");
let saved = downloads.lock().unwrap();
assert_eq!(saved.len(), 2);
for bytes in saved.iter() {
assert_eq!(saved.len(), 3);
for (_, bytes) in saved.iter().take(2) {
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));
}
assert!(saved[2].0.ends_with(".csv"));
assert!(String::from_utf8_lossy(&saved[2].1).contains("123"));
drop(saved);
assert!(h.state().editor.is_none());
choose(&mut h, "Close");
assert!(h.state().backstage.is_none());
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));
}
#[test]
fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
let mut h = harness(Services::default());
let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap();
assert_eq!(r["calculation"]["mode"], "automatic");
h.run_steps(3);
assert_eq!(page(&h), Some("options"));
click(&mut h, "Info");
assert_eq!(page(&h), Some("info"));
for id in ["print", "export", "saveAs", "new", "open", "home"] {
h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap();
h.run_steps(2);
assert_eq!(page(&h), Some(id));
}
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err());
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
assert!(h.state().backstage.is_none());
// The back arrow returns to the sheet too.
h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap();
h.run_steps(2);
click(&mut h, "Back");
assert!(h.state().backstage.is_none());
}
#[test]
fn export_as_pdf_is_delivered_as_a_download() {
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));

View File

@ -183,5 +183,21 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
assert!(!enter(&mut app, "=SUMA(1;2)"));
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::PtBr);
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation");
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
}
#[test]
fn czech_interface_retains_its_canonical_formula_fallback() {
// The Czech table translates interface labels only: formulas keep canonical English names,
// `,` separators and `.` decimals until a Czech formula locale exists.
let mut app = app();
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
assert_eq!(app.ui.language, Language::Cs);
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
app.begin_edit(None, true);
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
assert!(!enter(&mut app, "=SUMA(1;2)"));
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::Cs);
}

View File

@ -0,0 +1,144 @@
//! Formula helpers must stay in the worksheet while its editor scrolls.
use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput {
let mut output =
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
});
output.textures_delta.clear();
output
}
fn frames(ctx: &Context, app: &mut SheetApp) {
for _ in 0..4 {
frame(ctx, app);
}
}
fn visible(ctx: &Context, editor: &str) -> bool {
ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac"))))
}
#[test]
fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() {
for text in ["=SU", "=SUM("] {
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
app.begin_edit(Some(text.into()), false);
if text == "=SU" {
app.editor.as_mut().unwrap().update_autocomplete(&["SUM".into(), "SUMIF".into()]);
}
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.cell_editor"), "visible cell shows {text} help");
app.view_mut().unwrap().scroll.y = 400.0;
frames(&ctx, &mut app);
assert!(!visible(&ctx, "gridcraft.cell_editor"), "scrolled-out cell must hide {text} help");
assert_eq!(app.editor.as_ref().unwrap().text, text, "scrolling preserves the edit");
app.view_mut().unwrap().scroll.y = 0.0;
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores {text} help");
}
}
#[test]
fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() {
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
app.begin_edit(Some("=SUM(".into()), true);
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.formula_bar"));
app.view_mut().unwrap().scroll.y = 400.0;
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.formula_bar"));
}
#[test]
fn cell_argument_hint_is_clipped_to_the_worksheet() {
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
app.begin_edit(Some("=SUM(".into()), false);
frames(&ctx, &mut app);
app.view_mut().unwrap().scroll.y = 10.0;
frames(&ctx, &mut app);
let output = frame(&ctx, &mut app);
let cells = app.grid.cells_rect.unwrap();
let hint = output.shapes.iter().find(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "SUM(")).expect("argument hint painted");
assert!(cells.contains_rect(hint.clip_rect), "helper must not paint over the headers: {:?}", hint.clip_rect);
}
#[test]
fn scrolled_out_cell_editor_still_handles_escape() {
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
app.begin_edit(Some("=SUM(".into()), false);
frames(&ctx, &mut app);
app.view_mut().unwrap().scroll.y = 400.0;
frames(&ctx, &mut app);
let mut output = ctx.run_ui(
RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))),
events: vec![egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
..Default::default()
},
|ui| app.ui(ui),
);
output.textures_delta.clear();
assert!(app.editor.is_none(), "scrolling must not disable editor keys");
}
#[test]
fn cell_editor_and_helper_stay_below_the_frozen_top_row() {
let ctx = Context::default();
let mut session = Session::new();
session.new_workbook();
session.run("view.freezeTopRow", serde_json::json!({})).unwrap();
session.run("selection.set", serde_json::json!({"range": "B5"})).unwrap();
let mut app = SheetApp::new(session, Default::default());
frames(&ctx, &mut app);
let sh = app.session.active().unwrap().wb.active().unwrap();
let frozen_h = sh.row_height(0);
let scroll = sh.row_top(4) as f32;
app.begin_edit(Some("=SUM(".into()), false);
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.cell_editor"));
app.view_mut().unwrap().scroll.y = scroll - frozen_h / 2.0;
frames(&ctx, &mut app);
let output = frame(&ctx, &mut app);
let frozen_bottom = app.grid.cells_rect.unwrap().top() + frozen_h * gridcraft_ui_egui::grid::DISPLAY_SCALE;
let editor = output
.shapes
.iter()
.find(|s| s.clip_rect.bottom() > frozen_bottom && matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "=SUM("))
.expect("editor painted");
assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect);
app.view_mut().unwrap().scroll.y = scroll;
frames(&ctx, &mut app);
assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper");
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(");
app.view_mut().unwrap().scroll.y = 0.0;
frames(&ctx, &mut app);
assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper");
}

View File

@ -0,0 +1,76 @@
//! Freezing panes by dragging the freeze handles on the header edges (#195).
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::grid::{Geo, freeze_handles};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
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
}
fn geo(h: &Harness) -> Geo {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
}
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]);
h.step();
for i in 1..=6 {
let t = i as f32 / 6.0;
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t));
h.step();
}
h.input_mut().events.push(press(to, false));
h.run_steps(3);
}
fn freeze(h: &Harness) -> Option<(u32, u32)> {
h.state().session.active().unwrap().wb.active().unwrap().freeze
}
#[test]
fn dragging_the_handles_freezes_moves_and_unfreezes() {
let mut h = harness();
assert_eq!(freeze(&h), None);
// Column handle from the corner to just past column C's left edge: two columns frozen.
let g = geo(&h);
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
let (cols, _) = freeze_handles(&g);
let target = pos2(g.cell_rect(&sh, CellRef::new(0, 2)).left() + 3.0, g.cells.top() + 40.0);
drag(&mut h, cols.center(), target);
assert_eq!(freeze(&h), Some((0, 2)));
// Row handle down to row 4's top: three rows frozen, columns kept.
let g = geo(&h);
let (_, rows) = freeze_handles(&g);
let target = pos2(g.cells.left() + 40.0, g.cell_rect(&sh, CellRef::new(3, 4)).top() + 2.0);
drag(&mut h, rows.center(), target);
assert_eq!(freeze(&h), Some((3, 2)));
// Both handles back to the corner unfreeze.
let g = geo(&h);
let (cols, _) = freeze_handles(&g);
drag(&mut h, cols.center(), pos2(g.cells.left() - 5.0, cols.center().y));
assert_eq!(freeze(&h), Some((3, 0)));
let g = geo(&h);
let (_, rows) = freeze_handles(&g);
drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0));
assert_eq!(freeze(&h), None);
}

View File

@ -114,3 +114,41 @@ fn dragging_a_header_boundary_still_resizes_normally() {
assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit")));
}
}
#[test]
fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line() {
// Issue #341: Ctrl-selected rows/columns resize together; unselected lines keep their size.
for (rows, selection, dragged, changed) in [
(false, "A:A,C:C", 2, [true, false, true, false]),
(false, "A:A,C:C", 0, [true, false, true, false]),
(false, "A:A,C:D", 3, [true, false, true, true]),
(false, "A:A,C:C", 1, [false, true, false, false]),
(false, "A:A,C3", 0, [true, false, false, false]),
(true, "1:1,3:3", 2, [true, false, true, false]),
(true, "1:1,3:3", 0, [true, false, true, false]),
(true, "1:1,3:4", 3, [true, false, true, true]),
(true, "1:1,3:3", 1, [false, true, false, false]),
(true, "1:1,C3", 0, [true, false, false, false]),
] {
let mut h = harness(selection);
let before = sizes(&h, rows);
let (start, zoom) = edge(&h, rows, dragged);
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);
let expected = (before[dragged as usize] + 30.0 / zoom).round();
for (index, changed) in changed.iter().enumerate() {
let want = if *changed { expected } else { before[index] };
assert_eq!(after[index], want, "selection {selection}, dragged {dragged}, line {index}");
}
assert_eq!(h.state().session.active().unwrap().undo.len(), 1, "selection {selection}: one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line");
}
}

View File

@ -1,6 +1,7 @@
//! UI interaction tests: real egui input through the whole app (no GPU needed).
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
@ -85,6 +86,21 @@ fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) {
h.run_steps(2);
}
fn click_chart(h: &mut egui_kittest::Harness<'static, SheetApp>, id: u32) {
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 anchor = sheet.charts.iter().find(|chart| chart.id == id).unwrap().anchor;
let pos = egui::pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z)
+ egui::vec2(anchor.width, anchor.height) * geo.z / 2.0;
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());
@ -125,6 +141,26 @@ fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() {
assert!(h.state().editor.is_none());
}
#[test]
fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() {
// #247: typing A1 in the Name Box and pressing Enter selected A2; the grid replayed the Enter.
let mut h = harness(blank());
h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap();
h.run_steps(2);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("gridcraft.name_box")));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.input_mut().events.push(Event::Text("A1".into()));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
assert!(h.state().name_box.is_none());
// The grid has the focus back: a plain Enter there still moves down.
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(1, 0));
}
#[test]
fn typing_enters_values_and_moves_down() {
let mut h = harness(blank());
@ -170,6 +206,24 @@ fn shortcuts_format_and_extend() {
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn delete_key_removes_the_selected_chart() {
let mut session = blank();
session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap();
let id = session.execute("insert.chart", json!({"range": "A1:B2", "at": "D2"})).unwrap()["chart"].as_u64().unwrap() as u32;
let mut h = harness(session);
click_cell(&mut h, "C10");
click_chart(&mut h, id);
assert_eq!(h.state().selected_chart, Some(id));
key(&mut h, Key::Delete, Modifiers::NONE);
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty());
assert_eq!(h.state().selected_chart, None);
assert_eq!(value(&h, "A1"), Value::Number(1.0));
}
#[test]
fn renders_every_ribbon_tab_without_panicking() {
let mut s = Session::new();
@ -199,9 +253,14 @@ fn language_switch_translates_the_ribbon_and_persists() {
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
assert!(ids.contains(&id), "engine.commands lists {id}");
}
for (language, expected) in
[(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные"), (Language::PtBr, "Dados")]
{
for (language, expected) in [
(Language::Zh, "数据"),
(Language::Ja, "データ"),
(Language::Ko, "데이터"),
(Language::Ru, "Данные"),
(Language::PtBr, "Dados"),
(Language::De, "Daten"),
] {
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
h.run_steps(2); // every locale renders the ribbon without panicking
assert_eq!(h.state().ui.language, language);
@ -209,10 +268,106 @@ fn language_switch_translates_the_ribbon_and_persists() {
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, language);
}
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
h.run_steps(2);
assert_eq!(h.state().ui.language, Language::Cs);
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, Language::Cs);
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
assert_eq!(h.state().ui.language, Language::En);
}
#[test]
fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() {
// #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it
// had just opened).
let mut h = harness(blank());
h.state_mut().open_dialog("nameManager", json!({}));
h.run_steps(4);
h.get_by_label("New…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("New Name"));
if let Some(d) = h.state_mut().dialog.as_mut() {
d.values.insert("name".into(), json!("Total"));
d.values.insert("refersTo".into(), json!("=Sheet1!$A$1"));
}
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
let names = |h: &egui_kittest::Harness<'static, SheetApp>| {
h.state().session.active().unwrap().wb.names.iter().map(|n| n.name.clone()).collect::<Vec<_>>()
};
assert_eq!(names(&h), ["Total"]);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"), "OK returns to the Name Manager");
h.get_by_label("Edit…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Name"));
if let Some(d) = h.state_mut().dialog.as_mut() {
d.values.insert("name".into(), json!("GrandTotal"));
}
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert_eq!(names(&h), ["GrandTotal"], "Edit renames instead of adding a second name");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"));
}
#[test]
fn recommended_charts_dialog_inserts_the_chosen_chart() {
// #215: Recommended Charts opens a dialog of chart types for the data instead of inserting a
// column chart straight away; OK inserts the one picked.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:B4"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("recommendedCharts", json!({}));
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("recommendedCharts"));
h.get_by_label("Pie").click();
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::Pie]);
assert!(h.state().selected_chart.is_some(), "the new chart is selected");
// Without numbers there is nothing to recommend: a message, no dialog.
h.state_mut().session.run("selection.set", json!({"range": "A1:A4"})).unwrap();
h.state_mut().open_dialog("recommendedCharts", json!({}));
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_some());
}
#[test]
fn select_data_dialog_sets_the_category_axis_labels() {
// #287: Chart Design › Select Data edits the Horizontal (Category) Axis Labels and removes the
// series the X values were plotted as.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap();
let mut h = harness(s);
h.state_mut().selected_chart = Some(id as u32);
h.state_mut().open_dialog("selectData", json!({"chart": id}));
h.run_steps(2);
let d = h.state_mut().dialog.as_mut().unwrap();
assert_eq!(d.title, "Select Data Source");
assert_eq!(d.values["names"], json!(["Year", "Sales"]));
d.values.insert("categories".into(), json!("=A2:A4"));
d.values.insert("remove".into(), json!([0]));
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let chart = h.state().session.active().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!(chart.series.len(), 1);
assert_eq!(chart.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
assert_eq!(chart.series[0].values, "Sheet1!$B$2:$B$4");
}
#[test]
fn dialogs_open_and_close() {
let mut h = harness(blank());
@ -227,7 +382,10 @@ fn dialogs_open_and_close() {
"commandSearch",
"spelling",
"goTo",
"pivotCalculatedField",
"about",
"analysisToolPak",
"solver",
] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
@ -237,6 +395,70 @@ fn dialogs_open_and_close() {
}
}
#[test]
fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("analysisToolPak", json!({}));
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some("Descriptive Statistics"));
let fill = |h: &mut egui_kittest::Harness<'static, SheetApp>| {
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("range".into(), json!("A1:A5"));
d.values.insert("outputTo".into(), json!("range"));
d.values.insert("output".into(), json!("C1"));
};
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog");
assert_eq!(value(&h, "C3"), Value::from("Mean"));
assert_eq!(value(&h, "D3"), Value::Number(3.0));
// A second report into the same cells asks before replacing them (opened with a tool, the
// dialog starts at that tool's form).
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
let d = h.state().dialog.as_ref().expect("the dialog stays open to ask");
assert_eq!(d.values.get("askOverwrite"), Some(&json!(true)));
assert!(h.state().message.is_none());
}
#[test]
fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
)
.unwrap();
assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0));
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({}));
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!(d.values.get("objective"), Some(&json!("B1")));
assert_eq!(d.values.get("method"), Some(&json!("simplex")));
assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3));
h.state_mut().dialog = None;
// Solver Results › Restore Original Values undoes the solution.
h.state_mut().open_dialog("solverResults", r);
h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert_eq!(value(&h, "A1"), Value::Number(0.0));
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn about_renders_every_tab() {
let mut h = harness(blank());

View File

@ -0,0 +1,42 @@
//! Interface size preference (#230): a persistent zoom for the whole window, clamped.
use gridcraft_engine::Session;
use gridcraft_ui_egui::{SheetApp, UiState};
use serde_json::json;
#[test]
fn interface_size_applies_persists_and_clamps() {
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| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(2);
assert_eq!(h.ctx.zoom_factor(), 1.0);
h.state_mut().run("view.interfaceScale", json!({"percent": 125})).unwrap();
h.run_steps(2);
assert_eq!(h.ctx.zoom_factor(), 1.25);
let saved: UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(saved.interface_scale, 125);
// Hostile and missing values: clamped or refused, never applied raw.
assert_eq!(h.state_mut().run("view.interfaceScale", json!({"percent": 1e12})).unwrap()["percent"], 200);
assert_eq!(h.state_mut().run("view.interfaceScale", json!({"percent": -5})).unwrap()["percent"], 75);
assert!(h.state_mut().run("view.interfaceScale", json!({})).is_err(), "no value is an error, not a dialog");
assert!(h.state_mut().run("view.interfaceScale", json!({"percent": "huge"})).is_err());
h.state_mut().run("view.interfaceScale", json!({"percent": "auto"})).unwrap();
h.run_steps(2);
assert_eq!(h.ctx.zoom_factor(), 1.0);
// A malformed or out-of-range ui.json keeps the other preferences.
let ui: UiState = serde_json::from_str(r#"{"dark": true, "interfaceScale": "big"}"#).unwrap();
assert!(ui.dark);
assert_eq!(ui.interface_scale, 100);
let ui: UiState = serde_json::from_str(r#"{"interfaceScale": 900}"#).unwrap();
assert_eq!(ui.interface_scale, 200);
}

View File

@ -0,0 +1,108 @@
//! Excel keyboard shortcuts (crate::keymap) dispatched through real egui key events.
use egui::{Event, Key, Modifiers};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::BorderStyle;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
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 book(selected: &str) -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1.5, 2], [3, 4], [5, 6]]})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=A1*2"})).unwrap();
s.execute("selection.set", json!({"range": selected})).unwrap();
s
}
/// A key press as a US keyboard reports it (the physical key is the unshifted one).
fn key(h: &mut Harness, k: Key, m: Modifiers) {
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
h.run_steps(2);
}
fn style(h: &Harness, a1: &str) -> gridcraft_engine::model::Style {
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
d.wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).clone()
}
/// Ctrl on Windows and Linux sets both `ctrl` and `command`.
const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };
const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL };
#[test]
fn ctrl_shift_symbols_apply_number_formats_and_borders() {
let mut h = harness(book("A1:B2"));
key(&mut h, Key::Num4, CTRL_SHIFT);
assert_eq!(style(&h, "B2").num_fmt.as_str(), "$#,##0.00_);($#,##0.00)");
key(&mut h, Key::Num5, CTRL_SHIFT);
assert_eq!(style(&h, "A1").num_fmt.as_str(), "0%");
key(&mut h, Key::Backtick, CTRL_SHIFT);
assert_eq!(style(&h, "A1").num_fmt.as_str(), "General");
key(&mut h, Key::Num7, CTRL_SHIFT);
assert!(style(&h, "A1").borders.top.style != BorderStyle::None && style(&h, "B2").borders.right.style != BorderStyle::None);
key(&mut h, Key::Minus, CTRL_SHIFT);
assert!(style(&h, "A1").borders.top.style == BorderStyle::None);
// Ctrl+Shift+End extends from the anchor to the last used cell.
key(&mut h, Key::End, CTRL_SHIFT);
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3");
}
#[test]
fn copy_formula_from_above_and_function_keys() {
let mut h = harness(book("C2"));
key(&mut h, Key::Quote, CTRL);
assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=A1*2"));
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().editor.is_none());
let sheets = h.state().session.active().unwrap().wb.sheets.len();
key(&mut h, Key::F11, Modifiers::SHIFT);
assert_eq!(h.state().session.active().unwrap().wb.sheets.len(), sheets + 1);
// Back on the data: Alt+F1 charts the current region, Ctrl+Alt+= zooms in.
h.state_mut().session.run("sheet.previous", json!({})).unwrap();
h.state_mut().session.run("selection.set", json!({"range": "A1:B3"})).unwrap();
key(&mut h, Key::F1, Modifiers::ALT);
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].charts.len(), 1);
key(&mut h, Key::Equals, Modifiers { alt: true, ..CTRL });
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].zoom, 110);
}
#[test]
fn windows_and_mac_variants() {
let cmd = CTRL;
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
let ctrl = Modifiers::CTRL;
let run = |id: &'static str| Some(Shortcut::Run(id, json!({})));
assert_eq!(lookup(Key::L, Some(Key::L), cmd, false), run("insert.table"));
assert_eq!(lookup(Key::L, Some(Key::L), mac_cmd, true), run("home.alignLeft"));
assert_eq!(lookup(Key::E, Some(Key::E), mac_cmd, true), run("home.alignCenter"));
assert_eq!(lookup(Key::E, Some(Key::E), ctrl, true), run("edit.flashFill"));
assert_eq!(lookup(Key::Equals, Some(Key::Equals), Modifiers::ALT, false), run("formulas.autoSum"));
// The shifted character doesn't matter: Ctrl+Shift+1 is the Number format wherever `!` is.
assert_eq!(
lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false),
Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"})))
);
// Plain keys stay with the grid (typing, navigation).
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
}

View File

@ -0,0 +1,70 @@
//! Protect Sheet / Unprotect Sheet mask the password as it's typed (issue #347).
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
const SECRET: &str = "s3cret";
fn harness() -> 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, Default::default()),
);
h.run_steps(4);
h
}
fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) {
h.get_by_role(Role::PasswordInput).click();
h.run_steps(2);
h.input_mut().events.push(egui::Event::Text(SECRET.into()));
h.run_steps(2);
}
fn secret_on_screen(h: &egui_kittest::Harness<'static, SheetApp>) -> bool {
h.query_all_by(|n| n.value().is_some_and(|v| v.contains(SECRET)) || n.label().is_some_and(|l| l.contains(SECRET))).next().is_some()
}
#[test]
fn protect_and_unprotect_sheet_mask_the_password() {
let mut h = harness();
h.state_mut().open_dialog("protectSheet", json!({}));
h.run_steps(3);
type_password(&mut h);
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(SECRET)));
assert!(!secret_on_screen(&h), "the typed password must not be readable");
assert!(h.get_by_role(Role::PasswordInput).value().is_some_and(|v| !v.is_empty() && !v.contains(SECRET)));
// Show password reveals it on purpose; unticking masks it again.
h.get_by_label("Show password").click();
h.run_steps(3);
assert!(h.query_by_role(Role::PasswordInput).is_none());
assert!(secret_on_screen(&h));
h.get_by_label("Show password").click();
h.run_steps(3);
assert!(h.query_by_role(Role::PasswordInput).is_some());
assert!(!secret_on_screen(&h));
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some()));
// Unprotect asks for the password with a masked field too, and it starts hidden again.
h.state_mut().open_dialog("unprotectSheet", json!({}));
h.run_steps(3);
type_password(&mut h);
assert!(!secret_on_screen(&h), "the typed password must not be readable");
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none());
}

View File

@ -0,0 +1,90 @@
//! Sheet Right-to-Left: the grid is mirrored on screen and pointer/keyboard input follows it.
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, RangeRef};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["first", 2], [3, "fourth"]]})).unwrap();
session.execute("view.rightToLeft", json!({"on": true})).unwrap();
session.execute("selection.set", json!({"cell": "B2"})).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 geo(h: &Harness) -> Geo {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
}
/// Where `a1` is drawn on screen.
fn point(h: &Harness, a1: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
geo(h).screen_cell_rect(sheet, CellRef::parse(a1).unwrap()).center()
}
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 current(h: &Harness) -> RangeRef {
h.state().session.active().unwrap().selection.current()
}
#[test]
fn column_a_is_drawn_on_the_right_and_clicks_hit_the_mirrored_cells() {
let mut h = harness();
let g = geo(&h);
assert!(g.rtl);
let (a1, b1) = (point(&h, "A1"), point(&h, "B1"));
assert!(a1.x > b1.x, "A1 at {a1:?} should be right of B1 at {b1:?}");
// Row headers sit on the right edge, so A1 ends where they begin.
let sheet_a1 = {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
g.screen_cell_rect(sheet, CellRef::parse("A1").unwrap())
};
assert!((sheet_a1.right() - (g.rect.right() - g.header_w)).abs() < 0.5);
// Drag from C3 to F7 at their on-screen positions.
let (from, to) = (point(&h, "C3"), point(&h, "F7"));
frame(&mut h, Event::PointerMoved(from));
frame(&mut h, button(from, true));
frame(&mut h, Event::PointerMoved(to));
frame(&mut h, button(to, false));
h.run_steps(1);
assert_eq!(current(&h), RangeRef::parse("C3:F7").unwrap());
}
#[test]
fn left_and_right_arrows_follow_the_screen() {
let mut h = harness();
h.key_press(Key::ArrowLeft);
h.run_steps(1);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("C2").unwrap());
h.key_press(Key::ArrowRight);
h.key_press(Key::ArrowRight);
h.run_steps(1);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("A2").unwrap());
}

View File

@ -162,3 +162,33 @@ fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() {
system_theme(&mut h, Some(Theme::Dark));
assert_appearance(&h, false);
}
#[test]
fn title_bar_switch_and_file_menu_choose_the_theme() {
// #194: one click in the title bar switches light/dark; File › Display theme offers all three.
let mut h = harness(UiState::default());
system_theme(&mut h, Some(Theme::Light));
assert_appearance(&h, false);
h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click();
h.run_steps(2);
assert_eq!(preferences(&h)["dark"], true);
assert_eq!(preferences(&h)["systemTheme"], false);
assert_appearance(&h, true);
h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click();
h.run_steps(2);
assert_appearance(&h, false);
// File opens Backstage; its Options page holds the Office Theme choice (#331).
h.get_by_label("File").click();
h.run_steps(3);
h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click();
h.run_steps(3);
// The first combo box on the page is Display theme.
h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("theme combo").click();
h.run_steps(3);
h.get_by_label("System").click();
h.run_steps(3);
assert_eq!(preferences(&h)["systemTheme"], true);
system_theme(&mut h, Some(Theme::Dark));
assert_appearance(&h, true);
}

View File

@ -24,7 +24,10 @@ pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Fo
let Some(expr) = formula.expr_arc() else { return false };
let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() };
let shape = scan.expr(&expr, sheet, false, 0);
shape.array || shape.affected
// An explicit `@` is written as `_xlfn.SINGLE`, which only dynamic-array formulas carry.
let mut at = false;
expr.walk(&mut |e| at |= matches!(e, Expr::Unary(UnOp::At, _)));
shape.array || shape.affected || at
}
struct Scan<'a> {
@ -215,6 +218,9 @@ fn scalar_arg(name: &str, i: usize) -> bool {
"FILTER" | "SORTBY" => i > 1,
"SORT" | "UNIQUE" | "TAKE" | "DROP" | "CHOOSECOLS" | "CHOOSEROWS" | "TOROW" | "TOCOL" | "WRAPROWS" | "WRAPCOLS" | "EXPAND" => i != 0,
"LINEST" | "LOGEST" | "TREND" | "GROWTH" => i > 1,
"FORECAST" | "FORECAST.LINEAR" => i == 0,
"FORECAST.ETS" | "FORECAST.ETS.CONFINT" => i != 1 && i != 2,
"FORECAST.ETS.SEASONALITY" | "FORECAST.ETS.STAT" => i > 1,
_ => true,
}
}

View File

@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
source: None,
by_rows: false,
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
})
}
@ -465,6 +466,10 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
if (1..=48).contains(&ch.style) {
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
}
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
}
s.push_str("<c:chart>");
match &ch.title {
Some(t) if !t.is_empty() => {

Some files were not shown because too many files have changed in this diff Show More