Merge commit '684bc41d0318667295ea8bc4c8887a27f47fb5a7' into fix/multi-select-resize

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
lloyd 2026-10-11 18:16:57 +02:00
commit 4e94c2f720
79 changed files with 3675 additions and 823 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

14
Cargo.lock generated
View File

@ -1555,6 +1555,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"skrifa",
"sys-locale",
"toml",
"web-sys",
@ -1584,7 +1585,7 @@ dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-model",
"quick-xml 0.38.4",
"quick-xml",
"thiserror 2.0.21",
"zip",
]
@ -2739,15 +2740,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"
@ -3725,7 +3717,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,7 +32,7 @@ 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"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
@ -45,6 +45,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 509 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,

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 509 / 523, catalog 260 / 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 260 / 290 (89.7%), worksheet functions 509 / 523 (97.3%; 500 working, 95.6%) |
| **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,16 @@ 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-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 +153,12 @@ 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-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

@ -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;
@ -116,14 +119,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 +177,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() })

View File

@ -28,3 +28,17 @@ 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

View File

@ -554,7 +554,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 +677,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 {
@ -1455,6 +1469,7 @@ fn is_special(n: &str) -> bool {
| "INDIRECT"
| "INDEX"
| "CELL"
| "TRIMRANGE"
| "SUBTOTAL"
| "AGGREGATE"
| "TEXT"
@ -1492,6 +1507,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

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

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

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

View File

@ -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
),
@ -408,9 +408,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
@ -674,13 +674,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 +705,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();
@ -1139,6 +1165,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

@ -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

@ -1157,15 +1157,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

@ -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

@ -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,6 +8,7 @@
pub mod catalog;
pub mod cf;
mod clip_html;
pub mod cmd;
pub mod display;
pub mod fill;
@ -150,6 +151,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 +166,7 @@ impl Default for Prefs {
sheets_in_new_workbook: 1,
r1c1: false,
user_dictionary: vec![],
formula_bar: true,
}
}
}

View File

@ -52,6 +52,52 @@ 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 entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
let mut s = s();
@ -452,6 +498,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 +591,26 @@ 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");
}
/// 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 +705,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();
@ -1657,3 +1777,32 @@ fn row_height_and_column_width_take_a_list_of_areas() {
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");
}
/// 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)]);
}

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]

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

@ -122,6 +122,8 @@ pub enum Category {
Compatibility,
Web,
Cube,
/// Excel's "Add-in and Automation" category (EUROCONVERT).
AddIn,
}
pub struct FnSpec {

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

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

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

@ -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

@ -303,7 +303,10 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
&& 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);
}
}
}
}

View File

@ -168,13 +168,12 @@ 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)"),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
@ -196,12 +195,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

@ -33,6 +33,8 @@ pub struct Dialog {
pub result: Option<Json>,
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 {
@ -53,6 +55,7 @@ impl Dialog {
result: None,
picture_inbox: None,
picture_target: None,
back: None,
}
}
@ -69,6 +72,7 @@ impl Dialog {
result: None,
picture_inbox: None,
picture_target: None,
back: None,
}
}
@ -353,18 +357,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",
@ -636,10 +654,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);
}
}
}
}
_ => {}
@ -677,7 +697,7 @@ 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),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
@ -810,7 +830,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}));
@ -883,8 +904,21 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
} 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);
}
}
@ -1485,30 +1519,61 @@ 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")}));
}
});
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;
}
});
if ui.button("New…").clicked() {
app.open_dialog("defineName", json!({}));
}
}
fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) {

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.
@ -336,9 +340,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

@ -186,6 +186,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.
@ -396,7 +423,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 {
@ -901,21 +928,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 +973,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 +985,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,12 +1012,15 @@ 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;
@ -1047,6 +1103,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() {
@ -1065,6 +1124,12 @@ 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));
// Hover cursors.
if resp.hovered()
@ -1072,16 +1137,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
{
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);
@ -1135,7 +1205,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 +1223,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 };
}
@ -1258,11 +1330,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;
}
@ -1428,7 +1500,8 @@ 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)));
}
@ -1449,7 +1522,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();
@ -1615,6 +1688,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 {
@ -1663,7 +1752,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);
@ -1715,7 +1804,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!({}));
@ -1763,10 +1854,18 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
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);
}
@ -1810,6 +1909,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| {
@ -1826,19 +1926,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() {
@ -1860,7 +1973,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;
}
}
@ -1961,7 +2077,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);
@ -1993,6 +2109,7 @@ 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);
@ -2014,7 +2131,7 @@ 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());
@ -2054,7 +2171,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));
@ -2230,3 +2349,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,8 @@
use serde::{Deserialize, Serialize};
mod de;
mod es;
mod ko;
mod pt;
mod ru;
@ -26,10 +28,11 @@ pub enum Language {
Ru,
#[serde(rename = "pt")]
PtBr,
De,
}
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; 8] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De];
/// The language's own name, shown in the switcher.
pub fn name(self) -> &'static str {
@ -41,6 +44,7 @@ impl Language {
Self::Ko => "한국어",
Self::Ru => "Русский",
Self::PtBr => "Português (Brasil)",
Self::De => "Deutsch",
}
}
@ -54,6 +58,7 @@ impl Language {
Self::Ko => "ko",
Self::Ru => "ru",
Self::PtBr => "pt",
Self::De => "de",
}
}
@ -70,6 +75,7 @@ impl Language {
"ko" => Some(Self::Ko),
"ru" => Some(Self::Ru),
"pt" => Some(Self::PtBr),
"de" => Some(Self::De),
_ => None,
}
}
@ -97,12 +103,14 @@ 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),
};
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
}
@ -345,6 +353,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Light", "ライト"),
("Dark", "ダーク"),
("Interface language", "表示言語"),
("Interface size", "表示サイズ"),
("Auto", "自動"),
("Command\nPalette", "コマンド パレット"),
("Agent\nControl", "エージェント制御"),
("Action\nJournal", "操作ジャーナル"),
@ -434,12 +444,19 @@ 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_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");
@ -476,7 +493,9 @@ 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));

View File

@ -0,0 +1,276 @@
//! 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",
"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,277 @@
//! 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",
"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

@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "틀 고정 취소",
"Dark Mode" => "다크 모드",
"Interface language" => "인터페이스 언어",
"Interface size" => "인터페이스 크기",
"Auto" => "자동",
"Command\nPalette" => "명령\n팔레트",
"Agent\nControl" => "에이전트\n제어",
"Action\nJournal" => "작업\n기록",

View File

@ -193,6 +193,8 @@ 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",
"Command\nPalette" => "Paleta de\nComandos",
"Agent\nControl" => "Controle\ndo Agente",
"Action\nJournal" => "Registro\nde Ações",

View File

@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "Снять закрепление областей",
"Dark Mode" => "Тёмный режим",
"Interface language" => "Язык интерфейса",
"Interface size" => "Размер интерфейса",
"Auto" => "Авто",
"Command\nPalette" => "Палитра\nкоманд",
"Agent\nControl" => "Управление\nагентом",
"Action\nJournal" => "Журнал\nдействий",

View File

@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Unfreeze Panes" => "取消冻结窗格",
"Dark Mode" => "深色模式",
"Interface language" => "界面语言",
"Interface size" => "界面大小",
"Auto" => "自动",
"Command\nPalette" => "命令\n面板",
"Agent\nControl" => "智能体\n控制",
"Action\nJournal" => "操作\n日志",

View File

@ -45,7 +45,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 +54,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 +82,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 +102,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 +138,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`.
@ -263,6 +290,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() {
@ -294,10 +332,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 +353,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("");
@ -567,6 +615,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
@ -608,7 +666,7 @@ impl SheetApp {
self.ribbon_keys(&ctx);
ribbon::title_bar(self, ui);
ribbon::show(self, ui);
if self.ui.formula_bar {
if self.session.prefs.formula_bar {
formula_bar::show(self, ui);
}
if self.ui.status_bar {

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

@ -44,7 +44,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).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, 26.0)).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());
@ -323,7 +326,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 +335,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)
@ -1636,9 +1642,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 +1660,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 +1697,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 +1755,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}));
}
});

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] {
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
}
}
@ -650,4 +684,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,17 @@ 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)));
}

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

@ -183,5 +183,5 @@ 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");
}

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

@ -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);
@ -213,6 +272,42 @@ fn language_switch_translates_the_ribbon_and_persists() {
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 dialogs_open_and_close() {
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

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

View File

@ -78,6 +78,8 @@ fn expr_to_file(e: Expr) -> String {
},
// Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`.
Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]),
// Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`.
Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]),
other => other,
});
gridcraft_formula::print(&e)
@ -115,6 +117,15 @@ mod tests {
assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#");
}
#[test]
fn implicit_intersection() {
assert_eq!(to_file(&Formula::new("SUM(@A1:A3*B1:B3)")), "SUM(_xlfn.SINGLE(A1:A3)*B1:B3)");
assert_eq!(to_file(&Formula::new("@A1:A3*2")), "_xlfn.SINGLE(A1:A3)*2");
assert_eq!(text_to_file("=@Sheet1!$A$1:$A$3"), "_xlfn.SINGLE(Sheet1!$A$1:$A$3)");
assert_eq!(from_file("_xlfn.SINGLE(A1:A3)*2"), "@A1:A3*2");
assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)");
}
#[test]
fn prefixes() {
assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");

View File

@ -10,6 +10,7 @@ use gridcraft_core::date::{days_in_month, serial_from_ymd};
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
use quick_xml::NsReader;
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::ResolveResult;
@ -67,13 +68,13 @@ fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool {
matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected)
}
fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result<Option<String>, IoError> {
fn attr(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result<Option<String>, IoError> {
let mut found = None;
for a in e.attributes() {
let a = a.map_err(|e| IoError::Xml(e.to_string()))?;
let (resolved, local) = reader.resolve_attribute(a.key);
let (resolved, local) = reader.resolver_mut().resolve_attribute(a.key);
if is_ns(resolved, ns) && local.as_ref() == name {
let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?;
let value = a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?;
if value.len() > MAX_CELL_TEXT {
return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into()));
}
@ -106,7 +107,7 @@ fn check_manifest(bytes: &[u8]) -> Result<(), IoError> {
let event = reader.read_event().map_err(xml_error)?;
match &event {
Event::Start(e) | Event::Empty(e) => {
let (ns, name) = reader.resolve_element(e.name());
let (ns, name) = reader.resolver_mut().resolve_element(e.name());
if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" {
return Err(IoError::Format("encrypted ODS files are not supported".into()));
}
@ -153,8 +154,8 @@ enum Tag {
Other,
}
fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag {
let (ns, name) = reader.resolve_element(e.name());
fn tag(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag {
let (ns, name) = reader.resolver_mut().resolve_element(e.name());
let local = name.as_ref();
if is_ns(ns.clone(), OFFICE) {
return match local {
@ -253,23 +254,23 @@ impl Import {
return Err(xml_error("XML nested too deeply"));
}
if self.stack.is_empty() {
if root_seen || tag(&reader, &e) != Tag::Document {
if root_seen || tag(&mut reader, &e) != Tag::Document {
return Err(xml_error("expected one office:document-content root"));
}
root_seen = true;
}
self.start(&reader, &e)?;
self.start(&mut reader, &e)?;
}
Event::Empty(e) => {
if self.stack.is_empty() {
return Err(xml_error("expected office:document-content with a spreadsheet"));
}
self.start(&reader, &e)?;
self.start(&mut reader, &e)?;
self.end()?;
}
Event::End(_) => self.end()?,
Event::Text(t) => {
let text = t.xml_content().map_err(xml_error)?;
let text = t.xml_content(XmlVersion::Explicit1_0).map_err(xml_error)?;
if self.stack.is_empty() && !text.trim().is_empty() {
return Err(xml_error("text outside the document"));
}
@ -301,7 +302,7 @@ impl Import {
Ok(())
}
fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> {
fn start(&mut self, reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> {
let t = tag(reader, e);
let depth = self.stack.len();
match t {

View File

@ -6,7 +6,7 @@ use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{
AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, Orientation, Sheet, SparklineKind,
Style, Table, TotalsFn, Validation, ValidationKind, Workbook,
Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
@ -778,7 +778,15 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String {
b.push_str(" showValue=\"0\"");
}
b.push('>');
for t in thresholds {
// CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound.
let k = icon_count(set);
let defaults = default_icon_thresholds(set);
let cfvos: Vec<&CfValueKind> = match thresholds.len() {
n if n == k => thresholds.iter().collect(),
n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(),
_ => defaults.iter().collect(),
};
for t in cfvos {
b.push_str(&cfvo_xml(t));
}
b.push_str("</iconSet>");

View File

@ -194,6 +194,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"PDURATION",
"PERCENTILE.EXC",
"PERCENTILE.INC",
"PERCENTOF",
"PERCENTRANK.EXC",
"PERCENTRANK.INC",
"PERMUTATIONA",
@ -231,6 +232,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
"TEXTSPLIT",
"TOCOL",
"TOROW",
"TRIMRANGE",
"UNICHAR",
"UNICODE",
"UNIQUE",
@ -322,6 +324,8 @@ mod tests {
fn functions() {
assert_eq!(file_function_name("xlookup").as_deref(), Some("_xlfn.XLOOKUP"));
assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER"));
assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF"));
assert_eq!(file_function_name("TRIMRANGE").as_deref(), Some("_xlfn.TRIMRANGE"));
assert_eq!(file_function_name("SUM"), None);
assert_eq!(builtin_format(14), Some("m/d/yyyy"));
assert_eq!(builtin_id("0.00%"), Some(10));

View File

@ -236,8 +236,10 @@ fn implicit_intersection_metadata_respects_function_argument_context() {
("SUM(A1:A3)", false),
("SUMPRODUCT(A1:A3*B1:B3)", false),
("SUMPRODUCT(SQRT(A1:A3))", false),
("SQRT(@A1:A3)", false),
("@A1:A3*2", false),
// An explicit `@` is written as `_xlfn.SINGLE`, a dynamic-array formula. Dropping it
// instead would need the reader to put it back on legacy formulas.
("SQRT(@A1:A3)", true),
("@A1:A3*2", true),
("@(A1:A3*2)", true),
("SQRT(INDIRECT(\"A1\"))", false),
("SUM(INDIRECT(\"A1:A3\"))", false),

View File

@ -23,6 +23,22 @@ fn text(s: &str) -> Value {
Value::Text(s.into())
}
/// A start tag with thousands of `xmlns:` declarations is refused instead of allocated
/// (RUSTSEC-2026-0195: the resolver kept a binding per declaration, with no cap).
#[test]
fn ods_namespace_declaration_bomb_is_rejected() {
let bomb = |n: usize| -> String { (0..n).map(|i| format!(" xmlns:p{i}=\"urn:p{i}\"")).collect() };
let doc = |decls: &str| {
format!(
r#"<?xml version="1.0"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"{decls}><office:body><office:spreadsheet><table:table table:name="Data"><table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#
)
};
let build = |decls: &str| make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", doc(decls).as_bytes())]);
let ok = read_ods(&build(&bomb(8)));
assert!(ok.is_ok(), "a handful of namespaces still reads: {:?}", ok.err());
assert!(read_ods(&build(&bomb(10_000))).is_err(), "a tag with thousands of declarations is refused");
}
#[test]
fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() {
let bytes = row(r#"

View File

@ -600,3 +600,74 @@ fn spill_and_calc_errors_are_cached_as_value_errors() {
assert_eq!(cell.child("v").unwrap().text, value);
}
}
/// `CT_IconSet` needs one `cfvo` per icon (at least two); a rule without stored thresholds
/// gets Excel's defaults, one without the lower bound gets it prepended (#235).
#[test]
fn icon_sets_always_write_one_cfvo_per_icon() {
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
for (i, (set, thresholds)) in [
("3Arrows", vec![]),
("4Rating", vec![CfValueKind::Number(2.0), CfValueKind::Number(5.0), CfValueKind::Number(8.0)]),
("9Bogus", vec![CfValueKind::Percent(50.0)]),
]
.into_iter()
.enumerate()
{
s.cond_formats.push(CondFormat {
ranges: vec![rr(&format!("A{}", i + 1))],
rule: CfRule::IconSet { set: set.into(), thresholds, reverse: false, show_value: true },
priority: i as u32 + 1,
stop_if_true: false,
});
}
let bytes = write_xlsx(&wb).unwrap();
let (xml, _) = sheet1_xml(&bytes);
assert!(
xml.contains(
"<iconSet iconSet=\"3Arrows\"><cfvo type=\"percent\" val=\"0\"/><cfvo type=\"percent\" val=\"33\"/><cfvo type=\"percent\" val=\"67\"/></iconSet>"
),
"{xml}"
);
assert!(
xml.contains("<iconSet iconSet=\"4Rating\"><cfvo type=\"percent\" val=\"0\"/><cfvo type=\"num\" val=\"2\"/><cfvo type=\"num\" val=\"5\"/><cfvo type=\"num\" val=\"8\"/></iconSet>"),
"{xml}"
);
assert!(xml.contains("<iconSet iconSet=\"9Bogus\"><cfvo type=\"percent\" val=\"0\"/>"), "{xml}");
let (back, _) = read_xlsx(&bytes).unwrap();
let rules: Vec<usize> = back
.sheet(0)
.unwrap()
.cond_formats
.iter()
.map(|cf| match &cf.rule {
CfRule::IconSet { thresholds, .. } => thresholds.len(),
_ => 0,
})
.collect();
assert_eq!(rules, [3, 4, 5]);
}
/// Files have no `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)` in a dynamic-array
/// formula, and reads it back as `@x`.
#[test]
fn implicit_intersection_is_written_as_single() {
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
for (r, n) in [(0, 1.0), (1, 3.0), (2, 5.0)] {
s.set_value(CellRef::new(r, 0), Value::Number(n));
}
s.cells.set(at("D3"), Cell { value: Value::Number(10.0), ..Cell::formula(Formula::new("@A1:A3*2")) });
s.cells.set(at("D4"), Cell { value: Value::Number(2.0), ..Cell::formula(Formula::new("A1*2")) });
let bytes = write_xlsx(&wb).unwrap();
let (xml, metadata) = sheet1_xml(&bytes);
assert!(xml.contains("<c r=\"D3\" cm=\"1\"><f t=\"array\" ref=\"D3\">_xlfn.SINGLE(A1:A3)*2</f><v>10</v>"), "{xml}");
assert!(xml.contains("<c r=\"D4\"><f>A1*2</f>"), "{xml}");
assert!(!xml.contains('@'), "{xml}");
assert!(metadata);
let (back, _) = read_xlsx(&bytes).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).map(|f| f.text.as_str()), Some("@A1:A3*2"));
assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).and_then(|f| f.array), None);
}

View File

@ -7,6 +7,7 @@ use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Sheet, Visibility, Workbook};
use quick_xml::NsReader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
@ -160,7 +161,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE
let event = reader.read_event().map_err(|e| IoError::Xml(e.to_string()))?;
match &event {
Event::Start(e) | Event::Empty(e) => {
let (namespace, local) = reader.resolve_element(e.name());
let (namespace, local) = reader.resolver_mut().resolve_element(e.name());
let valid_ns = matches!(namespace, ResolveResult::Bound(n) if n.as_ref() == ns);
if depth == 0 {
if seen || !valid_ns || local.as_ref() != root_name {
@ -174,7 +175,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE
if !a.key.as_ref().contains(&b':') {
attrs.push((
String::from_utf8_lossy(a.key.as_ref()).into_owned(),
a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?.into_owned(),
a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.into_owned(),
));
}
}
@ -188,7 +189,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE
}
}
Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?,
Event::Text(t) if depth == 0 && !t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => {
Event::Text(t) if depth == 0 && !t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => {
return Err(invalid("text outside package metadata"));
}
Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")),

View File

@ -2,6 +2,7 @@
//! shared strings) so large parts never need a full tree, plus escaping helpers for writing.
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use crate::IoError;
@ -124,7 +125,7 @@ fn start_el(e: &BytesStart<'_>) -> El {
if key == b"xmlns" || key.starts_with(b"xmlns:") {
continue;
}
let v = match a.unescape_value() {
let v = match a.normalized_value(XmlVersion::Explicit1_0) {
Ok(v) => v.into_owned(),
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
};
@ -203,7 +204,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
}
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let s = t.xml_content().map_err(|e| IoError::Xml(e.to_string()))?;
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?;
top.text.push_str(&s);
}
}

View File

@ -35,7 +35,7 @@ open PRs #158, #161, and issue #190.
| Element | Excel | GridCraft |
|---|---|---|
| Move and resize the chart object | yes | move yes; **resize broken** (#190) |
| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) |
| Chart title, axis titles | text, linked to cells, full formatting | text only |
| Legend | position, overlay, formatting, per-entry delete | position only |
| Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag |

View File

@ -33,7 +33,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`.
| `ui.ribbon` | `{tab}` | Switch ribbon tab |
| `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs |
| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) |
| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences |
| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences (`formulaBar` runs `view.formulaBar`) |
| `ui.resize` / `ui.focus` | `{width, height}` | Window control |
| `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime |

View File

@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured against Microsoft's published Excel function list) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@ -12,7 +12,10 @@ Excel's worksheet functions, category by category, against GridCraft's function
them), plus 14 text functions the page lists under combined entries (FIND/FINDB, LEFT/LEFTB,
LEN/LENB, MID/MIDB, REPLACE/REPLACEB, RIGHT/RIGHTB, SEARCH/SEARCHB), BETA.INV, and COPILOT and
PY (Microsoft 365 functions not on that page when fetched).
- **Ours (501):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
- **Ours (505):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
- **Ours (503):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
- **Ours (502):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
- **Ours (502):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
@ -23,15 +26,15 @@ Excel's worksheet functions, category by category, against GridCraft's function
## Coverage
**Measured: 501 / 523 by name (95.8%); 492 / 523 working (94.1%).**
**Measured: 509 / 523 by name (97.3%); 500 / 523 working (95.6%).**
| Category | Excel | Ours | Working | % working | Missing |
|---|---|---|---|---|---|
| Statistical | 111 | 107 | 107 | 96% | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT |
| Math and trigonometry | 80 | 79 | 79 | 99% | PERCENTOF |
| Financial | 55 | 51 | 51 | 93% | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |
| Financial | 55 | 55 | 55 | 100% | — |
| Engineering | 54 | 54 | 54 | 100% | — |
| Text | 50 | 47 | 47 | 94% | BAHTTEXT, DETECTLANGUAGE, TRANSLATE |
| Text | 50 | 48 | 48 | 96% | DETECTLANGUAGE, TRANSLATE |
| Lookup and reference | 40 | 36 | 36 | 90% | GETPIVOTDATA, IMAGE, RTD, TRIMRANGE |
| Compatibility | 40 | 40 | 40 | 100% | — |
| Date and time | 25 | 25 | 25 | 100% | — |
@ -40,11 +43,11 @@ Excel's worksheet functions, category by category, against GridCraft's function
| Database | 12 | 12 | 12 | 100% | — |
| Cube | 7 | 7 | 0 | 0% | (all 7 are `#N/A` stubs) |
| Web | 3 | 3 | 1 | 33% | (WEBSERVICE, FILTERXML are stubs; ENCODEURL works) |
| Add-in and Automation | 3 | 0 | 0 | 0% | CALL, EUROCONVERT, REGISTER.ID |
| Add-in and Automation | 3 | 1 | 1 | 33% | CALL, REGISTER.ID |
| AI and Python | 2 | 0 | 0 | 0% | COPILOT, PY |
| **Total** | **523** | **501** | **492** | **94.1%** | 22 missing, 9 stubs |
| **Total** | **523** | **505** | **496** | **94.8%** | 18 missing, 9 stubs |
By the Excel version that introduced them (working / total): before 2010 342 / 360, 2010
By the Excel version that introduced them (working / total): before 2010 346 / 360, 2010
56 / 56, 2013 49 / 51, 2016 1 / 5, 2019 6 / 6, 2021 11 / 11, 2024 22 / 23, Microsoft 365 5 / 11.
Dynamic-array and LAMBDA-era functions (FILTER, SORT, UNIQUE, XLOOKUP, VSTACK, TAKE, GROUPBY,
PIVOTBY, REGEXTEST…) are all present.
@ -54,11 +57,12 @@ PIVOTBY, REGEXTEST…) are all present.
| Function(s) | In scope? | Estimate | Note |
|---|---|---|---|
| FORECAST.ETS family (4) | yes | 4–6 h | AAA exponential smoothing with seasonality detection; needed for Forecast Sheet too |
| ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | yes | 2–4 h | Odd first/last period bond maths |
| GETPIVOTDATA | yes | 2–3 h | Needs the pivot engine's result cache; also the "Generate GetPivotData" option |
| TRIMRANGE, PERCENTOF, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators |
| TRIMRANGE, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators |
| TRIMRANGE, PERCENTOF | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators |
| PERCENTOF, BAHTTEXT | yes | 1–2 h | |
| Trim references (`A1.:.E10`, `A1:.E10`, `A1.:E10`) | yes | 1–2 h | TRIMRANGE works; the operators need lexer support (`.` is a name character) |
| IMAGE | yes | 3–5 h | Pictures in cells (see PR #105); fetching URLs needs a privacy decision |
| EUROCONVERT | yes | 0.5 h | Fixed conversion table |
| CUBE* (7), WEBSERVICE, FILTERXML | partly | 4–8 h | FILTERXML can work offline; CUBE needs an OLAP/data-model source |
| TRANSLATE, DETECTLANGUAGE, STOCKHISTORY, COPILOT, PY | no (cloud) | — | Microsoft cloud services; open equivalents only by owner decision |
| CALL, REGISTER.ID, RTD | no | — | Native DLL/COM calls; not portable or safe |
@ -89,7 +93,7 @@ reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past
10-09). Statistical distributions and financial functions with iterative solvers (RATE, IRR,
XIRR, YIELD) are the highest-risk for last-digit differences.
Work to close (18–30 h in total): the in-scope missing functions (12–20 h with the table above),
Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above),
an oracle corpus of Excel-computed results per function (owner runs Excel on generated
workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h).
@ -97,4 +101,8 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working |
| 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working |
| 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working |
| 2026-10-11 | minor | TRIMRANGE added (the trim-reference operators not yet): 502 / 523 by name, 493 working |
| 2026-10-10 | major | Created: 501 / 523 by name, 492 working, per category, against Microsoft's published list |

View File

@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§15: the ODD* bond functions landed) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@ -106,7 +106,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
#143). **Estimate:** 12–20 h.
### 15. Remaining functions and an oracle suite (F)
- 22 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h.
- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h.
- 20 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 17–29 h.
- 21 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h.
- 21 missing, 9 stubs; trim-reference operators (`A1:.E10`) not parsed; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h.
[function-parity.md](function-parity.md)
### 16. Collaboration and review (F)
@ -149,6 +152,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) |
| 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) |
| 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) |
| 2026-10-11 | minor | §15: TRIMRANGE added (21 functions missing; trim-reference operators still missing) |
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |

View File

@ -12,7 +12,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of
| Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h |
| Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h |
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h |
| High-DPI and display scaling | yes | yes (display scale matched to desktop density) | — |
| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — |
| Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) |
| Printers | System print with driver options | Desktop prints through the system dialog via PDF; web exports only | Counted under print |

View File

@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (re-measured after the 10-10 language and font-fallback merges) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ribbon catalog added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
packs cover 100+), with localized function names, argument separators, number and date formats,
@ -10,9 +10,10 @@ right-to-left sheets and full complex-script support. Part of
## Where we are (measured from source on `main`, after the 2026-10-10 merges)
- **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in
#31, #104, #82) adds an interface-language preference that defaults to the system language
(closes #20) and a View-tab switcher, with English-keyed lookup tables: Japanese 268 labels,
Simplified Chinese, Korean, Russian and Brazilian Portuguese 266 each. The tables are used by
#31, #104, #82; German added 10-11) adds an interface-language preference that defaults to the
system language (closes #20) and a View-tab switcher, with English-keyed lookup tables:
Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese and German 266
each. The tables are used by
the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus,
context menus, task panes, status messages, command labels and function descriptions are still
English literals. Untranslated labels fall back to English.
@ -47,11 +48,11 @@ separators, number/date formats, machine translation with agent review, and scri
| Portuguese | pt-BR | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h |
| Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |
| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display | no | menus only | 8–12 h |
| German | de | 0 (0%; PR #163 open) | no | Latin: yes | no | none | 6–10 h |
| German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h |
| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h |
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h |
Other shipped languages: Russian (ru), 266 labels (~12%), menus only.
Other shipped languages: Russian (ru) and German (de), 266 labels each (~12%), menus only.
**Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully
once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every
@ -73,5 +74,6 @@ language (none so far) and of localized function names.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |
| 2026-10-10 | minor | Re-measured after #31, #104, #82, #38 merged: ribbon translated into ja, zh, ko, pt-BR and ru (266–268 labels, ~12%), system-language default, CJK/Thai/Hebrew font fallback; ~10% ready, 100–165 h |
| 2026-10-10 | major | Created: English only, no catalog, ~2,200 strings, script-support gaps, open language PRs |

View File

@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 258 / 290 commands implemented (89.0%).**
**Overall: 260 / 290 commands implemented (89.7%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@ -16,7 +16,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Formulas | 23 | 23 | 100% |
| Data | 20 | 28 | 71% |
| Review | 9 | 18 | 50% |
| View | 18 | 20 | 90% |
| View | 20 | 20 | 100% |
| Automate | 3 | 3 | 100% |
| Table Design | 12 | 13 | 92% |
| Chart Design | 8 | 8 | 100% |
@ -302,7 +302,7 @@ Missing:
- `review.allowEditRanges` Allow Edit Ranges (Protect)
- `review.showChanges` Show Changes (Changes)
## View (18 / 20, 90%)
## View (20 / 20, 100%)
Implemented:
@ -311,7 +311,9 @@ Implemented:
- `view.pageLayout` Page Layout (Workbook Views)
- `view.customViews` Custom Views (Workbook Views)
- `view.ruler` Ruler (Show)
- `view.formulaBar` Formula Bar (Show)
- `view.zoom` Zoom (Zoom)
- `view.zoom100` 100% (Zoom)
- `view.zoomToSelection` Zoom to Selection (Zoom)
- `view.newWindow` New Window (Window)
- `view.arrangeAll` Arrange All (Window)
@ -325,11 +327,6 @@ Implemented:
- `view.switchWindows` Switch Windows (Window)
- `view.macros` Macros (Macros)
Missing:
- `view.formulaBar` Formula Bar (Show)
- `view.zoom100` 100% (Zoom)
## Automate (3 / 3, 100%)
Implemented:

View File

@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 509 / 523, catalog 260 / 290) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@ -13,9 +13,9 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements |
| Ribbon and menu catalog | **258 / 290 (89.0%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Worksheet functions, by name | **501 / 523 (95.8%)** | measured: registered functions vs Microsoft's published function list (method below) |
| Worksheet functions that do real work | **492 / 523 (94.1%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
| Ribbon and menu catalog | **260 / 290 (89.7%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Worksheet functions, by name | **509 / 523 (97.3%)** | measured: registered functions vs Microsoft's published function list (method below) |
| Worksheet functions that do real work | **500 / 523 (95.6%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
| **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) |
| **Mainstream practitioner** | **~50%** | estimated; [method](#mainstream-practitioner) (69.5 depth × 0.90 × 0.92 × 0.88 = 50.6) |
| **Essentials user** | **~63%** | estimated; [method](#essentials-user) (80.2 depth × 0.92 × 0.95 × 0.90 = 63.1) |
@ -53,7 +53,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
LEFT/LEFTB, LEN/LENB, MID/MIDB, REPLACE/REPLACEB, RIGHT/RIGHTB, SEARCH/SEARCHB, BETA.INV) and
COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus
`SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only
`ADD2`: **501**, all of them Excel names.
`ADD2`: **505**, all of them Excel names.
- **Ribbon catalog:** `crates/engine/src/catalog.rs` (290 unique ids across 15 tabs) against the
313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and
identical to the committed `parity-checklist.md`. The catalog is our own transcription of
@ -80,7 +80,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Area | Weight | Breadth | Ready | Hours | Evidence |
|---|---|---|---|---|---|
| Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded |
| Worksheet functions | 12 | 95.8% (measured) | ~82% | 18–30 | 501 / 523 names, 492 working. Missing: FORECAST.ETS family, ODDF*/ODDL*, GETPIVOTDATA, TRIMRANGE, PERCENTOF, BAHTTEXT, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) |
| Worksheet functions | 12 | 97.3% (measured) | ~82% | 16–26 | 509 / 523 names, 500 working. Missing: FORECAST.ETS family, GETPIVOTDATA, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) |
| Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) |
| Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes |
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
@ -277,7 +277,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
- Engine: A1/R1C1 formula parser with reference adjustment, dependency-graph recalculation with
dynamic arrays (spill + `#SPILL!`), cycle detection, LET/LAMBDA/MAP/REDUCE/SCAN/BYROW/BYCOL/
MAKEARRAY, structured table references, defined names, INDIRECT/OFFSET/INDEX references,
501 worksheet functions (478 at M0) (math, statistics and distributions, text and regex, lookup incl.
505 worksheet functions (478 at M0) (math, statistics and distributions, text and regex, lookup incl.
XLOOKUP/FILTER/SORT/UNIQUE/VSTACK, dates, financial incl. bonds, engineering incl. complex
numbers and Bessel, database, information).
- Number formats: Excel's full format-code language (sections, conditions, colours, dates and
@ -332,6 +332,11 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |
| 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT |
| 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT |
| 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): TRIMRANGE |
| 2026-10-11 | minor | View › Formula Bar and Zoom 100% are engine commands (CLI/MCP); catalog 258 → 260 / 290 (#289) |
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |

View File

@ -1,10 +1,10 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected objects can be deleted from the keyboard) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
separately in [parity-checklist.md](parity-checklist.md) (258 / 290).
separately in [parity-checklist.md](parity-checklist.md) (260 / 290).
Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes),
`crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes
@ -32,11 +32,11 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Behaviour | Status |
|---|---|
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done; right-drag menu missing |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing |
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
| Point mode (click/drag/arrow references), reference colouring, F4 | done |
| AutoComplete in columns, function autocomplete and argument tips | done |
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
| Freeze panes, split panes | freeze done; split to verify |
| Copy as HTML table for other apps | done (#36, 10-10) |
@ -56,6 +56,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T, Ctrl+K, Ctrl+Shift+L | yes | most done; audit pending |
| F2, F4, F5, F9 family, Shift+F3, F11 | yes | most; F11 chart sheet missing (no chart sheets) |
| Mac-specific (Cmd variants, Fn keys) | yes | Cmd mapping done |
| Delete selected charts, pictures and shapes | yes | done (#213) |
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
Work: a shortcut table generated from the command catalog checked against Microsoft's published
@ -91,5 +92,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) |
| 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) |
| 2026-10-10 | minor | Chart resize bug (#190) |
| 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |