Merge branch 'main' of github.com:storytold/gridcraft into fix/copy-multiple-ranges
# Conflicts: # crates/engine/src/tests.rs # crates/ui-egui/tests/clipboard.rs
This commit is contained in:
commit
823dc5a060
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
37
ROADMAP.md
37
ROADMAP.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("e(&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("e(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 & 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",
|
||||
"�&",
|
||||
"<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\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -760,13 +760,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),
|
||||
|
|
@ -786,6 +791,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();
|
||||
|
|
@ -1228,6 +1254,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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
pub mod catalog;
|
||||
pub mod cf;
|
||||
mod clip_html;
|
||||
pub mod cmd;
|
||||
pub mod display;
|
||||
pub mod fill;
|
||||
|
|
@ -153,6 +154,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 {
|
||||
|
|
@ -166,6 +169,7 @@ impl Default for Prefs {
|
|||
sheets_in_new_workbook: 1,
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -1694,3 +1814,32 @@ fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
|
|||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
|
||||
assert!(v(&s, "E1").is_empty());
|
||||
}
|
||||
|
||||
/// 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)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ pub enum Category {
|
|||
Compatibility,
|
||||
Web,
|
||||
Cube,
|
||||
/// Excel's "Add-in and Automation" category (EUROCONVERT).
|
||||
AddIn,
|
||||
}
|
||||
|
||||
pub struct FnSpec {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
// A multi-area copy marks each area as selected.
|
||||
let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() };
|
||||
|
|
@ -1051,6 +1107,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() {
|
||||
|
|
@ -1069,6 +1128,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()
|
||||
|
|
@ -1076,16 +1141,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);
|
||||
|
|
@ -1139,7 +1209,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);
|
||||
|
|
@ -1155,7 +1227,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 };
|
||||
}
|
||||
|
|
@ -1262,11 +1334,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;
|
||||
}
|
||||
|
|
@ -1425,7 +1497,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)));
|
||||
}
|
||||
|
|
@ -1446,7 +1519,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();
|
||||
|
|
@ -1612,6 +1685,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 {
|
||||
|
|
@ -1660,7 +1749,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);
|
||||
|
|
@ -1712,7 +1801,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!({}));
|
||||
|
|
@ -1760,10 +1851,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);
|
||||
}
|
||||
|
||||
|
|
@ -1807,6 +1906,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| {
|
||||
|
|
@ -1823,19 +1923,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() {
|
||||
|
|
@ -1857,7 +1970,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1958,7 +2074,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);
|
||||
|
|
@ -1990,6 +2106,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);
|
||||
|
|
@ -2011,7 +2128,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());
|
||||
|
|
@ -2051,7 +2168,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));
|
||||
|
|
@ -2227,3 +2346,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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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기록",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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действий",
|
||||
|
|
|
|||
|
|
@ -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日志",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -266,6 +293,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() {
|
||||
|
|
@ -302,10 +340,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)
|
||||
|
|
@ -327,7 +361,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("");
|
||||
|
|
@ -575,6 +623,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
|
||||
|
|
@ -616,7 +674,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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -102,3 +102,17 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
|
|||
assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections."));
|
||||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
11
docs/gaps.md
11
docs/gaps.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue