Merge remote-tracking branch 'origin/main' into fix-268
This commit is contained in:
commit
de8d70cc39
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -447,6 +447,15 @@ version = "2.13.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
|
|
@ -697,6 +706,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
|
@ -746,6 +764,15 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
|
|
@ -776,6 +803,16 @@ dependencies = [
|
|||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1427,6 +1464,7 @@ dependencies = [
|
|||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
|
|
@ -1529,6 +1567,7 @@ dependencies = [
|
|||
"gridcraft-formula",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
|
@ -1555,6 +1594,7 @@ dependencies = [
|
|||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
|
|
@ -1584,7 +1624,7 @@ dependencies = [
|
|||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
|
@ -1663,6 +1703,15 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
|
|
@ -2480,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2739,15 +2788,6 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
|
|
@ -3071,6 +3111,17 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
|
|
@ -3452,6 +3503,12 @@ dependencies = [
|
|||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
|
|
@ -3725,7 +3782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
|||
log = "0.4"
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
|
|
@ -45,6 +46,9 @@ rfd = "0.17"
|
|||
arboard = { version = "3.6", default-features = false }
|
||||
# The desktop's preferred language, for the first-run interface language (native only).
|
||||
sys-locale = "0.3"
|
||||
# Font parser egui's epaint uses; the UI validates system font files with it before handing them
|
||||
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -197,7 +197,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
|||
|
||||
GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file
|
||||
compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 501 of Excel's 523 worksheet functions
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions
|
||||
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we
|
||||
estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large
|
||||
workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage,
|
||||
|
|
@ -230,8 +230,16 @@ $env:WGPU_BACKEND = "vulkan"
|
|||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
An explicit `gl` override can bring the driver crash back on affected systems, and `vulkan` can
|
||||
bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The macOS, Linux and
|
||||
web backend defaults are unchanged.
|
||||
|
||||
**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no
|
||||
graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every
|
||||
frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if
|
||||
the first renderer fails to start, it tries the other one once. The log file records the choice
|
||||
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
|
||||
tried), e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
|
||||
|
||||
### macOS
|
||||
|
||||
|
|
|
|||
49
ROADMAP.md
49
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 513 / 523, catalog 266 / 290) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
|
|
@ -21,7 +21,7 @@ This page is the summary. The detail is in:
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 501 / 523 (95.8%; 492 working, 94.1%) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 266 / 290 (91.7%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
|
||||
|
|
@ -62,7 +62,7 @@ collaboration, mobile, AI).
|
|||
| File formats | ~50% | 80–120 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Performance | ~35% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#performance) |
|
||||
| Stability | ~60% | 15–25 | [gaps.md](docs/gaps.md) |
|
||||
| Localization | ~10% (ribbon in 5 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | ~10% (ribbon in 6 languages) | 100–165 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Hardware (multi-core calc, GPU, pen, multi-window) | ~40% | 25–40 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Platforms | ~80% (no iOS/Android; ahead on Linux, BSD, RISC-V) | 30–60 | [target-app-parity.md](docs/target-app-parity.md#platforms) |
|
||||
| Ecosystem / add-ins | ~10% (VBA out of scope; own scripts, MCP, CLI) | 25–40 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
|
||||
|
|
@ -74,7 +74,7 @@ collaboration, mobile, AI).
|
|||
| Area | Breadth | Ready | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| Formula language and calculation | ~92% | ~72% | 20–30 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Worksheet functions | 95.8% measured | ~82% | 18–30 | [function-parity.md](docs/function-parity.md) |
|
||||
| Worksheet functions | 96.6% measured | ~82% | 16–26 | [function-parity.md](docs/function-parity.md) |
|
||||
| Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
|
|
@ -89,11 +89,12 @@ collaboration, mobile, AI).
|
|||
|
||||
## Languages
|
||||
|
||||
The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese and
|
||||
Russian (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82), the
|
||||
The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Portuguese, Russian
|
||||
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
|
||||
added 10-11), the
|
||||
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
|
||||
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. German
|
||||
is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
|
||||
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Status | UI strings |
|
||||
|---|---|---|---|
|
||||
|
|
@ -106,11 +107,11 @@ is in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
|
|||
| Portuguese | pt-BR | menus only | ~12% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | menus only | ~12% |
|
||||
| German | de | none (PR #163 open) | 0% |
|
||||
| German | de | menus only | ~12% |
|
||||
| Korean | ko | menus only | ~12% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Other shipped languages: 1 (Russian, menus only, ~12%).
|
||||
Other shipped languages: 2 (Russian and German, menus only, ~12%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
|
|
@ -131,6 +132,24 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
|
||||
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
|
||||
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |
|
||||
| 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) |
|
||||
| 2026-10-11 | BAHTTEXT: amounts in Thai words with Baht and Satang (#291) |
|
||||
| 2026-10-11 | TRIMRANGE: ranges and arrays without blank outer rows and columns (#296) |
|
||||
| 2026-10-11 | Selected charts, pictures and shapes can be deleted from the keyboard (#213) |
|
||||
| 2026-10-11 | Ribbon translated into German (266 labels; `de`, `de-DE`, `de-AT`, `de-CH` locale tags resolve to German) |
|
||||
| 2026-10-11 | View › Interface size: a saved 75–200 % scale for the whole window (#230) |
|
||||
| 2026-10-11 | View › Formula Bar and Zoom 100% reachable from the CLI and MCP (#289) |
|
||||
| 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) |
|
||||
| 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) |
|
||||
| 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) |
|
||||
| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) |
|
||||
| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) |
|
||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
||||
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
|
@ -142,6 +161,16 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 502 / 523 (TRIMRANGE); progress log row |
|
||||
| 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) |
|
||||
| 2026-10-11 | minor | Catalog 258 → 260 / 290: View › Formula Bar and Zoom 100% (#289) |
|
||||
| 2026-10-11 | minor | Progress log: XLSX threaded comments round trip (#300) |
|
||||
| 2026-10-11 | minor | Catalog 258 → 259 / 290: Allow Edit Ranges (#307) |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row |
|
||||
| 2026-10-11 | minor | Czech (cs) listed under other shipped languages: ribbon only, ~12% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ arboard = { workspace = true }
|
|||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
|
||||
pollster = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# Finder double-click, Open With, Dock drops and `open -a GridCraft file.xlsx` arrive as a
|
||||
|
|
|
|||
|
|
@ -1,47 +1,192 @@
|
|||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
//!
|
||||
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
|
||||
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
|
||||
//! violation happens in the driver, where no Rust error handling can catch it.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType},
|
||||
};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
/// What the start-up probe learned about one DirectX 12 adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dx12Adapter {
|
||||
/// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the
|
||||
/// processor, which makes the app lag on older machines.
|
||||
pub device_type: DeviceType,
|
||||
/// Whether the adapter offers the limits eframe will ask for when it creates the device.
|
||||
pub meets_limits: bool,
|
||||
}
|
||||
|
||||
/// The backends to initialize on Windows.
|
||||
///
|
||||
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
|
||||
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
|
||||
/// driver has no DirectX 12 support still have an OpenGL driver, and GL can't reach the AMD driver
|
||||
/// crash described in [`configure_backends`], because an AMD GPU with a driver shows up as a
|
||||
/// DirectX 12 adapter and keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays
|
||||
/// enabled next to GL as a last resort for machines without an OpenGL driver either (virtual
|
||||
/// machines): wgpu prefers any GPU over a software adapter, so it's only picked when GL has
|
||||
/// nothing better. Vulkan is never part of the default.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
|
||||
if let Some(backends) = backend_override {
|
||||
return backends;
|
||||
}
|
||||
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
|
||||
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
|
||||
Backends::DX12
|
||||
} else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) {
|
||||
Backends::DX12 | Backends::GL
|
||||
} else {
|
||||
// No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a
|
||||
// DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one.
|
||||
Backends::GL
|
||||
}
|
||||
}
|
||||
|
||||
/// The backends for a second start when the first one failed with a graphics error before the
|
||||
/// app was created (no adapter, no device, no surface): the other one of DirectX 12 and OpenGL.
|
||||
/// `None` when `WGPU_BACKEND` chose the backends (the user asked for exactly those) or the first
|
||||
/// start already tried both. Never Vulkan (#120).
|
||||
pub fn fallback_backends(backend_override: Option<Backends>, first: Backends) -> Option<Backends> {
|
||||
if backend_override.is_some() {
|
||||
return None;
|
||||
}
|
||||
let rest = (Backends::DX12 | Backends::GL).difference(first);
|
||||
(!rest.is_empty()).then_some(rest)
|
||||
}
|
||||
|
||||
/// Restricts the wgpu instance eframe creates to `backends`.
|
||||
pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes Vulkan and GL. Creating the GL backend can crash inside AMD's
|
||||
// atio6axx.dll, and enumerating Vulkan adapters inside Intel's igvk64.dll (#120), before
|
||||
// adapter selection or any Rust error handling runs, so the window never appears.
|
||||
// Choosing an adapter afterwards is too late: only leaving the backend out of the
|
||||
// instance avoids it. This matches the verified WGPU_BACKEND=dx12 workaround without
|
||||
// mutating process environment variables.
|
||||
create.instance_descriptor.backends = backends;
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the backends ([`choose_backends`]), applies them to `options` and returns them.
|
||||
/// Without a `backend_override` this first probes DirectX 12 with a short-lived instance of its
|
||||
/// own, which is dropped before eframe creates the real one.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) -> Backends {
|
||||
let backends = match backend_override {
|
||||
Some(backends) => {
|
||||
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
|
||||
backends
|
||||
}
|
||||
None => {
|
||||
let adapters = probe_dx12(options);
|
||||
let backends = choose_backends(None, &adapters);
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
backends
|
||||
}
|
||||
};
|
||||
configure_backends(options, backends);
|
||||
backends
|
||||
}
|
||||
|
||||
/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor
|
||||
/// will require. Empty when this build has no DirectX 12 backend.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
use eframe::wgpu::{Instance, InstanceDescriptor};
|
||||
// `Instance::new` panics when the build has no backend for the platform.
|
||||
if !Instance::enabled_backend_features().contains(Backends::DX12) {
|
||||
return Vec::new();
|
||||
}
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
return Vec::new();
|
||||
};
|
||||
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
|
||||
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
|
||||
.iter()
|
||||
.map(|adapter| {
|
||||
let info = adapter.get_info();
|
||||
let required = (create.device_descriptor)(adapter).required_limits;
|
||||
let meets_limits = required.check_limits(&adapter.limits());
|
||||
log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver);
|
||||
Dx12Adapter { device_type: info.device_type, meets_limits }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
configure_backends(&mut options, backends);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter {
|
||||
Dx12Adapter { device_type, meets_limits }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_initializes_only_dx12() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
fn windows_default_never_initializes_vulkan() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize Vulkan and OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
|
||||
// #120: whatever the DirectX 12 probe finds, and whatever the retry picks, no Vulkan.
|
||||
let probes: [&[Dx12Adapter]; 4] = [&[], &gpu, &[adapter(DeviceType::Cpu, true)], &[adapter(DeviceType::IntegratedGpu, false)]];
|
||||
for adapters in probes {
|
||||
let first = choose_backends(None, adapters);
|
||||
assert!(!first.contains(Backends::VULKAN), "{adapters:?}");
|
||||
if let Some(second) = fallback_backends(None, first) {
|
||||
assert!(!second.contains(Backends::VULKAN), "{adapters:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
// Whatever the probe would have found, the override wins (the probe doesn't run then),
|
||||
// and a failed start isn't retried with something the user didn't ask for.
|
||||
assert_eq!(choose_backends(Some(backends), &[]), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
|
||||
assert_eq!(configured_backends(backends), backends);
|
||||
assert_eq!(fallback_backends(Some(backends), backends), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gl_when_dx12_has_no_usable_gpu() {
|
||||
// No DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[]), Backends::GL);
|
||||
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
|
||||
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
|
||||
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
|
||||
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
|
||||
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_start_retries_with_the_other_backend() {
|
||||
assert_eq!(fallback_backends(None, Backends::DX12), Some(Backends::GL));
|
||||
assert_eq!(fallback_backends(None, Backends::GL), Some(Backends::DX12));
|
||||
// Both were already tried: nothing left to fall back to.
|
||||
assert_eq!(fallback_backends(None, Backends::DX12 | Backends::GL), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ mod logging;
|
|||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
|
@ -51,6 +54,16 @@ impl eframe::App for App {
|
|||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
// `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends
|
||||
// in a crash report. eframe calls `on_exit` on every way of quitting (Quit/⌘Q, the Dock,
|
||||
// the close button, `app.quit`), and nothing after it is needed: the preferences are
|
||||
// saved and the log file is unbuffered. eframe does the same when it doesn't
|
||||
// run-and-return. `on_exit` only runs once the app exists, never on a failed start.
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +97,8 @@ fn load_prefs(app: &mut SheetApp) {
|
|||
}
|
||||
// First run (or a ui.json without a usable language): follow the desktop's language.
|
||||
app.ui.language = gridcraft_ui_egui::i18n::Language::system();
|
||||
// ...and its light or dark appearance (#239), until a theme is chosen and saved in ui.json.
|
||||
app.ui.system_theme = true;
|
||||
let Some(dir) = config_dir() else { return };
|
||||
if let Ok(b) = std::fs::read(dir.join("ui.json"))
|
||||
&& let Ok(ui) = serde_json::from_slice::<gridcraft_ui_egui::UiState>(&b)
|
||||
|
|
@ -116,14 +131,23 @@ fn save_prefs(app: &SheetApp) {
|
|||
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let mut clipboard: Option<arboard::Clipboard> = None;
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let paste_clipboard = clipboard.clone();
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
if clipboard.is_none() {
|
||||
clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
|
||||
*clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
|
||||
}
|
||||
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
|
||||
})),
|
||||
paste_html: Some(Box::new(move || {
|
||||
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
|
||||
if clipboard.is_none() {
|
||||
*clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
clipboard.as_mut()?.get().html().ok()
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
|
|
@ -165,6 +189,9 @@ fn services() -> Services {
|
|||
}
|
||||
|
||||
fn icon() -> Option<egui::IconData> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png");
|
||||
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
||||
Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() })
|
||||
|
|
@ -260,37 +287,65 @@ fn main() -> eframe::Result<()> {
|
|||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
|
||||
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let backend_override = eframe::wgpu::Backends::from_env();
|
||||
#[cfg(target_os = "windows")]
|
||||
let (options, first_backends) = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
let backends = graphics::configure(&mut options, backend_override);
|
||||
(options, backends)
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let retry_options = options.clone();
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"GridCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
// The app waits here until a start gets as far as creating it; a start that fails before
|
||||
// (no graphics adapter, device or surface) leaves it here for the next attempt.
|
||||
let pending = std::cell::Cell::new(Some(app));
|
||||
let run = |options: eframe::NativeOptions| {
|
||||
eframe::run_native(
|
||||
"GridCraft",
|
||||
options,
|
||||
Box::new(|cc| {
|
||||
let Some(mut app) = pending.take() else {
|
||||
return Err("the app was already created".into());
|
||||
};
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
};
|
||||
let result = run(options);
|
||||
// A graphics error before the app existed: start once more with the other backend (eframe
|
||||
// reuses its event loop for the second run). Errors after that, or from winit, are returned.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(eframe::Error::Wgpu(e)) = &result
|
||||
&& let Some(app) = pending.take()
|
||||
&& let Some(backends) = graphics::fallback_backends(backend_override, first_backends)
|
||||
{
|
||||
log::error!("graphics: the renderer didn't start on {first_backends:?} ({e}); trying {backends:?}");
|
||||
pending.set(Some(app));
|
||||
let mut options = retry_options;
|
||||
graphics::configure_backends(&mut options, backends);
|
||||
return run(options);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,3 +28,20 @@ sync_github_name = false
|
|||
|
||||
[people.edurbs]
|
||||
sync_github_name = true
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
sync_github_name = true
|
||||
|
||||
[people.MacJedi42]
|
||||
sync_github_name = true
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ pub trait Host {
|
|||
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
|
||||
self.workbook().sheet(sheet).and_then(|s| s.used_range())
|
||||
}
|
||||
/// Calls `f` on each stored value of a block, row-major (formula cells evaluated first),
|
||||
/// until `f` returns false. `false` when this host can't (callers use `range_values`).
|
||||
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Evaluator<'h> {
|
||||
|
|
@ -554,7 +559,20 @@ impl<'h> Evaluator<'h> {
|
|||
if args.len() < 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let i = match self.arg_num(args, 0, 0.0) {
|
||||
let index = self.value(&args[0]);
|
||||
if let Value::Array(_) = index {
|
||||
// An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)`
|
||||
// is a two-column array. The library CHOOSE lifts over its arguments.
|
||||
let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) };
|
||||
let mut evaluated = vec![Arg { value: index, from_ref: false }];
|
||||
for a in &args[1..] {
|
||||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
Ok(n) => n.trunc() as i64,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
|
|
@ -664,6 +682,7 @@ impl<'h> Evaluator<'h> {
|
|||
"INDIRECT" => self.indirect(args),
|
||||
"INDEX" => self.index(args),
|
||||
"CELL" => self.cell_info(args),
|
||||
"TRIMRANGE" => self.trimrange(args),
|
||||
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
|
||||
"TEXT" => {
|
||||
if args.len() != 2 {
|
||||
|
|
@ -726,6 +745,9 @@ impl<'h> Evaluator<'h> {
|
|||
if args.len() < spec.min_args || args.len() > spec.max_args {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
if let Some(kind) = Agg::of(spec.name) {
|
||||
return self.aggregate(kind, args);
|
||||
}
|
||||
let mut evaluated = Vec::with_capacity(args.len());
|
||||
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
|
||||
let mut criteria_shape = None;
|
||||
|
|
@ -762,6 +784,70 @@ impl<'h> Evaluator<'h> {
|
|||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
}
|
||||
|
||||
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
|
||||
/// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one
|
||||
/// after another, as the array path does, so volatile formulas (like `RAND`) see the same order.
|
||||
/// Only `SUM`, `COUNT` and `AVG` are supported currently.
|
||||
fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev {
|
||||
let mut acc = Acc::default();
|
||||
let mut first_error = None;
|
||||
for a in args {
|
||||
let ev = self.eval(a);
|
||||
let steps = match ev {
|
||||
Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(),
|
||||
ev => {
|
||||
let value = self.deref(ev);
|
||||
vec![self.aggregate_value(kind, value, false, &mut acc)]
|
||||
}
|
||||
};
|
||||
if first_error.is_none() {
|
||||
first_error = steps.into_iter().find_map(Result::err);
|
||||
}
|
||||
}
|
||||
first_error.unwrap_or_else(|| Ev::V(acc.finish(kind)))
|
||||
}
|
||||
|
||||
/// Adds one area to `acc`; `Err` is the result the function ends with.
|
||||
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
|
||||
if !a.range.is_single() {
|
||||
let mut error = None;
|
||||
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
error = Some(e);
|
||||
false
|
||||
}
|
||||
});
|
||||
if walked {
|
||||
return error.map_or(Ok(()), |e| Err(err(e)));
|
||||
}
|
||||
}
|
||||
let value = self.area_value(a);
|
||||
self.aggregate_value(kind, value, true, acc)
|
||||
}
|
||||
|
||||
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
|
||||
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
|
||||
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
|
||||
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
|
||||
return Err(err(CellError::Name));
|
||||
};
|
||||
let args = [Arg { value, from_ref }];
|
||||
if kind != Agg::Count {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) {
|
||||
Value::Number(n) => acc.sum += n,
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
}
|
||||
if kind != Agg::Sum {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) {
|
||||
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
|
||||
if args.len() > l.params.len() {
|
||||
return err(CellError::Value);
|
||||
|
|
@ -1239,6 +1325,57 @@ pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option<String> {
|
|||
Some(format!("{sheet}{out}"))
|
||||
}
|
||||
|
||||
/// The aggregates that can fold a reference without building an array.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Agg {
|
||||
Sum,
|
||||
Count,
|
||||
Average,
|
||||
}
|
||||
|
||||
impl Agg {
|
||||
fn of(name: &str) -> Option<Agg> {
|
||||
match name {
|
||||
"SUM" => Some(Agg::Sum),
|
||||
"COUNT" => Some(Agg::Count),
|
||||
"AVERAGE" => Some(Agg::Average),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a folded aggregate carries from one value to the next.
|
||||
#[derive(Default)]
|
||||
struct Acc {
|
||||
sum: f64,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl Acc {
|
||||
/// Takes a value found inside a reference: only numbers count. `Err` is an error value that
|
||||
/// ends `SUM` and `AVERAGE`; `COUNT` skips errors.
|
||||
fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
self.sum += n;
|
||||
self.count = self.count.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
Value::Error(e) if kind != Agg::Count => Err(*e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, kind: Agg) -> Value {
|
||||
match kind {
|
||||
Agg::Sum => Value::number(self.sum),
|
||||
Agg::Count => Value::number(self.count as f64),
|
||||
Agg::Average if self.count == 0 => Value::Error(CellError::Div0),
|
||||
Agg::Average => Value::number(self.sum / self.count as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- operators
|
||||
|
||||
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value {
|
||||
|
|
@ -1455,6 +1592,7 @@ fn is_special(n: &str) -> bool {
|
|||
| "INDIRECT"
|
||||
| "INDEX"
|
||||
| "CELL"
|
||||
| "TRIMRANGE"
|
||||
| "SUBTOTAL"
|
||||
| "AGGREGATE"
|
||||
| "TEXT"
|
||||
|
|
@ -1492,6 +1630,7 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
|
|||
"INDIRECT",
|
||||
"INDEX",
|
||||
"CELL",
|
||||
"TRIMRANGE",
|
||||
"SUBTOTAL",
|
||||
"AGGREGATE",
|
||||
"TEXT",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -598,6 +598,30 @@ impl Host for PassHost<'_> {
|
|||
}
|
||||
out
|
||||
}
|
||||
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
let wb = self.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return false };
|
||||
// Formula cells first, in row-major order like `range_values`: reading one calculates it
|
||||
// and the pass remembers the value, so the walk below gets the same value again without
|
||||
// this method holding any.
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
if cell.formula.is_some() {
|
||||
let _ = self.cell_value(sheet, c);
|
||||
}
|
||||
}
|
||||
// Spilled values sit between the stored cells (and the formulas just calculated may have
|
||||
// produced some): leave such blocks to `range_values`.
|
||||
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
|
||||
return false;
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
|
||||
let wb = self.wb;
|
||||
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
|
||||
|
|
@ -974,13 +998,13 @@ fn prof_now() -> f64 {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
|
||||
use super::{Calc, Intervals, Key};
|
||||
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random cases.
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
|
|
@ -1181,4 +1205,41 @@ mod tests {
|
|||
calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps);
|
||||
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
|
||||
}
|
||||
|
||||
/// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the
|
||||
/// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been
|
||||
/// calculated yet, or `None` when it declines the block.
|
||||
pub(crate) fn probe_visit_range(wb: &Workbook, pending: &[(&str, &str)], range: RangeRef) -> Option<Vec<Value>> {
|
||||
let mut exprs = HashMap::default();
|
||||
for (at, formula) in pending {
|
||||
let expr = gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).ok()?;
|
||||
exprs.insert((0, CellRef::parse(at)?), std::sync::Arc::new(expr));
|
||||
}
|
||||
|
||||
// A cell in `pending` is calculated the first time something reads it, which is the state
|
||||
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
|
||||
// being calculated under it.
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
pending: exprs.keys().copied().collect(),
|
||||
exprs: &exprs,
|
||||
results: HashMap::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: 0.0,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
let mut seen = Vec::new();
|
||||
host.visit_range(0, range, &mut |v| {
|
||||
seen.push(v.clone());
|
||||
true
|
||||
})
|
||||
.then_some(seen)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,25 @@ fn ifs_and_lazy() {
|
|||
assert_eq!(t.get("B4"), Value::from("five"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_with_an_array_index() {
|
||||
let mut t = T::new();
|
||||
for (i, (k, v)) in [(1, 10), (2, 20), (3, 30)].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), &k.to_string());
|
||||
t.set(&format!("B{}", i + 1), &v.to_string());
|
||||
}
|
||||
t.set("D1", "=CHOOSE({1,2},\"a\",\"b\")");
|
||||
assert_eq!(t.get("D1"), Value::from("a"));
|
||||
assert_eq!(t.get("E1"), Value::from("b"));
|
||||
// The usual "look left" idiom: a two-column array from two ranges.
|
||||
t.set("D3", "=VLOOKUP(20,CHOOSE({1,2},B1:B3,A1:A3),2,FALSE)");
|
||||
assert_eq!(t.num("D3"), 2.0);
|
||||
t.set("D4", "=SUM(CHOOSE({1,2},A1:A3,B1:B3))");
|
||||
assert_eq!(t.num("D4"), 66.0);
|
||||
t.set("D5", "=CHOOSE(2,A1:A3,B1:B3)");
|
||||
assert_eq!(t.num("D7"), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_functions() {
|
||||
let mut t = T::new();
|
||||
|
|
@ -467,3 +486,223 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
|
|||
assert_eq!(t.num("C1"), 10.0);
|
||||
assert_eq!(t.num("C2"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimrange() {
|
||||
let mut t = T::new();
|
||||
t.set("C3", "1");
|
||||
t.set("D5", "2");
|
||||
let cases = [
|
||||
("J1", "=ROWS(TRIMRANGE(A1:F10))", 3.0),
|
||||
("J2", "=COLUMNS(TRIMRANGE(A1:F10))", 2.0),
|
||||
("J3", "=ROWS(TRIMRANGE(A1:F10,2))", 5.0),
|
||||
("J4", "=ROWS(TRIMRANGE(A1:F10,1,0))", 8.0),
|
||||
("J5", "=COLUMNS(TRIMRANGE(A1:F10,1,0))", 6.0),
|
||||
("J6", "=ROWS(TRIMRANGE(A1:F10,0,0))", 10.0),
|
||||
("J7", "=SUM(TRIMRANGE(C:C))", 1.0),
|
||||
("J8", "=ROWS(TRIMRANGE(C:C))", 1.0),
|
||||
("J9", "=ROWS(TRIMRANGE(C:C,2))", 3.0),
|
||||
("J10", "=MIN(ROW(TRIMRANGE(A1:F10)))", 3.0),
|
||||
("J11", "=COLUMNS(TRIMRANGE({1,2;3,4}))", 2.0),
|
||||
];
|
||||
for (at, f, _) in cases {
|
||||
t.set(at, f);
|
||||
}
|
||||
for (at, f, want) in cases {
|
||||
assert_eq!(t.num(at), want, "{f}");
|
||||
}
|
||||
// The result spills as the trimmed block and follows edits.
|
||||
t.set("L1", "=TRIMRANGE(A1:F10)");
|
||||
assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0));
|
||||
t.set("E6", "3");
|
||||
assert_eq!((t.num("J1"), t.num("J2")), (4.0, 3.0));
|
||||
// Errors: a bad trim code, nothing but blanks, several areas.
|
||||
t.set("K1", "=TRIMRANGE(A1:F10,4)");
|
||||
assert_eq!(t.get("K1"), Value::Error(CellError::Value));
|
||||
t.set("K2", "=TRIMRANGE(Q1:Q9)");
|
||||
assert_eq!(t.get("K2"), Value::Error(CellError::Calc));
|
||||
t.set("K3", "=TRIMRANGE((A1:B2,C3:D4))");
|
||||
assert_eq!(t.get("K3"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
/// `formula` in AB1 over a small mixed sheet. The ranges in the cases below are cut from it:
|
||||
///
|
||||
/// ```text
|
||||
/// A B C E
|
||||
/// 1 1.5 10 =A1*4 (C1 is a formula cell inside the ranges)
|
||||
/// 2 2 TRUE #DIV/0! (E2 only when `error` is set)
|
||||
/// 3 text 3
|
||||
/// 4 -0.25
|
||||
/// ```
|
||||
fn aggregate_of(error: bool, formula: &str) -> Value {
|
||||
let mut t = T::new();
|
||||
for (at, v) in [("A1", "1.5"), ("A2", "2"), ("A3", "skipped"), ("A4", "-0.25"), ("B1", "10"), ("B2", "TRUE"), ("B3", "3"), ("C1", "=A1*4")] {
|
||||
t.set(at, v);
|
||||
}
|
||||
if error {
|
||||
t.set("E2", "#DIV/0!");
|
||||
}
|
||||
|
||||
// AB1 is outside every range used, so the formula can't read itself.
|
||||
t.set("AB1", formula);
|
||||
t.get("AB1")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_count_average_over_ranges() {
|
||||
// Text, booleans, blanks and a formula cell inside ranges; direct arguments; unions; whole
|
||||
// columns; blocks holding an error or nothing numeric. The expected values are what the
|
||||
// array path (the ranges copied into an array) gave before the ranges were folded in place,
|
||||
// so any difference is a change of behavior.
|
||||
let n = Value::Number;
|
||||
let e = Value::Error;
|
||||
|
||||
// (formula written with SUM, error in E2?, [SUM, COUNT, AVERAGE] of that formula)
|
||||
#[rustfmt::skip]
|
||||
let cases: &[(&str, bool, [Value; 3])] = &[
|
||||
("=SUM(A1:A1,B3)", false, [n(4.5), n(2.0), n(2.25)]),
|
||||
("=SUM(A1:A1,B3)", true, [n(4.5), n(2.0), n(2.25)]),
|
||||
("=SUM((A1:A2,B1:B3))", false, [n(16.5), n(4.0), n(4.125)]),
|
||||
("=SUM((A1:A2,B1:B3))", true, [n(16.5), n(4.0), n(4.125)]),
|
||||
("=SUM(A1:A4,B1:B3)", false, [n(16.25), n(5.0), n(3.25)]),
|
||||
("=SUM(A1:A4,B1:B3)", true, [n(16.25), n(5.0), n(3.25)]),
|
||||
("=SUM(A1:A4,\"x\")", false, [e(CellError::Value), n(3.0), e(CellError::Value)]),
|
||||
("=SUM(A1:A4,\"x\")", true, [e(CellError::Value), n(3.0), e(CellError::Value)]),
|
||||
("=SUM(A1:B4,5,\"7\",TRUE)", false, [n(29.25), n(8.0), n(3.65625)]),
|
||||
("=SUM(A1:B4,5,\"7\",TRUE)", true, [n(29.25), n(8.0), n(3.65625)]),
|
||||
("=SUM(A1:C4)+SUM(B1:B3)", false, [n(35.25), n(8.0), n(10.208333333333334)]),
|
||||
("=SUM(A1:C4)+SUM(B1:B3)", true, [n(35.25), n(8.0), n(10.208333333333334)]),
|
||||
("=SUM(A1:C4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
|
||||
("=SUM(A1:C4)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
|
||||
("=SUM(A1:E4)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
|
||||
("=SUM(A1:E4)", true, [e(CellError::Div0), n(6.0), e(CellError::Div0)]),
|
||||
("=SUM(A1)", false, [n(1.5), n(1.0), n(1.5)]),
|
||||
("=SUM(A1)", true, [n(1.5), n(1.0), n(1.5)]),
|
||||
("=SUM(A3,B2)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
|
||||
("=SUM(A3,B2)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
|
||||
("=SUM(A:C)", false, [n(22.25), n(6.0), n(3.7083333333333335)]),
|
||||
("=SUM(A:C)", true, [n(22.25), n(6.0), n(3.7083333333333335)]),
|
||||
("=SUM(E1:E3)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
|
||||
("=SUM(E1:E3)", true, [e(CellError::Div0), n(0.0), e(CellError::Div0)]),
|
||||
("=SUM(Z1:Z9)", false, [n(0.0), n(0.0), e(CellError::Div0)]),
|
||||
("=SUM(Z1:Z9)", true, [n(0.0), n(0.0), e(CellError::Div0)]),
|
||||
];
|
||||
|
||||
for (formula, error, want) in cases {
|
||||
for (func, want) in ["SUM", "COUNT", "AVERAGE"].into_iter().zip(want) {
|
||||
// The same formula for each function: `SUM(` becomes `COUNT(` or `AVERAGE(`.
|
||||
let f = formula.replace("SUM", func);
|
||||
|
||||
assert_eq!(&aggregate_of(*error, &f), want, "{f} (error in E2: {error})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_count_average_have_no_size_limit() {
|
||||
// One stored cell far down stretches the used range to 27 million cells (27 columns by a
|
||||
// million rows), past the 16M-cell limit of ranges copied into an array. Only two cells are
|
||||
// stored, so the sheet itself is tiny.
|
||||
for (func, want) in [("SUM", Value::Number(2.5)), ("COUNT", Value::Number(2.0)), ("AVERAGE", Value::Number(1.25))] {
|
||||
for range in ["A1:AA1000000", "A:AA"] {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1.5");
|
||||
t.set("Z1000000", "1");
|
||||
|
||||
t.set("AB1", &format!("={func}({range})"));
|
||||
|
||||
assert_eq!(t.get("AB1"), want, "{func}({range})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_count_average_over_spilled_values() {
|
||||
// A1 spills over A1:A3, so the block holds values that exist only as a spill (they have no
|
||||
// stored cell of their own). The host must not fold such a block in place: it falls back
|
||||
// to the array path, which sees the spill. Expected: 1 + 2 + 3 + 10.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
t.set("A4", "10");
|
||||
|
||||
for (f, want) in [("SUM", 16.0), ("COUNT", 4.0), ("AVERAGE", 4.0)] {
|
||||
t.set("C1", &format!("={f}(A1:A5)"));
|
||||
|
||||
assert_eq!(t.num("C1"), want, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_arguments_are_read_in_order() {
|
||||
// Volatile cells draw a random number each time they are calculated, so the order in which
|
||||
// the arguments are read decides which draw each one gets. `B1:B2` comes first, so B2 must
|
||||
// draw before the `RAND()` argument does. (Reading the plain `RAND()` first once gave
|
||||
// 7.9043 here.) The expected values are what the array path gave; they depend on the random
|
||||
// generator and its seed, and must be updated if either changes.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "1");
|
||||
t.set("B2", "=RAND()*10");
|
||||
|
||||
t.set("A1", "=SUM(B1:B2,RAND())");
|
||||
|
||||
assert_eq!((t.num("A1"), t.num("B2")), (4.968520165759784, 3.3112028100185356));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_count_average_follow_edits_inside_the_range() {
|
||||
// A range read in place must still count as a precedent of the formula: if it didn't, an
|
||||
// edit inside it would leave the formula showing a stale value. Each step below changes one
|
||||
// cell inside the range and checks all three functions; the last one changes a cell outside.
|
||||
for range in ["A1:A5", "A:A"] {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("A3", "3");
|
||||
t.set("C1", &format!("=SUM({range})"));
|
||||
t.set("C2", &format!("=COUNT({range})"));
|
||||
t.set("C3", &format!("=AVERAGE({range})"));
|
||||
let all = |t: &T| (t.num("C1"), t.num("C2"), t.num("C3"));
|
||||
assert_eq!(all(&t), (4.0, 2.0, 2.0), "{range}");
|
||||
|
||||
t.set("A1", "5");
|
||||
assert_eq!(all(&t), (8.0, 2.0, 4.0), "{range}: edit");
|
||||
|
||||
t.set("A2", "10");
|
||||
assert_eq!(all(&t), (18.0, 3.0, 6.0), "{range}: fill a blank");
|
||||
|
||||
t.set("A3", "text");
|
||||
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: number to text");
|
||||
|
||||
t.set("A2", "");
|
||||
assert_eq!(all(&t), (5.0, 1.0, 5.0), "{range}: clear");
|
||||
|
||||
t.set("A5", "=A1*2");
|
||||
assert_eq!(all(&t), (15.0, 2.0, 7.5), "{range}: formula in the range");
|
||||
|
||||
t.set("A1", "1");
|
||||
assert_eq!(all(&t), (3.0, 2.0, 1.5), "{range}: its precedent changes");
|
||||
|
||||
// A7 is inside the whole column A:A but outside A1:A5.
|
||||
t.set("A7", "100");
|
||||
let outside = if range == "A:A" { (103.0, 3.0, 103.0 / 3.0) } else { (3.0, 2.0, 1.5) };
|
||||
assert_eq!(all(&t), outside, "{range}: a cell outside the range");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() {
|
||||
// The sheet: D1 = SEQUENCE(3) and D4 = 10, and D1 has not been calculated yet when the
|
||||
// block D1:D5 is read.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.cells.set(c("D1"), Cell::formula(Formula::new("=SEQUENCE(3)")));
|
||||
sh.cells.set(c("D4"), Cell::value(Value::Number(10.0)));
|
||||
let block = gridcraft_core::RangeRef::parse("D1:D5").unwrap();
|
||||
|
||||
// Reading the block calculates D1, which spills over D2:D3. A walk over the stored cells
|
||||
// would never see 2 and 3, so the host must decline and leave the block to `range_values`.
|
||||
// (`Calc` recalculates a pass that produced new spills, so final results are right either
|
||||
// way; only this direct call shows the host's contract.)
|
||||
let walked = crate::recalc::tests::probe_visit_range(&wb, &[("D1", "=SEQUENCE(3)")], block);
|
||||
|
||||
assert_eq!(walked, None, "walked a block holding a spill it can't see");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
|
|||
Some((sh, r))
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped.
|
||||
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
|
||||
fn union_parts(f: &str) -> Option<Vec<&str>> {
|
||||
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
'(' if !quoted => depth += 1,
|
||||
')' if !quoted => depth -= 1,
|
||||
',' if !quoted && depth == 0 => {
|
||||
parts.push(inner.get(start..i)?);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if depth < 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parts.push(inner.get(start..)?);
|
||||
(parts.len() > 1).then_some(parts)
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
|
||||
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
|
||||
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
|
||||
eval_list_in(wb, sheet, formula, 0)
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
|
||||
let f = formula.trim();
|
||||
if f.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
if depth < 4
|
||||
&& let Some(parts) = union_parts(f)
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for p in parts {
|
||||
if out.len() >= MAX_POINTS {
|
||||
break;
|
||||
}
|
||||
let rest = MAX_POINTS - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match evaluate(wb, sheet, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
|
||||
v => vec![v],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart {
|
|||
y_title: Some("Revenue".into()),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() {
|
|||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_series_references() {
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() {
|
||||
s.set_value(CellRef::new(r as u32, 0), Value::from(*l));
|
||||
s.set_value(CellRef::new(r as u32, 1), Value::Number(*v));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("\"Sum of X\"".into()),
|
||||
categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()),
|
||||
values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(),
|
||||
..Series::default()
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]);
|
||||
assert_eq!(d.series[0].name, "Sum of X");
|
||||
assert_eq!(d.categories, vec!["a", "b", "d"]);
|
||||
// Deeply nested parentheses from a file are bounded, not a crash.
|
||||
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let _ = resolve(&wb, 0, &c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\""));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"data.filter",
|
||||
"Filter",
|
||||
["Data", "Sort & Filter"],
|
||||
Some("Cmd+Shift+F"),
|
||||
Some("Ctrl+Shift+L"),
|
||||
"{range?} toggles AutoFilter on the current region",
|
||||
has_doc,
|
||||
toggle_filter
|
||||
|
|
@ -248,7 +248,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
|||
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
|
||||
pos(va).cmp(&pos(vb))
|
||||
} else if case && va.is_text() && vb.is_text() {
|
||||
va.as_text().unwrap_or("").cmp(vb.as_text().unwrap_or(""))
|
||||
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
|
||||
} else {
|
||||
sort_compare(va, vb)
|
||||
};
|
||||
|
|
@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
Ok(json!({"filter": false}))
|
||||
});
|
||||
}
|
||||
let r = data_range(s, p)?;
|
||||
let mut r = data_range(s, p)?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let below = current_region(sh, r.start).end.row;
|
||||
if below > r.end.row {
|
||||
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
|
||||
Ok(json!({"filter": true, "range": r.a1()}))
|
||||
|
|
@ -653,6 +662,12 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
|||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
|
||||
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
|
||||
/// before uppercase at the first letter that differs in case.
|
||||
fn case_order(a: &str, b: &str) -> Ordering {
|
||||
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
|
||||
}
|
||||
|
||||
fn sort_key(v: &Value) -> (u8, i64, String) {
|
||||
match v {
|
||||
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
|
||||
|
|
@ -685,8 +700,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut keep: Vec<u32> = Vec::new();
|
||||
// Cells are compared by the text they show, as in Excel: the same date in two formats
|
||||
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
|
||||
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
|
||||
for row in start..=r.end.row {
|
||||
let key: Vec<String> = cols.iter().map(|c| sh.value(CellRef::new(row, *c)).display().to_lowercase()).collect();
|
||||
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
|
||||
if seen.insert(key) {
|
||||
keep.push(row);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, StyleId};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Paste",
|
||||
["Home", "Clipboard"],
|
||||
Some("Cmd+V"),
|
||||
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
|
||||
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
|
|
@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
|
|
@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
fill_series
|
||||
),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
|
||||
cmd!(
|
||||
"edit.replace",
|
||||
|
|
@ -199,10 +199,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let input =
|
||||
str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default();
|
||||
let array = bool_param(p, "array").unwrap_or(false);
|
||||
let protected =
|
||||
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked));
|
||||
if protected {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet)
|
||||
&& sh.is_protected()
|
||||
&& d.wb.styles.get(sh.style_id(at)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
if array {
|
||||
|
|
@ -408,9 +411,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
if !extend && let Some(m) = sheet.merge_at(from) {
|
||||
// Leaving a merged cell moves from its edge.
|
||||
if dr > 0 {
|
||||
t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col);
|
||||
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
|
||||
} else if dc > 0 {
|
||||
t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1));
|
||||
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
|
||||
}
|
||||
}
|
||||
t
|
||||
|
|
@ -604,25 +607,34 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
// ---------------------------------------------------------------- clipboard
|
||||
|
||||
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
if ranges.len() > 1 {
|
||||
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
|
||||
if ranges.len() > 1 && cut {
|
||||
return Err(multiple());
|
||||
}
|
||||
let range = ranges.first().copied().unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let range = if range.is_full_cols() || range.is_full_rows() {
|
||||
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
|
||||
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
|
||||
} else {
|
||||
range
|
||||
let trim = |range: RangeRef| {
|
||||
if range.is_full_cols() || range.is_full_rows() {
|
||||
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
|
||||
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
|
||||
} else {
|
||||
range
|
||||
}
|
||||
};
|
||||
let text = crate::display::range_text(&d.wb, sheet, range);
|
||||
let (wb, range, areas) = if ranges.len() > 1 {
|
||||
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, areas)
|
||||
} else {
|
||||
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
|
||||
};
|
||||
let text = crate::display::range_text(&wb, sheet, range);
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
// scripts get it only when they ask for it.
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas };
|
||||
s.clipboard = Some(clip);
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
|
|
@ -631,6 +643,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
|
||||
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
|
||||
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
|
||||
/// into one block at the first area's place, and that block. Formulas travel as their values, as
|
||||
/// in Excel. `None` when the areas don't line up.
|
||||
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
|
||||
let first = *areas.first()?;
|
||||
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
|
||||
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
|
||||
let mut sorted = areas.to_vec();
|
||||
if across {
|
||||
sorted.sort_by_key(|a| a.start.col);
|
||||
} else if down {
|
||||
sorted.sort_by_key(|a| a.start.row);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
|
||||
return None;
|
||||
}
|
||||
let anchor = sorted.first()?.start;
|
||||
let (h, w) = if across {
|
||||
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
|
||||
} else {
|
||||
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
|
||||
};
|
||||
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
|
||||
let src = wb.sheet(sheet)?;
|
||||
let mut out = wb.clone();
|
||||
let dst = out.sheet_mut(sheet)?;
|
||||
dst.take_cells(block);
|
||||
dst.spill.retain(|c, _| !block.contains(*c));
|
||||
dst.spill_ranges.retain(|c, _| !block.contains(*c));
|
||||
dst.comments.retain(|c, _| !block.contains(*c));
|
||||
dst.merges.retain(|m| !m.intersects(&block));
|
||||
if across {
|
||||
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
|
||||
} else {
|
||||
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
|
||||
}
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
for a in &sorted {
|
||||
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
|
||||
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
}
|
||||
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
|
||||
if let Some(to) = c.offset(dr, dc) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
if across {
|
||||
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
|
||||
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
|
||||
}
|
||||
} else {
|
||||
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
|
||||
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
|
||||
}
|
||||
}
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
Some((out, block))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
copy_impl(s, p, false)
|
||||
}
|
||||
|
|
@ -674,13 +763,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
|||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
|
||||
let rows: Vec<Vec<String>> = parse_tsv(text);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
|
||||
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
|
||||
let h = rows.len();
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
|
||||
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
if let Some(cl) = cell.as_mut() {
|
||||
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(c, cell),
|
||||
|
|
@ -700,6 +794,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
|||
Ok(json!({"rows": h, "cols": w}))
|
||||
}
|
||||
|
||||
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
|
||||
/// source recorded for it in its HTML, with a format that shows the same text when one can be
|
||||
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
|
||||
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
|
||||
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
|
||||
let Some(hc) = html else { return };
|
||||
let Some(n) = hc.num else { return };
|
||||
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
|
||||
return;
|
||||
}
|
||||
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
|
||||
return;
|
||||
}
|
||||
cell.value = Value::number(n);
|
||||
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
|
||||
&& let Some(code) = crate::clip_html::format_for(field, n)
|
||||
{
|
||||
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab-separated text with Excel-style quoting.
|
||||
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
let mut rows = Vec::new();
|
||||
|
|
@ -769,6 +884,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
|
||||
let transpose = bool_param(p, "transpose").unwrap_or(false);
|
||||
let link = bool_param(p, "link").unwrap_or(false);
|
||||
if link && !clip.areas.is_empty() {
|
||||
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
|
||||
}
|
||||
let dest_sheet = target_sheet(s, p)?;
|
||||
let sel = s.doc()?.selection.current();
|
||||
let at = cell_param(p, "at").unwrap_or(sel.start);
|
||||
|
|
@ -1050,6 +1168,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
|||
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
|
||||
// and only when every box in the selection is unchecked are the boxes removed.
|
||||
let uncheck_only = what == "contents" && {
|
||||
let d = s.doc()?;
|
||||
d.wb.sheet(sheet).is_some_and(|sh| {
|
||||
ranges.iter().any(|r| {
|
||||
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
|
||||
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
|
|
@ -1061,6 +1191,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
|||
};
|
||||
let cells: Vec<(CellRef, Cell)> =
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
|
||||
let mut cells = cells;
|
||||
if what == "contents" {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
cell.value = Value::Empty;
|
||||
if checkbox {
|
||||
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
|
|
@ -1068,8 +1213,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
|||
sh.remove_cell(c);
|
||||
}
|
||||
"contents" => {
|
||||
cell.value = Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
"formats" => {
|
||||
|
|
@ -1139,6 +1282,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
|
||||
let mode = match str_param(p, "mode").unwrap_or("series") {
|
||||
"copy" => crate::fill::FillMode::Copy,
|
||||
"alternate" => crate::fill::FillMode::Alternate,
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Select Data…",
|
||||
["Chart Design", "Data"],
|
||||
None,
|
||||
"{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}",
|
||||
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
|
||||
has_doc,
|
||||
select_data
|
||||
),
|
||||
|
|
@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Checkbox",
|
||||
["Insert", "Cell Controls"],
|
||||
None,
|
||||
"{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)",
|
||||
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
|
||||
has_doc,
|
||||
checkbox
|
||||
),
|
||||
|
|
@ -350,22 +350,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
}
|
||||
|
||||
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string();
|
||||
let (sheet_name, body) = split_sheet(&range);
|
||||
let d = s.doc()?;
|
||||
let src_si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
|
||||
let rebuilt = match str_param(p, "range") {
|
||||
Some(range) => {
|
||||
let range = range.trim_start_matches('=');
|
||||
let (sheet_name, body) = split_sheet(range);
|
||||
let src_si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
|
||||
if r.count() > 1_000_000 {
|
||||
return Err(bad("chart.selectData", "range too large for a chart"));
|
||||
}
|
||||
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
|
||||
let series = super::insert::series_from_range(src, r, by_rows);
|
||||
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
|
||||
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
|
||||
let series = super::insert::series_from_range(src, r, by_rows);
|
||||
let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1());
|
||||
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
|
||||
let categories = match str_param(p, "categories") {
|
||||
None => None,
|
||||
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
|
||||
Some(t) => {
|
||||
let t = t.trim().trim_start_matches('=');
|
||||
let (sheet_name, body) = split_sheet(t);
|
||||
let si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
|
||||
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
|
||||
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
|
||||
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
|
||||
}
|
||||
};
|
||||
let mut remove: Vec<usize> = p
|
||||
.get("removeSeries")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
remove.sort_unstable();
|
||||
remove.dedup();
|
||||
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
|
||||
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
|
||||
}
|
||||
with_chart(s, p, move |c| {
|
||||
c.series = series;
|
||||
c.source = Some(source);
|
||||
c.by_rows = by_rows;
|
||||
if let Some((series, source, by_rows)) = rebuilt {
|
||||
c.series = series;
|
||||
c.source = Some(source);
|
||||
c.by_rows = by_rows;
|
||||
}
|
||||
for i in remove.iter().rev() {
|
||||
if *i < c.series.len() && c.series.len() > 1 {
|
||||
c.series.remove(*i);
|
||||
}
|
||||
}
|
||||
if let Some(cats) = categories {
|
||||
for se in &mut c.series {
|
||||
se.categories = cats.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
@ -828,6 +876,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
|
||||
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
|
||||
|
||||
/// Whether `c` has checkbox formatting.
|
||||
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
|
||||
}
|
||||
|
||||
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let si = target_sheet(s, p)?;
|
||||
|
|
@ -843,9 +896,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
s.align.h = HAlign::Center;
|
||||
});
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let v = sh.value(c);
|
||||
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
|
||||
sh.set_cell(c, Cell { value, formula: None, style: new });
|
||||
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
|
||||
// cell starts unchecked.
|
||||
let mut cell = sh.cell(c).cloned().unwrap_or_default();
|
||||
if cell.value.is_empty() && cell.formula.is_none() {
|
||||
cell.value = Value::Bool(false);
|
||||
}
|
||||
cell.style = new;
|
||||
sh.set_cell(c, cell);
|
||||
cx.touch(si, c);
|
||||
}
|
||||
}
|
||||
|
|
@ -862,12 +920,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
};
|
||||
edit(s, |cx| {
|
||||
for c in cells {
|
||||
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Value::Bool(b) = cell.value
|
||||
&& cell.formula.is_none()
|
||||
{
|
||||
cell.value = Value::Bool(!b);
|
||||
// An empty checkbox cell counts as FALSE.
|
||||
let next = match cell.value {
|
||||
Value::Bool(b) if checkbox => Value::Bool(!b),
|
||||
Value::Empty if checkbox => Value::Bool(true),
|
||||
_ => continue,
|
||||
};
|
||||
cell.value = next;
|
||||
cx.changed.push((si, c));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! File menu: new, open, save, close, export, properties.
|
||||
//! File menu: new, open, save, close, export, properties, options.
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
|
|
@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
|
||||
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
|
||||
cmd!(
|
||||
query "file.options",
|
||||
"Options",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
always,
|
||||
options
|
||||
),
|
||||
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
|
||||
]
|
||||
}
|
||||
|
|
@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
|
||||
/// page. Nothing changes here; every setting has its own command.
|
||||
fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
|
||||
let calculation = s.active().map(|d| {
|
||||
let c = &d.wb.calc;
|
||||
let mode = match c.mode {
|
||||
gridcraft_model::CalcMode::Automatic => "automatic",
|
||||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||||
gridcraft_model::CalcMode::Manual => "manual",
|
||||
};
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||||
});
|
||||
Ok(json!({"calculation": calculation}))
|
||||
}
|
||||
|
||||
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
|
||||
if i >= s.documents().len() {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// Alignment
|
||||
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
|
||||
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], Some("Cmd+R"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
|
||||
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
|
||||
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
|
||||
|
|
@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Row Height…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{rows?: \"2:5\", height: 20 (points on screen)}",
|
||||
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
|
||||
has_doc,
|
||||
row_height
|
||||
),
|
||||
|
|
@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Column Width…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{cols?: \"B:D\", width: 64 (points) | chars: 8.43}",
|
||||
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
|
||||
has_doc,
|
||||
col_width
|
||||
),
|
||||
|
|
@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
if let Some(t) = str_param(p, key) {
|
||||
let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?;
|
||||
return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]);
|
||||
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
|
||||
return t
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
insert_chart
|
||||
),
|
||||
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
|
||||
s,
|
||||
&with(p, "type", json!("auto"))
|
||||
)),
|
||||
cmd!(
|
||||
"insert.recommendedCharts",
|
||||
"Recommended Charts",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
|
||||
has_doc,
|
||||
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
|
||||
),
|
||||
cmd!(
|
||||
query "insert.chartRecommendations",
|
||||
"Chart Recommendations",
|
||||
[],
|
||||
None,
|
||||
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
|
||||
has_doc,
|
||||
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
|
||||
),
|
||||
cmd!(
|
||||
"chart.set",
|
||||
"Chart Properties",
|
||||
|
|
@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
|
||||
s, p, true
|
||||
)),
|
||||
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
|
||||
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
|
||||
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
|
||||
|
|
@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
|||
let header_row = r.height() > 1
|
||||
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
|
||||
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
|
||||
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
|
||||
// A blank top-left cell above a header row marks the first column as the category labels even
|
||||
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
|
||||
// plotted as a series of its own (#287).
|
||||
let blank_corner = header_row && sh.value(r.start).is_empty();
|
||||
let label_col =
|
||||
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
|
||||
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
|
||||
let mut out = Vec::new();
|
||||
if !by_rows {
|
||||
|
|
@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
|||
/// Most cells a chart's source block may span (bounds the series it builds).
|
||||
const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
|
||||
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
|
||||
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
|
|
@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = r.width() > r.height() + 1;
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let points = ranges.first().map_or(0, RangeRef::count);
|
||||
let (mut any_number, mut non_negative) = (false, true);
|
||||
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
|
||||
any_number = true;
|
||||
non_negative &= n >= 0.0;
|
||||
}
|
||||
if series.is_empty() || !any_number {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
let labelled = series.first().is_some_and(|x| x.categories.is_some());
|
||||
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
|
||||
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
|
||||
const LINE: (&str, &str, &str) = ("line", "", "Line");
|
||||
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
|
||||
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
|
||||
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
|
||||
const AREA: (&str, &str, &str) = ("area", "", "Area");
|
||||
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
|
||||
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
|
||||
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
|
||||
let mut out = Vec::new();
|
||||
if series.len() == 2 && !labelled && !by_rows {
|
||||
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
|
||||
} else if series.len() == 1 {
|
||||
if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR]);
|
||||
if non_negative && points >= 2 {
|
||||
out.extend([PIE, DOUGHNUT]);
|
||||
}
|
||||
out.push(LINE_MARKERS);
|
||||
}
|
||||
} else if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
|
||||
if t == "auto" {
|
||||
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
|
||||
t = first.0.into();
|
||||
sub = first.1.into();
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Excel plots series by the longer dimension (more rows → series in columns).
|
||||
let data_h = r.height();
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
if t == "auto" {
|
||||
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
|
||||
}
|
||||
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
|
|
@ -606,7 +695,14 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
Ok((sheet, chart))
|
||||
}
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, chart) = build_chart(s, p)?;
|
||||
let id = chart.id;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
Ok(json!({"chart": id}))
|
||||
|
|
@ -782,7 +878,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
};
|
||||
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
|
||||
let style = sh.style_id(at);
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
|
||||
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
|
||||
|
|
@ -1036,9 +1132,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
|||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?
|
||||
.comments
|
||||
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
}
|
||||
|
|
@ -1157,15 +1254,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
};
|
||||
let rule = match kind.as_str() {
|
||||
"cellIs" => {
|
||||
// Excel's file and VBA names (`lessThan`, …) are accepted too.
|
||||
let op = match g("operator").as_deref().unwrap_or("greater") {
|
||||
"between" => CfOperator::Between,
|
||||
"notBetween" => CfOperator::NotBetween,
|
||||
"equal" => CfOperator::Equal,
|
||||
"notEqual" => CfOperator::NotEqual,
|
||||
"less" => CfOperator::Less,
|
||||
"greaterOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" => CfOperator::LessOrEqual,
|
||||
_ => CfOperator::Greater,
|
||||
"less" | "lessThan" => CfOperator::Less,
|
||||
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
|
||||
"greater" | "greaterThan" => CfOperator::Greater,
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
|
||||
};
|
||||
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(super::edit::json_to_input);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
|
||||
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
|
||||
|
||||
pub mod analysis;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
|
|
@ -14,6 +15,7 @@ pub mod pivot;
|
|||
pub mod print;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod solver;
|
||||
pub mod spelling;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
|
|
@ -69,6 +71,21 @@ impl CommandSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/// Params that hold a secret: `password`, or a name ending in `Password`.
|
||||
pub fn is_secret_param(key: &str) -> bool {
|
||||
key == "password" || key.ends_with("Password")
|
||||
}
|
||||
|
||||
/// The params as the journal records them: secret params are left out, so a password never
|
||||
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
|
||||
/// Replaying such an entry runs the command without that param.
|
||||
pub fn journal_params(params: &Json) -> Json {
|
||||
match params {
|
||||
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -118,8 +135,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(extra::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(analysis::specs());
|
||||
v.extend(print::specs());
|
||||
v
|
||||
})
|
||||
|
|
@ -257,7 +276,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
|||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb)?;
|
||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
|
|
@ -292,7 +311,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is
|
|||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
continue;
|
||||
|
|
@ -302,14 +321,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
if !same_content(sh.cell(c), after.cell(c))
|
||||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
|
||||
/// Range without a password or one unlocked in this session, else the message to show.
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
let mut needs_password = None;
|
||||
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
|
||||
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||||
return None;
|
||||
}
|
||||
needs_password.get_or_insert(r.title.as_str());
|
||||
}
|
||||
Some(match needs_password {
|
||||
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
|
||||
None => PROTECTED.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
|
|
|
|||
|
|
@ -43,6 +43,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
summarize_table
|
||||
),
|
||||
cmd!(
|
||||
"pivot.calculatedField",
|
||||
"Calculated Field",
|
||||
["PivotTable Analyze", "Calculations"],
|
||||
None,
|
||||
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
|
||||
has_doc,
|
||||
calculated_field
|
||||
),
|
||||
cmd!(
|
||||
"insert.pivotChart",
|
||||
"PivotChart",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
|
||||
has_doc,
|
||||
insert_pivot_chart
|
||||
),
|
||||
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
|
||||
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
|
||||
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
|
||||
|
|
@ -320,6 +338,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
|
|||
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
|
||||
}
|
||||
|
||||
/// The calculated field named `field`, if any.
|
||||
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
|
||||
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
|
||||
}
|
||||
|
||||
/// The canonical name of a source or calculated field.
|
||||
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
|
||||
match calc_field(pt, field) {
|
||||
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
|
||||
_ => canonical(cmd, src, field),
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
|
||||
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
|
||||
if !taken(base) {
|
||||
|
|
@ -343,7 +374,11 @@ fn add_to_area(
|
|||
name: Option<&str>,
|
||||
position: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let field = canonical(cmd, src, field)?;
|
||||
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
|
||||
if is_calc && area != "values" {
|
||||
return Err(other("A calculated field can only be placed in the Values area."));
|
||||
}
|
||||
let field = canonical_any(cmd, pt, src, field)?;
|
||||
match area {
|
||||
"rows" | "columns" => {
|
||||
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
|
||||
|
|
@ -369,7 +404,7 @@ fn add_to_area(
|
|||
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
|
||||
}
|
||||
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
|
||||
let base = match name {
|
||||
Some(n) if !n.trim().is_empty() => {
|
||||
|
|
@ -663,6 +698,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
|||
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
|
||||
"style": pt.style,
|
||||
"showHeaders": pt.show_headers,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -702,6 +738,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
.collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let mut list = list;
|
||||
for c in &pt.calculated_fields {
|
||||
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
|
||||
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
|
||||
}
|
||||
if let Some(m) = out.as_object_mut() {
|
||||
m.insert("fields".into(), Json::Array(list));
|
||||
if let Ok(src) = &src {
|
||||
|
|
@ -743,7 +784,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
|
||||
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
|
||||
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
|
||||
})
|
||||
}
|
||||
|
||||
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const C: &str = "pivot.calculatedField";
|
||||
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
|
||||
// Menu-style call: the Insert Calculated Field dialog.
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
|
||||
let pivot = pt.name.clone();
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
|
||||
return ok();
|
||||
};
|
||||
if name.is_empty() || name.chars().count() > 255 {
|
||||
return Err(bad(C, "`name` must be 1 to 255 characters"));
|
||||
}
|
||||
let delete = bool_param(p, "delete").unwrap_or(false);
|
||||
let formula = str_param(p, "formula").map(|f| {
|
||||
let f = f.trim();
|
||||
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
|
||||
});
|
||||
let to_values = bool_param(p, "values").unwrap_or(true);
|
||||
if !delete && formula.is_none() {
|
||||
return Err(bad(C, "missing `formula`"));
|
||||
}
|
||||
update(s, p, |pt, src, _| {
|
||||
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
|
||||
if delete {
|
||||
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
|
||||
let gone = pt.calculated_fields.remove(i);
|
||||
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
|
||||
return Ok(json!({"name": gone.name, "deleted": true}));
|
||||
}
|
||||
let formula = formula.clone().unwrap_or_default();
|
||||
if src.col(&name).is_some() {
|
||||
return Err(other("PivotTable field name already exists."));
|
||||
}
|
||||
let canon = match existing {
|
||||
Some(i) => {
|
||||
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
c.formula = formula.clone();
|
||||
c.name.clone()
|
||||
}
|
||||
None => {
|
||||
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
|
||||
}
|
||||
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
|
||||
name.clone()
|
||||
}
|
||||
};
|
||||
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
|
||||
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
if let Some(e) = set.error(i) {
|
||||
return Err(bad(C, e.to_string()));
|
||||
}
|
||||
if set.is_cyclic(i) {
|
||||
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
|
||||
}
|
||||
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
|
||||
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"name": canon,
|
||||
"formula": formula,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1171,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
edit(s, |cx| {
|
||||
// Its PivotCharts become ordinary charts.
|
||||
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
|
||||
if let Some((sheet, name)) = link {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
|
||||
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
|
||||
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
|
||||
c.pivot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pv::clear_output(&mut cx.wb, si, pi);
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if pi < sh.pivots.len() {
|
||||
|
|
@ -1181,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotChart
|
||||
|
||||
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
|
||||
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
if let Some(t) = str_param(p, "pivot") {
|
||||
let d = s.doc()?;
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
};
|
||||
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
|
||||
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
|
||||
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
|
||||
}
|
||||
}
|
||||
find_pivot(s, p)
|
||||
}
|
||||
|
||||
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = chart_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
|
||||
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
|
||||
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
|
||||
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
|
||||
}
|
||||
let series = pv::chart_series(&sh.name, area.start, &out);
|
||||
let t = str_param(p, "type").unwrap_or("column");
|
||||
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let title = match p.get("title") {
|
||||
Some(Json::String(t)) => Some(t.clone()),
|
||||
Some(Json::Null) => None,
|
||||
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
|
||||
};
|
||||
let id = d.wb.next_object_id();
|
||||
let link = pv::chart_link(&sh.name, &pt.name);
|
||||
let pivot_name = pt.name.clone();
|
||||
let n = series.len();
|
||||
let chart = Chart {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
title,
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: Some(link),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.charts.push(chart);
|
||||
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recommended
|
||||
|
||||
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
|
||||
|
|
@ -1280,8 +1476,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use serde_json::json;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
|
|
@ -1550,6 +1746,133 @@ mod tests {
|
|||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
|
||||
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
|
||||
}
|
||||
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
|
||||
let d = s.doc().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
|
||||
data.series.into_iter().map(|x| x.values).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_follows_the_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
// Any cell of the report picks the PivotTable.
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
assert_eq!(ch.title.as_deref(), Some("Total"));
|
||||
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
|
||||
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
|
||||
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
|
||||
// A column field: one series per item, rebound by the layout change.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
|
||||
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
|
||||
// Nested row fields: the innermost items only, as a union around the parent rows.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
|
||||
let pts = plotted(&s);
|
||||
assert_eq!(pts[0].len(), 9);
|
||||
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
|
||||
// Survives an XLSX round trip as a PivotChart.
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
let si = back.sheet_index("Sheet2").unwrap();
|
||||
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
|
||||
// Renaming the sheet keeps the link; a refresh still rebinds.
|
||||
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
|
||||
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).series.len(), 3);
|
||||
// Deleting the PivotTable leaves an ordinary chart.
|
||||
s.execute("pivot.delete", json!({})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_errors() {
|
||||
let mut s = sales();
|
||||
// No PivotTable yet.
|
||||
assert!(s.execute("insert.pivotChart", json!({})).is_err());
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
|
||||
// Not in the report; no values.
|
||||
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
|
||||
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
|
||||
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
|
||||
assert_eq!(ch.title, None);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_on_sums() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
|
||||
assert_eq!(r["formula"], "=Sales / Units");
|
||||
assert_eq!(text(&s, "C3"), "Sum of Price");
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
// East: 415 sales over 33 units, from the sums.
|
||||
let east = val(&s, "C4").as_f64().unwrap();
|
||||
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
|
||||
// The grand total applies the formula to the totals, not the sum of the rows.
|
||||
assert_eq!(text(&s, "A7"), "Grand Total");
|
||||
let total = val(&s, "C7").as_f64().unwrap();
|
||||
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
|
||||
// Quoted names, functions, and another calculated field.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
|
||||
assert_eq!(val(&s, "D4"), Value::Number(25.2));
|
||||
// Division by zero shows #DIV/0! cells.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
|
||||
// Change and delete.
|
||||
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Number(66.0));
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
|
||||
assert_eq!(pt(&s).values.len(), 3);
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// The field list shows calculated fields.
|
||||
let f = s.execute("pivot.fields", json!({})).unwrap();
|
||||
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_errors() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
|
||||
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
|
||||
assert!(e(json!({"name": "Bad"})).contains("formula"));
|
||||
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
|
||||
// A cycle is refused.
|
||||
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
|
||||
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
|
||||
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
|
||||
assert!(err.contains("itself"), "{err}");
|
||||
// Calculated fields only go to Values; nothing changed after the failures.
|
||||
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// Without a name the dialog opens (menu-style call).
|
||||
s.ui_requests.clear();
|
||||
s.execute("pivot.calculatedField", json!({})).unwrap();
|
||||
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_protection() {
|
||||
let mut s = sales();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Review tab: protection, spelling (basic), workbook statistics.
|
||||
|
||||
use gridcraft_model::{SheetProtection, password_hash};
|
||||
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
|
|
@ -19,6 +19,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
|
||||
cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook),
|
||||
cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
|
||||
// Not journaled: the params can hold a password.
|
||||
CommandSpec {
|
||||
id: "review.allowEditRanges",
|
||||
label: "Allow Edit Ranges…",
|
||||
menu: &["Review", "Protect"],
|
||||
shortcut: None,
|
||||
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
|
||||
enabled: has_doc,
|
||||
run: allow_edit_ranges,
|
||||
journal: false,
|
||||
undoable: true,
|
||||
},
|
||||
cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
|
||||
]
|
||||
}
|
||||
|
|
@ -60,6 +72,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
fn range_text(r: &EditRange) -> String {
|
||||
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
|
||||
/// protected sheet, or unlock a password range for this session.
|
||||
fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let action = str_param(p, "action").unwrap_or("list");
|
||||
let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let sheet_name = sh.name.clone();
|
||||
let find = |t: Option<&str>| -> Result<usize> {
|
||||
let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
|
||||
sh.edit_ranges
|
||||
.iter()
|
||||
.position(|r| r.title.eq_ignore_ascii_case(t))
|
||||
.ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
|
||||
};
|
||||
let hash = |pw: &str, title: &str| {
|
||||
let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits());
|
||||
PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes()))
|
||||
};
|
||||
if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
|
||||
return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
|
||||
}
|
||||
match action {
|
||||
"list" => {}
|
||||
"unlock" => {
|
||||
let i = find(title)?;
|
||||
let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
|
||||
if let Some(h) = &r.password {
|
||||
let pw = str_param(p, "password").unwrap_or("");
|
||||
if !h.verify(pw).map_err(EngineError::Other)? {
|
||||
return Err(EngineError::Other("The password you supplied is not correct.".into()));
|
||||
}
|
||||
let key = (sheet_name.clone(), r.title.clone());
|
||||
let d = s.doc_mut()?;
|
||||
if !d.unlocked_ranges.contains(&key) {
|
||||
d.unlocked_ranges.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
"add" => {
|
||||
let t = match title {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
(1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
|
||||
return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
|
||||
}
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
|
||||
let r = EditRange { title: t, ranges, password, security_descriptor: None };
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"modify" => {
|
||||
let i = find(title)?;
|
||||
let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
|
||||
if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
|
||||
}
|
||||
r.title = n.to_string();
|
||||
}
|
||||
if let Some(pw) = str_param(p, "password") {
|
||||
r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
|
||||
}
|
||||
if p.get("range").is_some() {
|
||||
r.ranges = target_ranges(s, p)?;
|
||||
}
|
||||
edit(s, |cx| {
|
||||
if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
|
||||
*x = r.clone();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"delete" => {
|
||||
let i = find(title)?;
|
||||
edit(s, |cx| {
|
||||
let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
|
||||
if i < list.len() {
|
||||
list.remove(i);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
Ok(Json::Array(
|
||||
sh.edit_ranges
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
|
||||
json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cur = s.doc()?.wb.protected_structure;
|
||||
let on = bool_param(p, "on").unwrap_or(!cur);
|
||||
|
|
@ -97,3 +220,61 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
|
|||
}
|
||||
Ok(Json::Array(issues))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn protected_book() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
|
||||
let mut s = protected_book();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
|
||||
let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
|
||||
assert!(err.contains("Budget"), "{err}");
|
||||
// A paste or fill is checked the same way.
|
||||
assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
|
||||
let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
|
||||
assert_eq!(list[1]["unlocked"], true);
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
|
||||
// Ranges can't change while the sheet is protected.
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_ranges_add_modify_delete_list_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
// Empty params list; nothing to list yet.
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
|
||||
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
|
||||
let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
|
||||
assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
|
||||
let list = s
|
||||
.execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
|
||||
.unwrap();
|
||||
assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
|
||||
// Inserting a row above moves the range with its cells.
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
|
||||
s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
|
||||
assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
|||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
}
|
||||
for er in sh.edit_ranges.iter_mut() {
|
||||
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
}
|
||||
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect();
|
||||
|
|
@ -538,6 +542,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
{
|
||||
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
|
|
@ -693,6 +703,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
|||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
for er in sh.edit_ranges.iter_mut() {
|
||||
er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.edit_ranges.retain(|er| !er.ranges.is_empty());
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
|
|||
|
||||
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
|
||||
/// are trimmed to the used range.
|
||||
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
|
||||
|
|
|
|||
|
|
@ -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,11 +8,13 @@
|
|||
|
||||
pub mod catalog;
|
||||
pub mod cf;
|
||||
mod clip_html;
|
||||
pub mod cmd;
|
||||
pub mod display;
|
||||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
|
|
@ -70,6 +72,9 @@ pub struct Clipboard {
|
|||
pub doc_uid: u64,
|
||||
/// Plain text (tab-separated) for the system clipboard.
|
||||
pub text: String,
|
||||
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
|
||||
/// together into one block at `range`. Empty for a single-range copy.
|
||||
pub areas: Vec<RangeRef>,
|
||||
}
|
||||
|
||||
/// In-cell edit mode as shown in the status bar.
|
||||
|
|
@ -96,6 +101,8 @@ pub struct DocState {
|
|||
pub title: String,
|
||||
/// Per-sheet selection memory.
|
||||
pub sheet_selections: Vec<Selection>,
|
||||
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
|
||||
pub unlocked_ranges: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -119,6 +126,7 @@ impl DocState {
|
|||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
|
@ -150,6 +158,8 @@ pub struct Prefs {
|
|||
pub r1c1: bool,
|
||||
/// Words added with Add to Dictionary.
|
||||
pub user_dictionary: Vec<String>,
|
||||
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
||||
pub formula_bar: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -163,6 +173,7 @@ impl Default for Prefs {
|
|||
sheets_in_new_workbook: 1,
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +195,7 @@ pub struct Session {
|
|||
pub clipboard: Option<Clipboard>,
|
||||
pub prefs: Prefs,
|
||||
pub mode: Mode,
|
||||
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
|
||||
pub journal: Vec<(String, Json)>,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
|
|
@ -284,7 +296,7 @@ impl Session {
|
|||
}
|
||||
};
|
||||
if spec.journal && result.is_ok() {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.journal.push((id.to_string(), cmd::journal_params(¶ms)));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..5_000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial;
|
|||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
/// Largest source block read (cells).
|
||||
pub const MAX_SOURCE_CELLS: u64 = 10_000_000;
|
||||
/// Largest report written (cells).
|
||||
|
|
@ -503,6 +505,12 @@ pub struct Output {
|
|||
/// Rows above the body (report filters plus a blank row).
|
||||
pub body_offset: u32,
|
||||
pub width: u32,
|
||||
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
|
||||
pub chart_rows: Vec<u32>,
|
||||
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
|
||||
pub chart_cols: Vec<(u32, String)>,
|
||||
/// Grid column holding the innermost row labels (`None` without row fields).
|
||||
pub label_col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
|
|
@ -546,6 +554,8 @@ enum LineKind {
|
|||
|
||||
struct Line {
|
||||
kind: LineKind,
|
||||
/// A category of a PivotChart: an innermost (or collapsed) item.
|
||||
leaf: bool,
|
||||
path: Vec<u32>,
|
||||
values: bool,
|
||||
/// (column, value, indent, number format)
|
||||
|
|
@ -587,14 +597,21 @@ impl Ctx<'_> {
|
|||
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(depth + 1 < n),
|
||||
leaf: true,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
});
|
||||
} else {
|
||||
let top = self.pt.subtotals && self.pt.subtotals_top;
|
||||
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
self.flatten(&node.children, depth + 1, prefix, out);
|
||||
if self.pt.subtotals && !self.pt.subtotals_top {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
|
||||
|
|
@ -621,12 +638,13 @@ impl Ctx<'_> {
|
|||
labels.push((depth, v, 0, fmt));
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
|
||||
} else {
|
||||
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
|
||||
if self.pt.subtotals {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
|
||||
|
|
@ -666,13 +684,44 @@ fn flatten_cols(
|
|||
|
||||
type AggMap = HashMap<(Vec<u32>, Vec<u32>), Vec<Acc>>;
|
||||
|
||||
/// Where a value field's numbers come from.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValSrc {
|
||||
/// A source column.
|
||||
Col(usize),
|
||||
/// A calculated field (index into the PivotTable's calculated fields).
|
||||
Calc(usize),
|
||||
}
|
||||
|
||||
/// Computes a PivotTable report from the workbook.
|
||||
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
|
||||
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect();
|
||||
// Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`).
|
||||
let calc = CalcSet::new(pt, &src.headers);
|
||||
let vals: Vec<(ValSrc, &PivotValue)> = pt
|
||||
.values
|
||||
.iter()
|
||||
.filter_map(|v| match src.col(&v.source_col) {
|
||||
Some(c) => Some((ValSrc::Col(c), v)),
|
||||
None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)),
|
||||
})
|
||||
.collect();
|
||||
// Source columns summed for the calculated fields, accumulated after the value fields.
|
||||
let mut extra: Vec<usize> = Vec::new();
|
||||
for (vs, _) in &vals {
|
||||
if let ValSrc::Calc(i) = vs {
|
||||
for name in calc.source_refs(*i) {
|
||||
if let Some(c) = src.col(&name)
|
||||
&& !extra.contains(&c)
|
||||
{
|
||||
extra.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
|
||||
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
|
||||
}
|
||||
|
|
@ -728,8 +777,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
}
|
||||
for a in 0..=rp.len() {
|
||||
for b in 0..=cp.len() {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k]);
|
||||
for (acc, (c, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]);
|
||||
for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
if let ValSrc::Col(c) = vs {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
|
|
@ -755,7 +809,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if rows.is_empty() {
|
||||
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
|
||||
} else {
|
||||
if pt.layout == PivotLayout::Tabular {
|
||||
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
|
||||
|
|
@ -763,7 +817,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
|
||||
}
|
||||
if pt.grand_totals_cols {
|
||||
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
|
||||
lines.push(Line {
|
||||
kind: LineKind::Grand,
|
||||
leaf: false,
|
||||
path: vec![],
|
||||
values: true,
|
||||
labels: vec![(0, Value::text("Grand Total"), 0, None)],
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut specs: Vec<ColSpec> = Vec::new();
|
||||
|
|
@ -829,7 +889,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
if empty {
|
||||
let r = g.push_row(RowKind::Placeholder)?;
|
||||
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
|
||||
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
|
||||
return Ok(Output {
|
||||
width: g.width as u32,
|
||||
grid: g.rows,
|
||||
row_kinds: g.kinds,
|
||||
body_offset,
|
||||
chart_rows: vec![],
|
||||
chart_cols: vec![],
|
||||
label_col: None,
|
||||
});
|
||||
}
|
||||
|
||||
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
|
||||
|
|
@ -915,8 +983,21 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
|
||||
// ---- body
|
||||
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
|
||||
let (_, v) = vals.get(vi)?;
|
||||
aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func))
|
||||
let (vs, v) = vals.get(vi)?;
|
||||
let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?;
|
||||
match vs {
|
||||
ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)),
|
||||
ValSrc::Calc(i) => {
|
||||
let sum = |name: &str| -> Value {
|
||||
src.col(name)
|
||||
.and_then(|c| extra.iter().position(|x| *x == c))
|
||||
.and_then(|j| accs.get(k + j))
|
||||
.map(|a| a.result(PivotFunc::Sum))
|
||||
.unwrap_or(Value::Number(0.0))
|
||||
};
|
||||
Some(calc.value(wb, *i, &sum))
|
||||
}
|
||||
}
|
||||
};
|
||||
let num = |v: &Option<Value>| v.as_ref().and_then(Value::as_f64);
|
||||
let ratio = |a: Value, b: Option<Value>| -> Value {
|
||||
|
|
@ -935,7 +1016,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
};
|
||||
let data_fmt: Vec<Option<String>> = vals
|
||||
.iter()
|
||||
.map(|(c, v)| match v.show_as {
|
||||
.map(|(vs, v)| match v.show_as {
|
||||
PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => {
|
||||
Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into()))
|
||||
}
|
||||
|
|
@ -943,12 +1024,42 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
_ => v.number_format.clone().or_else(|| match v.func {
|
||||
PivotFunc::Count | PivotFunc::CountNumbers => None,
|
||||
_ => {
|
||||
let f = fmt_of(*c);
|
||||
let f = match vs {
|
||||
ValSrc::Col(c) => fmt_of(*c),
|
||||
ValSrc::Calc(_) => "General".to_string(),
|
||||
};
|
||||
(f != "General").then_some(f)
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut chart_rows: Vec<u32> = Vec::new();
|
||||
let chart_cols: Vec<(u32, String)> = data_cols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, (si, vi))| {
|
||||
let spec = specs.get(*si)?;
|
||||
if spec.kind != ColKind::Leaf {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<String> =
|
||||
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
|
||||
if let Some(vi) = vi
|
||||
&& (k > 1 || parts.is_empty())
|
||||
{
|
||||
parts.push(val_name(*vi));
|
||||
}
|
||||
Some(((l + j) as u32, parts.join(" - ")))
|
||||
})
|
||||
.filter(|_| k > 0)
|
||||
.collect();
|
||||
let label_col = if rows.is_empty() {
|
||||
None
|
||||
} else if compact {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(rows.len().saturating_sub(1) as u32)
|
||||
};
|
||||
for line in &lines {
|
||||
let rk = match line.kind {
|
||||
LineKind::Item(b) => RowKind::Item(b),
|
||||
|
|
@ -961,6 +1072,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
|
||||
};
|
||||
let r = g.push_row(rk)?;
|
||||
if line.leaf && k > 0 {
|
||||
chart_rows.push(r as u32);
|
||||
}
|
||||
for (c, v, indent, fmt) in &line.labels {
|
||||
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
|
||||
}
|
||||
|
|
@ -1015,7 +1129,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- styling and writing
|
||||
|
|
@ -1205,9 +1319,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
|||
if let Some(p) = shm.pivots.get_mut(idx) {
|
||||
p.last_range = Some(area);
|
||||
}
|
||||
let sheet_name = shm.name.clone();
|
||||
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotCharts
|
||||
|
||||
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
|
||||
pub fn chart_link(sheet: &str, pivot: &str) -> String {
|
||||
format!("{}!{pivot}", quote_sheet(sheet))
|
||||
}
|
||||
|
||||
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
|
||||
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
let t = link.trim();
|
||||
let t = match t.strip_prefix('[') {
|
||||
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
|
||||
None => t,
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((sheet, name.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a PivotChart link points at `pivot` on `sheet`.
|
||||
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
|
||||
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
|
||||
}
|
||||
|
||||
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
|
||||
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
|
||||
let q = quote_sheet(sheet);
|
||||
let c = origin.col.checked_add(col)?;
|
||||
let letters = gridcraft_core::col_to_letters(c);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&start) = rows.get(i) {
|
||||
let mut end = start;
|
||||
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
|
||||
end += 1;
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
|
||||
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
|
||||
}
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.pop(),
|
||||
_ => Some(format!("({})", parts.join(","))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most category rows a PivotChart plots.
|
||||
const MAX_CHART_ROWS: usize = 10_000;
|
||||
|
||||
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
|
||||
/// items, one series per item column (column items × value fields); totals are left out.
|
||||
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
|
||||
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
|
||||
out.chart_cols
|
||||
.iter()
|
||||
.filter_map(|(c, name)| {
|
||||
Some(Series {
|
||||
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
|
||||
categories: categories.clone(),
|
||||
values: rows_ref(sheet, origin, *c, &rows)?,
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
|
||||
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
|
||||
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
|
||||
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let series = chart_series(sheet, origin, out);
|
||||
for i in hits {
|
||||
let Some(sh) = wb.sheet_mut(i) else { continue };
|
||||
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
|
||||
ch.series = series.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a pivot's last output area.
|
||||
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`,
|
||||
//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`).
|
||||
//!
|
||||
//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names
|
||||
//! for each report cell (items, subtotals and grand totals alike), not record by record: for
|
||||
//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written
|
||||
//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not
|
||||
//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{PivotTable, Workbook};
|
||||
|
||||
/// Longest formula accepted.
|
||||
pub const MAX_FORMULA_LEN: usize = 8_192;
|
||||
/// Most calculated fields per PivotTable.
|
||||
pub const MAX_CALC_FIELDS: usize = 256;
|
||||
/// Deepest chain of calculated fields using calculated fields.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const PREFIX: &str = "__pcf";
|
||||
|
||||
fn placeholder(i: usize) -> String {
|
||||
format!("{PREFIX}{i}")
|
||||
}
|
||||
|
||||
/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the
|
||||
/// order of `refs`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Compiled {
|
||||
pub body: Expr,
|
||||
/// Canonical names of the fields used.
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a
|
||||
/// source or calculated field.
|
||||
pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<Compiled, String> {
|
||||
let text = formula.trim();
|
||||
let text = text.strip_prefix('=').unwrap_or(text).trim();
|
||||
if text.is_empty() {
|
||||
return Err("The formula is empty.".into());
|
||||
}
|
||||
if text.len() > MAX_FORMULA_LEN {
|
||||
return Err("The formula is too long.".into());
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let at = |i: usize| chars.get(i).copied();
|
||||
let mut out = String::with_capacity(text.len() + 16);
|
||||
let mut refs: Vec<String> = Vec::new();
|
||||
let slot = |name: String, refs: &mut Vec<String>| -> String {
|
||||
let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| {
|
||||
refs.push(name);
|
||||
refs.len() - 1
|
||||
});
|
||||
placeholder(i)
|
||||
};
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
match c {
|
||||
'"' => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated text value.".into()),
|
||||
Some('"') if at(i + 1) == Some('"') => {
|
||||
out.push_str("\"\"");
|
||||
i += 2;
|
||||
}
|
||||
Some('"') => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
out.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
let mut name = String::new();
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated field name.".into()),
|
||||
Some('\'') if at(i + 1) == Some('\'') => {
|
||||
name.push('\'');
|
||||
i += 2;
|
||||
}
|
||||
Some('\'') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
name.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?;
|
||||
out.push_str(&slot(canon, &mut refs));
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
if matches!(at(i), Some('e' | 'E')) {
|
||||
let sign = usize::from(matches!(at(i + 1), Some('+' | '-')));
|
||||
if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) {
|
||||
for _ in 0..=sign {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c if c.is_alphabetic() || c == '_' => {
|
||||
let start = i;
|
||||
while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default();
|
||||
let mut j = i;
|
||||
while at(j).is_some_and(char::is_whitespace) {
|
||||
j += 1;
|
||||
}
|
||||
let is_call = at(j) == Some('(');
|
||||
match field(&word) {
|
||||
Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)),
|
||||
_ => out.push_str(&word),
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?;
|
||||
let mut problem: Option<String> = None;
|
||||
body.walk(&mut |e| {
|
||||
if problem.is_some() {
|
||||
return;
|
||||
}
|
||||
match e {
|
||||
Expr::Name(n) if n.starts_with(PREFIX) => {}
|
||||
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
|
||||
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
match problem {
|
||||
Some(p) => Err(p),
|
||||
None => Ok(Compiled { body, refs }),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(v: &Value) -> Expr {
|
||||
match v {
|
||||
Value::Number(n) => Expr::Number(*n),
|
||||
Value::Error(e) => Expr::Error(*e),
|
||||
Value::Text(t) => Expr::Text(t.clone()),
|
||||
Value::Bool(b) => Expr::Bool(*b),
|
||||
Value::Empty => Expr::Number(0.0),
|
||||
Value::Array(_) => literal(&v.scalar()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates a compiled formula with the given field values (in `refs` order).
|
||||
pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value {
|
||||
let expr = if c.refs.is_empty() {
|
||||
c.body.clone()
|
||||
} else {
|
||||
let mut call = Vec::with_capacity(c.refs.len() * 2 + 1);
|
||||
for i in 0..c.refs.len() {
|
||||
call.push(Expr::Name(placeholder(i)));
|
||||
call.push(literal(args.get(i).unwrap_or(&Value::Empty)));
|
||||
}
|
||||
call.push(c.body.clone());
|
||||
Expr::Call("LET".into(), call)
|
||||
};
|
||||
match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() {
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PivotTable's calculated fields, compiled against its source headers.
|
||||
pub struct CalcSet {
|
||||
names: Vec<String>,
|
||||
compiled: Vec<Result<Compiled, String>>,
|
||||
}
|
||||
|
||||
impl CalcSet {
|
||||
pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet {
|
||||
let names: Vec<String> = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect();
|
||||
let lookup = |n: &str| -> Option<String> {
|
||||
let n = n.trim();
|
||||
headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned()
|
||||
};
|
||||
let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect();
|
||||
CalcSet { names, compiled }
|
||||
}
|
||||
|
||||
pub fn index(&self, name: &str) -> Option<usize> {
|
||||
self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The compile error of a calculated field, if any.
|
||||
pub fn error(&self, i: usize) -> Option<&str> {
|
||||
self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Source fields a calculated field needs, directly or through other calculated fields.
|
||||
pub fn source_refs(&self, i: usize) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack = vec![i];
|
||||
while let Some(j) = stack.pop() {
|
||||
if !seen.get(j).copied().unwrap_or(true) {
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
for r in &c.refs {
|
||||
match self.index(r) {
|
||||
Some(k) => stack.push(k),
|
||||
None => {
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) {
|
||||
out.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a calculated field reaches itself through the fields it uses.
|
||||
pub fn is_cyclic(&self, i: usize) -> bool {
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack: Vec<usize> = match self.compiled.get(i) {
|
||||
Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
while let Some(j) = stack.pop() {
|
||||
if j == i {
|
||||
return true;
|
||||
}
|
||||
if seen.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
stack.extend(c.refs.iter().filter_map(|r| self.index(r)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The value of calculated field `i` for one report cell; `sum` gives a source field's sum
|
||||
/// over that cell's records.
|
||||
pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value {
|
||||
let mut memo: Vec<Option<Value>> = vec![None; self.names.len()];
|
||||
let mut active = vec![false; self.names.len()];
|
||||
self.value_in(wb, i, sum, &mut memo, &mut active, 0)
|
||||
}
|
||||
|
||||
fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option<Value>], active: &mut [bool], depth: usize) -> Value {
|
||||
if let Some(Some(v)) = memo.get(i) {
|
||||
return v.clone();
|
||||
}
|
||||
if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) {
|
||||
return Value::Error(CellError::Ref);
|
||||
}
|
||||
let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) };
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = true;
|
||||
}
|
||||
let args: Vec<Value> = c
|
||||
.refs
|
||||
.iter()
|
||||
.map(|r| match self.index(r) {
|
||||
Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1),
|
||||
None => sum(r),
|
||||
})
|
||||
.collect();
|
||||
let v = eval(wb, c, &args);
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = false;
|
||||
}
|
||||
if let Some(m) = memo.get_mut(i) {
|
||||
*m = Some(v.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fields(n: &str) -> Option<String> {
|
||||
["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiles_field_names() {
|
||||
let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]);
|
||||
// A field named like a cell reference is still a field; functions stay functions.
|
||||
let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Q1"]);
|
||||
let wb = Workbook::new();
|
||||
assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0));
|
||||
assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus"));
|
||||
assert!(compile("=A1*2", &fields).is_err());
|
||||
assert!(compile("='Nope'*2", &fields).is_err());
|
||||
assert!(compile("=Sales*(", &fields).is_err());
|
||||
assert!(compile("=", &fields).is_err());
|
||||
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_and_cyclic_chains_are_bounded() {
|
||||
// 300 calculated fields, each using the previous one, and a two-field cycle.
|
||||
let mut pt = PivotTable::default();
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() });
|
||||
for i in 1..300 {
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) });
|
||||
}
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&pt, &["Sales".to_string()]);
|
||||
let wb = Workbook::new();
|
||||
let sum = |_: &str| Value::Number(10.0);
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0));
|
||||
assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0));
|
||||
// Past the depth bound (the list itself is capped at MAX_CALC_FIELDS).
|
||||
assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields");
|
||||
// A small cycle within the cap.
|
||||
let mut small = PivotTable::default();
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&small, &["Sales".to_string()]);
|
||||
assert!(set.is_cyclic(0));
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.source_refs(0), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,110 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
|
||||
// #257: "Rp10,000.00" copied from Excel for the web / LibreOffice Calc pasted as text. Their
|
||||
// clipboard HTML records the number (x:num, sdval); paste keeps it and shows the same text.
|
||||
let mut s = s();
|
||||
let text = "Rp10,000.00\tRp2,500.50\tLabel\n";
|
||||
let html =
|
||||
r#"<table><tr><td x:num="10000">Rp10,000.00</td><td sdval="2500.5" sdnum="1033;0;x"><font>Rp2,500.50</font></td><td>Label</td></tr></table>"#;
|
||||
s.execute("edit.paste", json!({"at": "A1", "text": text, "html": html})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::number(10000.0));
|
||||
assert_eq!(v(&s, "B1"), Value::number(2500.5));
|
||||
assert_eq!(v(&s, "C1"), Value::text("Label"));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "Rp10,000.00");
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "Rp2,500.50");
|
||||
|
||||
// HTML that doesn't show the same text (left over from another copy) changes nothing; without
|
||||
// HTML the text stays text, as typing it would in an en-US locale.
|
||||
let stale = r#"<table><tr><td x:num="7">Rp7.00</td></tr></table>"#;
|
||||
s.execute("edit.paste", json!({"at": "A3", "text": "Rp10,000.00", "html": stale})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A4", "text": "Rp10,000.00"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::text("Rp10,000.00"));
|
||||
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_name_renames_it_in_formulas() {
|
||||
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=Price*2"})).unwrap();
|
||||
let err = s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=Sheet1!$A$1", "oldName": "Price"}));
|
||||
assert!(err.is_err(), "renaming onto another existing name is refused");
|
||||
s.execute("formulas.defineName", json!({"name": "Cost", "refersTo": "=Sheet1!$A$1", "comment": "unit", "oldName": "Price"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let names: Vec<&str> = d.wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["Other", "Cost"]);
|
||||
let b1 = d.wb.active().unwrap().cell(CellRef::parse("B1").unwrap()).and_then(|c| c.formula.clone()).unwrap();
|
||||
assert_eq!(b1.text, "Cost*2");
|
||||
assert_eq!(v(&s, "B1"), Value::number(42.0));
|
||||
assert!(s.execute("formulas.defineName", json!({"name": "X", "oldName": "Missing"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_recommendations_follow_the_data_shape() {
|
||||
// #215: Recommended Charts lists chart types for the data; programmatic insert stays dialog-free.
|
||||
let mut s = s();
|
||||
let kinds = |s: &mut Session, range: &str| -> Vec<String> {
|
||||
let r = s.execute("insert.chartRecommendations", json!({"range": range})).unwrap();
|
||||
r.as_array().unwrap().iter().map(|x| format!("{}{}", x["type"].as_str().unwrap(), x["subtype"].as_str().unwrap())).collect()
|
||||
};
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
|
||||
assert_eq!(kinds(&mut s, "A1:B4"), ["column", "bar", "pie", "doughnut", "linemarkers"]);
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2], [2, 4.5], [3, 5.5], [4, 9]]})).unwrap();
|
||||
assert_eq!(kinds(&mut s, "D1:E4").first().map(String::as_str), Some("scatter"), "two numeric columns: scatter first");
|
||||
s.execute("range.setValues", json!({"range": "G1", "values": [["Q", "North", "South"], ["Q1", 1, -2], ["Q2", 3, 4]]})).unwrap();
|
||||
let multi = kinds(&mut s, "G1:I3");
|
||||
assert_eq!(multi.first().map(String::as_str), Some("column"));
|
||||
assert!(!multi.iter().any(|k| k == "pie"), "several series: no pie");
|
||||
assert!(s.execute("insert.chartRecommendations", json!({"range": "K1:K3"})).is_err(), "no numbers, no chart");
|
||||
|
||||
// Without a type the command inserts the first recommendation; with one, that type.
|
||||
s.execute("insert.recommendedCharts", json!({"range": "D1:E4"})).unwrap();
|
||||
s.execute("insert.recommendedCharts", json!({"range": "A1:B4", "type": "pie"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let kinds: Vec<_> = d.wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [gridcraft_model::ChartKind::Scatter, gridcraft_model::ChartKind::Pie]);
|
||||
assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_category_axis_from_blank_corner_and_select_data() {
|
||||
// #287: numeric X values must be the category axis, not a series of their own.
|
||||
let mut s = s();
|
||||
let series = |s: &Session| -> Vec<(Option<String>, String)> {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.active().unwrap().charts.last().unwrap().series.iter().map(|x| (x.categories.clone(), x.values.clone())).collect()
|
||||
};
|
||||
// A blank top-left cell marks the first column as category labels, numbers or not.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[null, "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap();
|
||||
assert_eq!(series(&s), [(Some("Sheet1!$A$2:$A$4".to_string()), "Sheet1!$B$2:$B$4".to_string())]);
|
||||
|
||||
// With a "Year" header the year column is a series, as before; Select Data fixes it:
|
||||
// Horizontal (Category) Axis Labels and Remove on the year series.
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "D1:E4", "type": "line"})).unwrap();
|
||||
assert_eq!(series(&s).len(), 2);
|
||||
assert!(s.execute("chart.selectData", json!({})).is_err(), "nothing to change");
|
||||
assert!(s.execute("chart.selectData", json!({"categories": "not a range"})).is_err());
|
||||
s.execute("chart.selectData", json!({"categories": "=D2:D4", "removeSeries": [0, 7]})).unwrap();
|
||||
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
|
||||
assert_eq!(data.categories, ["2020", "2021", "2022"]);
|
||||
// The last series can't be removed; "" clears the labels.
|
||||
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
|
||||
assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
|
||||
let mut s = s();
|
||||
|
|
@ -452,6 +556,39 @@ fn fill_series() {
|
|||
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_drag_fill_swaps_copy_and_series() {
|
||||
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4"})).unwrap();
|
||||
assert_eq!(v(&s, "A4"), Value::Number(7.0), "a plain drag copies a lone number");
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Number(8.0));
|
||||
assert_eq!(v(&s, "A4"), Value::Number(10.0));
|
||||
// Upward too.
|
||||
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B5", "target": "B3:B5", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "B3"), Value::Number(5.0));
|
||||
// A two-number series, a list entry and text with a number are copied.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [[1], [3]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "C3"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "C4"), Value::Number(3.0));
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "Mon"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "D1", "target": "D1:D3", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "D3"), Value::from("Mon"));
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "Item 9"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "E1", "target": "E1:E2", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "E2"), Value::from("Item 9"));
|
||||
// Plain text copies either way; an empty source clears.
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "abc"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "F2"), Value::from("abc"));
|
||||
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G3", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "G3"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
|
|
@ -512,6 +649,63 @@ fn sort_and_filter() {
|
|||
assert_eq!(r["hiddenRows"], 1);
|
||||
}
|
||||
|
||||
/// Turning the filter on with only the header row selected filters the data below it (#201).
|
||||
#[test]
|
||||
fn filter_on_a_selected_header_row_covers_the_data_below() {
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
let r = s.execute("data.filter", json!({})).unwrap();
|
||||
assert_eq!(r["range"], "A1:B6");
|
||||
let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], 3);
|
||||
// An explicit range is taken as given.
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap();
|
||||
assert_eq!(r["range"], "A1:B1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
|
||||
const PW: &str = "synthetic-pw-7319";
|
||||
let mut s = s();
|
||||
s.execute("automate.recordActions", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap();
|
||||
// The password still protects the sheet; the refused attempt isn't journaled.
|
||||
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
|
||||
s.execute("review.unprotectSheet", json!({"password": PW})).unwrap();
|
||||
s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap();
|
||||
|
||||
let journal = serde_json::to_string(&s.journal).unwrap();
|
||||
assert!(!journal.contains(PW), "{journal}");
|
||||
let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone());
|
||||
assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true})));
|
||||
assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({})));
|
||||
|
||||
// The recorded script is stored in the workbook, so it is saved with the file.
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap();
|
||||
assert!(script.contains("review.protectSheet"), "{script}");
|
||||
assert!(!script.contains(PW), "{script}");
|
||||
|
||||
// The journal still replays: protection without a password, then unprotection.
|
||||
let mut r = self::s();
|
||||
for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) {
|
||||
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
|
||||
if id == "review.protectSheet" {
|
||||
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
|
||||
}
|
||||
}
|
||||
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
|
||||
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
|
||||
}
|
||||
|
||||
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
|
||||
fn protected(extra: serde_json::Value) -> Session {
|
||||
let mut s = s();
|
||||
|
|
@ -606,6 +800,27 @@ fn hidden_formulas_stay_hidden_on_protected_sheets() {
|
|||
assert_eq!(r["results"][0]["value"], "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_sensitive_sort_puts_lowercase_first() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["b"], ["B"], ["Ab"], ["a"], ["aB"], ["A"]]})).unwrap();
|
||||
s.execute("data.sort", json!({"range": "A1:A6", "header": false, "matchCase": true, "keys": [{"column": "A"}]})).unwrap();
|
||||
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
|
||||
assert_eq!(col, ["a", "A", "aB", "Ab", "b", "B"].map(Value::from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_compares_shown_text() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Price"], ["2006-03-08", 1.01], ["2006-03-08", 1.02]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "mmm d, yyyy"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0"})).unwrap();
|
||||
let r = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "header": true})).unwrap();
|
||||
assert_eq!(r["removed"], 0);
|
||||
let r = s.execute("data.removeDuplicates", json!({"range": "B1:B3", "header": true})).unwrap();
|
||||
assert_eq!(r["removed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_and_styles() {
|
||||
let mut s = s();
|
||||
|
|
@ -1036,6 +1251,74 @@ fn every_command_survives_empty_params() {
|
|||
}
|
||||
}
|
||||
|
||||
fn fmt_of(s: &Session, a: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let sh = wb.active().unwrap();
|
||||
wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkboxes_keep_values_toggle_and_delete_in_two_passes() {
|
||||
// Issue #196.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "hi"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:B2"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(false), "an empty cell starts unchecked");
|
||||
assert_eq!(v(&s, "B1"), Value::from("hi"), "other values stay");
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(true));
|
||||
// Space over a selection toggles only checkbox cells; an emptied checkbox counts as FALSE.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": ""})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:C2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Bool(true));
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Bool(true), "a plain TRUE is not a checkbox");
|
||||
// Delete: checked boxes are unchecked first, then the boxes go.
|
||||
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Bool(false), Value::Bool(false)));
|
||||
assert_eq!(fmt_of(&s, "A1"), "checkbox");
|
||||
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Empty);
|
||||
assert_ne!(fmt_of(&s, "A1"), "checkbox");
|
||||
// Clear Formats removes the box and keeps the value (B2 was checked by the selection toggle).
|
||||
s.execute("edit.clearFormats", json!({"range": "B2"})).unwrap();
|
||||
assert_eq!(v(&s, "B2"), Value::Bool(true));
|
||||
assert_ne!(fmt_of(&s, "B2"), "checkbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkboxes_survive_xlsx_as_cell_controls() {
|
||||
let mut s = s();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "0.5"})).unwrap();
|
||||
reopen(&mut s);
|
||||
assert_eq!(fmt_of(&s, "A1"), "checkbox");
|
||||
assert_eq!(fmt_of(&s, "A2"), "checkbox");
|
||||
assert_eq!(fmt_of(&s, "B1"), "General");
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(true));
|
||||
assert_eq!(v(&s, "A2"), Value::Bool(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_options_reports_settings_and_asks_for_the_options_page() {
|
||||
let mut s = Session::new();
|
||||
// No workbook: nothing to report, but still no error.
|
||||
assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null}));
|
||||
s.take_ui_requests();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap();
|
||||
let before = s.active().unwrap().wb.clone();
|
||||
let r = s.execute("file.options", json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["mode"], "manual");
|
||||
assert_eq!(r["calculation"]["iterative"], true);
|
||||
assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options"));
|
||||
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
|
|
@ -1633,3 +1916,117 @@ fn notes_comments_and_links_survive_a_json_save() {
|
|||
assert_eq!(after.hyperlinks, before.hyperlinks);
|
||||
assert_eq!(after.cells, before.cells);
|
||||
}
|
||||
|
||||
/// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the
|
||||
/// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235).
|
||||
#[test]
|
||||
fn icon_set_thresholds_from_files_pick_the_icon() {
|
||||
use gridcraft_model::{CfRule, CfValueKind, CondFormat};
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[0], [10], [20], [30], [100]]})).unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();
|
||||
let icon = |s: &Session, a: &str| {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
crate::cf::CfCache::new().look(wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.icon).map(|(_, i)| i)
|
||||
};
|
||||
// Defaults: percent of the 0..100 span, so 30 is still the lowest icon.
|
||||
assert_eq!([icon(&s, "A1"), icon(&s, "A4"), icon(&s, "A5")], [Some(0), Some(0), Some(2)]);
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.cond_formats = vec![CondFormat {
|
||||
ranges: vec![gridcraft_core::RangeRef::parse("A1:A5").unwrap()],
|
||||
rule: CfRule::IconSet {
|
||||
set: "3Arrows".into(),
|
||||
thresholds: vec![CfValueKind::Percent(0.0), CfValueKind::Number(10.0), CfValueKind::Number(25.0)],
|
||||
reverse: false,
|
||||
show_value: true,
|
||||
},
|
||||
priority: 1,
|
||||
stop_if_true: false,
|
||||
}];
|
||||
assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_height_and_column_width_take_a_list_of_areas() {
|
||||
// Issue #341: header drags on a multi-area selection size every selected line in one step.
|
||||
let mut s = s();
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap();
|
||||
let widths: Vec<f32> = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect();
|
||||
let heights: Vec<f32> = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect();
|
||||
for c in [1, 3, 4] {
|
||||
assert_eq!(widths[c], 120.0, "column {c}");
|
||||
}
|
||||
for c in [0, 2, 5] {
|
||||
assert_ne!(widths[c], 120.0, "column {c}");
|
||||
}
|
||||
for r in [1, 3, 4] {
|
||||
assert_eq!(heights[r], 40.0, "row {r}");
|
||||
}
|
||||
for r in [0, 2, 5] {
|
||||
assert_ne!(heights[r], 40.0, "row {r}");
|
||||
}
|
||||
assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err());
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() {
|
||||
// Issue #348: Excel slides lined-up areas together; formulas travel as their values.
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap();
|
||||
|
||||
// Same rows, selected right to left: pasted left to right, B left out.
|
||||
s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(copied["text"], "a1\tc1\n1\t3\n");
|
||||
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
|
||||
assert_eq!(v(&s, "F1"), Value::text("a1"));
|
||||
assert_eq!(v(&s, "G1"), Value::text("c1"));
|
||||
assert_eq!(v(&s, "F2"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "G2"), Value::Number(3.0));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none());
|
||||
assert!(v(&s, "H1").is_empty());
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2");
|
||||
// The source is untouched.
|
||||
assert_eq!(v(&s, "B1"), Value::text("b1"));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some());
|
||||
|
||||
// Same columns: stacked, row 2 left out.
|
||||
s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n");
|
||||
s.execute("edit.paste", json!({"at": "F5"})).unwrap();
|
||||
for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] {
|
||||
assert_eq!(v(&s, cell), Value::text(want), "{cell}");
|
||||
}
|
||||
assert!(v(&s, "F8").is_empty());
|
||||
|
||||
// Whole columns are trimmed to the used rows like a single-area copy.
|
||||
s.execute("edit.undo", json!({"steps": 2})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] {
|
||||
s.execute("selection.set", json!({"range": range})).unwrap();
|
||||
let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}");
|
||||
assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays");
|
||||
}
|
||||
// Paste Link can't point at a slid-together block.
|
||||
s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
|
||||
assert!(v(&s, "E1").is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ pub(crate) mod database;
|
|||
pub(crate) mod date;
|
||||
pub(crate) mod dynamic;
|
||||
pub(crate) mod engineering;
|
||||
pub(crate) mod ets;
|
||||
pub(crate) mod financial;
|
||||
pub(crate) mod info;
|
||||
pub(crate) mod lift;
|
||||
|
|
@ -122,6 +123,8 @@ pub enum Category {
|
|||
Compatibility,
|
||||
Web,
|
||||
Cube,
|
||||
/// Excel's "Add-in and Automation" category (EUROCONVERT).
|
||||
AddIn,
|
||||
}
|
||||
|
||||
pub struct FnSpec {
|
||||
|
|
@ -168,6 +171,7 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
|||
for list in [
|
||||
math::specs(),
|
||||
stat::specs(),
|
||||
ets::specs(),
|
||||
text::specs(),
|
||||
date::specs(),
|
||||
lookup::specs(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
gridcraft-core = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ mod tests {
|
|||
threaded: true,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
},
|
||||
);
|
||||
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
|
||||
|
|
|
|||
|
|
@ -196,6 +196,18 @@ pub enum CfRule {
|
|||
},
|
||||
}
|
||||
|
||||
/// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5.
|
||||
pub fn icon_count(set: &str) -> usize {
|
||||
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize
|
||||
}
|
||||
|
||||
/// Excel's default icon-set thresholds: one per icon, the first being the lower bound,
|
||||
/// then even percent steps (`0, 33, 67` for three icons).
|
||||
pub fn default_icon_thresholds(set: &str) -> Vec<CfValueKind> {
|
||||
let k = icon_count(set);
|
||||
(0..k).map(|i| CfValueKind::Percent((i as f64 * 100.0 / k as f64).round())).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CondFormat {
|
||||
pub ranges: Vec<RangeRef>,
|
||||
|
|
@ -279,6 +291,10 @@ pub struct Comment {
|
|||
pub threaded: bool,
|
||||
pub resolved: bool,
|
||||
pub visible: bool,
|
||||
/// Threaded comments: when the thread (first entry) and each reply were written, ISO 8601 as
|
||||
/// read from the file (`dT`); empty when unknown, e.g. for comments made here.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -390,6 +406,10 @@ pub struct Chart {
|
|||
/// Source range when created from a selection (for Switch Row/Column).
|
||||
pub source: Option<String>,
|
||||
pub by_rows: bool,
|
||||
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
|
||||
/// whenever the PivotTable is refreshed or its layout changes.
|
||||
#[serde(default)]
|
||||
pub pivot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -549,6 +569,24 @@ pub struct SheetProtection {
|
|||
pub autofilter: bool,
|
||||
}
|
||||
|
||||
/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a
|
||||
/// password (XLSX `<protectedRange>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EditRange {
|
||||
pub title: String,
|
||||
pub ranges: Vec<RangeRef>,
|
||||
pub password: Option<crate::PasswordHash>,
|
||||
/// Windows permissions (`securityDescriptor`), kept as read from the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub security_descriptor: Option<String>,
|
||||
}
|
||||
|
||||
impl EditRange {
|
||||
pub fn contains(&self, c: CellRef) -> bool {
|
||||
self.ranges.iter().any(|r| r.contains(c))
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a
|
||||
/// convenience, not security).
|
||||
pub fn password_hash(pw: &str) -> u64 {
|
||||
|
|
@ -699,6 +737,18 @@ pub struct PivotTable {
|
|||
pub show_headers: bool,
|
||||
/// Area written by the last refresh.
|
||||
pub last_range: Option<RangeRef>,
|
||||
/// Calculated fields, usable in the Values area by name.
|
||||
pub calculated_fields: Vec<PivotCalcField>,
|
||||
}
|
||||
|
||||
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
|
||||
/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PivotCalcField {
|
||||
pub name: String,
|
||||
/// Formula text, starting with `=`.
|
||||
pub formula: String,
|
||||
}
|
||||
|
||||
impl Default for PivotTable {
|
||||
|
|
@ -720,6 +770,7 @@ impl Default for PivotTable {
|
|||
style: "PivotStyleLight16".into(),
|
||||
show_headers: true,
|
||||
last_range: None,
|
||||
calculated_fields: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ mod b64;
|
|||
pub mod cell;
|
||||
mod cellmap;
|
||||
pub mod features;
|
||||
pub mod password;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
|
|
@ -15,6 +16,7 @@ pub mod workbook;
|
|||
|
||||
pub use cell::{Cell, CellPicture, Formula};
|
||||
pub use features::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Excel-compatible password hashes for protection (ECMA-376 Part 1 §18.2.29 and §18.3.1.66
|
||||
//! attribute sets): the legacy 16-bit hash (`password="CC1A"`) and the iterated, salted hash
|
||||
//! (`algorithmName`, `hashValue`, `saltValue`, `spinCount`). Protection is a convenience, not
|
||||
//! security, but verification really checks the password: an unsupported variant is refused,
|
||||
//! never accepted.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
|
||||
/// Excel's default spin count for new passwords.
|
||||
pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
|
||||
/// Largest spin count accepted when verifying (the documented maximum); a file asking for more
|
||||
/// would only make verification hang.
|
||||
pub const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PasswordHash {
|
||||
/// The legacy 16-bit hash.
|
||||
Legacy(u16),
|
||||
/// An iterated, salted hash as stored in the file: algorithm name, base64 hash and salt.
|
||||
Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 },
|
||||
}
|
||||
|
||||
impl PasswordHash {
|
||||
/// A SHA-512 hash with Excel's default spin count, for a new password.
|
||||
pub fn new_sha512(password: &str, salt: &[u8]) -> PasswordHash {
|
||||
let hash = iterated::<sha2::Sha512>(password, salt, DEFAULT_SPIN_COUNT);
|
||||
PasswordHash::Iterated {
|
||||
algorithm: "SHA-512".into(),
|
||||
hash: crate::b64::encode(&hash),
|
||||
salt: crate::b64::encode(salt),
|
||||
spin_count: DEFAULT_SPIN_COUNT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `password` matches. `Err` when the hash can't be checked (unsupported algorithm,
|
||||
/// broken base64, spin count over [`MAX_SPIN_COUNT`]).
|
||||
pub fn verify(&self, password: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
PasswordHash::Legacy(h) => Ok(legacy_hash(password) == *h),
|
||||
PasswordHash::Iterated { algorithm, hash, salt, spin_count } => {
|
||||
if algorithm.is_empty() {
|
||||
return Err("the stored password hash is damaged".into());
|
||||
}
|
||||
if *spin_count > MAX_SPIN_COUNT {
|
||||
return Err(format!("the password uses a spin count of {spin_count}, more than the {MAX_SPIN_COUNT} allowed"));
|
||||
}
|
||||
let (Some(want), Some(salt)) = (crate::b64::decode(hash), crate::b64::decode(salt)) else {
|
||||
return Err("the stored password hash is damaged".into());
|
||||
};
|
||||
let got = match algorithm.to_ascii_uppercase().replace('_', "-").as_str() {
|
||||
"SHA-512" => iterated::<sha2::Sha512>(password, &salt, *spin_count),
|
||||
"SHA-384" => iterated::<sha2::Sha384>(password, &salt, *spin_count),
|
||||
"SHA-256" => iterated::<sha2::Sha256>(password, &salt, *spin_count),
|
||||
_ => return Err(format!("passwords hashed with {algorithm} aren't supported")),
|
||||
};
|
||||
Ok(got == want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16-byte salt derived from `seed` (callers mix in what varies: names, a counter, the clock).
|
||||
pub fn salt_from(seed: &[u8]) -> [u8; 16] {
|
||||
let h = sha2::Sha512::digest(seed);
|
||||
let mut out = [0u8; 16];
|
||||
for (o, b) in out.iter_mut().zip(h.iter()) {
|
||||
*o = *b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The legacy 16-bit password hash: each UTF-16 unit contributes its low byte (its high byte
|
||||
/// when the low one is zero), folded in from the last character with a 15-bit rotation, then
|
||||
/// the length and the constant `0xCE4B`.
|
||||
pub fn legacy_hash(password: &str) -> u16 {
|
||||
let bytes: Vec<u16> = password.encode_utf16().map(|u| if u & 0xFF != 0 { u & 0xFF } else { u >> 8 }).collect();
|
||||
let rotate = |v: u16| ((v >> 14) & 1) | ((v << 1) & 0x7FFF);
|
||||
let mut h: u16 = 0;
|
||||
for b in bytes.iter().rev() {
|
||||
h = rotate(h) ^ b;
|
||||
}
|
||||
h = rotate(h) ^ (bytes.len() as u16);
|
||||
h ^ 0xCE4B
|
||||
}
|
||||
|
||||
/// H0 = H(salt + UTF-16LE password); Hn = H(Hn-1 + n as 32-bit little-endian), `spin` times.
|
||||
fn iterated<D: Digest>(password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
d.update(salt);
|
||||
for u in password.encode_utf16() {
|
||||
d.update(u.to_le_bytes());
|
||||
}
|
||||
let mut h = d.finalize().to_vec();
|
||||
for i in 0..spin {
|
||||
let mut d = D::new();
|
||||
d.update(&h);
|
||||
d.update(i.to_le_bytes());
|
||||
h = d.finalize().to_vec();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_hash_matches_the_published_example() {
|
||||
// The hash Excel writes for the password "password" (`password="83AF"`).
|
||||
assert_eq!(legacy_hash("password"), 0x83AF);
|
||||
assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap());
|
||||
assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap());
|
||||
assert_eq!(legacy_hash(""), 0xCE4B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterated_hash_verifies_and_refuses_what_it_cannot_check() {
|
||||
let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef");
|
||||
assert!(h.verify("s3cret").unwrap());
|
||||
assert!(!h.verify("s3cret ").unwrap());
|
||||
assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512"));
|
||||
let PasswordHash::Iterated { hash, salt, .. } = h else { return };
|
||||
let other =
|
||||
|algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count };
|
||||
assert!(other("SHA-1", 10).verify("s3cret").is_err());
|
||||
assert!(other("MD5", 10).verify("s3cret").is_err());
|
||||
assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err());
|
||||
assert!(!other("SHA-256", 2).verify("s3cret").unwrap());
|
||||
let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 };
|
||||
assert!(broken.verify("s3cret").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,9 @@ pub struct Sheet {
|
|||
#[serde(default)]
|
||||
pub pivots: Vec<PivotTable>,
|
||||
pub protection: Option<SheetProtection>,
|
||||
/// Allow Edit Ranges (meaningful while the sheet is protected).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edit_ranges: Vec<crate::EditRange>,
|
||||
pub print: PrintSettings,
|
||||
/// Last selection and scroll position, saved with the file.
|
||||
pub view_active: CellRef,
|
||||
|
|
@ -108,6 +111,7 @@ impl Sheet {
|
|||
sparklines: vec![],
|
||||
pivots: vec![],
|
||||
protection: None,
|
||||
edit_ranges: vec![],
|
||||
print: PrintSettings::default(),
|
||||
view_active: CellRef::default(),
|
||||
view_top_left: CellRef::default(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ impl NumberFormat {
|
|||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
|
||||
pub fn parse(code: &str) -> NumberFormat {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") {
|
||||
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
|
||||
// value displays as General.
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" {
|
||||
return NumberFormat {
|
||||
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG.
|
||||
//!
|
||||
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
|
||||
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt]
|
||||
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt|cs]
|
||||
//! [--dialog 'name={json}'] out.png`
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,828 @@
|
|||
//! File tab Backstage view (#174): a full-window page that replaces the ribbon, formula bar, grid
|
||||
//! and sheet tabs until Esc or the back arrow returns to the sheet.
|
||||
//!
|
||||
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
|
||||
//! Options); Save and Close act at once. Every action runs an existing command through
|
||||
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
|
||||
//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched
|
||||
//! until the user picks one.
|
||||
|
||||
use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::i18n::{self, Language};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// Width of the navigation column.
|
||||
const NAV_W: f32 = 196.0;
|
||||
/// Width of the content column (pages are left-aligned like a document, not stretched).
|
||||
const CONTENT_W: f32 = 760.0;
|
||||
|
||||
/// A Backstage page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Home,
|
||||
New,
|
||||
Open,
|
||||
Info,
|
||||
SaveAs,
|
||||
Print,
|
||||
Export,
|
||||
Options,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options];
|
||||
|
||||
/// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Page::Home => "home",
|
||||
Page::New => "new",
|
||||
Page::Open => "open",
|
||||
Page::Info => "info",
|
||||
Page::SaveAs => "saveAs",
|
||||
Page::Print => "print",
|
||||
Page::Export => "export",
|
||||
Page::Options => "options",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<Page> {
|
||||
let id = id.trim();
|
||||
Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// English label (navigation entry and page heading); translated when drawn.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Page::Home => "Home",
|
||||
Page::New => "New",
|
||||
Page::Open => "Open",
|
||||
Page::Info => "Info",
|
||||
Page::SaveAs => "Save As",
|
||||
Page::Print => "Print",
|
||||
Page::Export => "Export",
|
||||
Page::Options => "Options",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(self) -> Icon {
|
||||
match self {
|
||||
Page::Home => Icon::Home,
|
||||
Page::New => Icon::NewFile,
|
||||
Page::Open => Icon::OpenFolder,
|
||||
Page::Info => Icon::Info,
|
||||
Page::SaveAs => Icon::Save,
|
||||
Page::Print => Icon::Print,
|
||||
Page::Export => Icon::Share,
|
||||
Page::Options => Icon::Settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document properties shown on the Info page: (`file.properties` key, label).
|
||||
const PROPERTIES: [(&str, &str); 6] = [
|
||||
("title", "Title"),
|
||||
("subject", "Subject"),
|
||||
("author", "Author"),
|
||||
("company", "Company"),
|
||||
("keywords", "Keywords"),
|
||||
("description", "Description"),
|
||||
];
|
||||
|
||||
/// The open Backstage view.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Backstage {
|
||||
pub page: Page,
|
||||
/// Info: property drafts while a field is being edited (committed when it loses focus).
|
||||
props: [String; 6],
|
||||
/// Print: the whole workbook rather than the active sheet.
|
||||
pub print_all: bool,
|
||||
/// Print: fit each sheet on one page.
|
||||
pub fit_to_page: bool,
|
||||
/// Print: the previewed page (0-based).
|
||||
preview_page: usize,
|
||||
/// Print: the preview for (document, revision, whole workbook, fit to page).
|
||||
preview: Option<((u64, u64, bool, bool), Json)>,
|
||||
}
|
||||
|
||||
impl Backstage {
|
||||
pub fn new(page: Page) -> Backstage {
|
||||
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click asked for, applied after drawing.
|
||||
#[derive(Default)]
|
||||
struct Outcome {
|
||||
close: bool,
|
||||
page: Option<Page>,
|
||||
}
|
||||
|
||||
/// Draws the Backstage view in place of the ribbon, formula bar, grid and sheet tabs.
|
||||
pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let Some(mut bs) = app.backstage.take() else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
// Esc inside a text field leaves the field; it closes the view only from outside one.
|
||||
let typing = ctx.text_edit_focused();
|
||||
let t = Tokens::get(&ctx);
|
||||
let lang = app.ui.language;
|
||||
let mut out = Outcome::default();
|
||||
let (nav_bg, nav_text, nav_sel) =
|
||||
if t.dark { (t.accent_soft, t.text, Color32::from_white_alpha(30)) } else { (t.accent, Color32::WHITE, Color32::from_black_alpha(46)) };
|
||||
egui::Panel::left("backstage_nav")
|
||||
.exact_size(NAV_W)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(nav_bg).inner_margin(egui::Margin { left: 0, right: 0, top: 10, bottom: 10 }))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
if nav_item(ui, Icon::Back, lang.tr("Back"), false, nav_text, nav_sel).clicked() {
|
||||
out.close = true;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
for page in [Page::Home, Page::New, Page::Open] {
|
||||
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(page);
|
||||
}
|
||||
}
|
||||
nav_separator(ui, nav_text);
|
||||
if nav_item(ui, Page::Info.icon(), lang.tr("Info"), bs.page == Page::Info, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(Page::Info);
|
||||
}
|
||||
if nav_item(ui, Icon::Save, lang.tr("Save"), false, nav_text, nav_sel).clicked() {
|
||||
save(app, &mut out);
|
||||
}
|
||||
for page in [Page::SaveAs, Page::Print, Page::Export] {
|
||||
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(page);
|
||||
}
|
||||
}
|
||||
if nav_item(ui, Icon::Close, lang.tr("Close"), false, nav_text, nav_sel).clicked() {
|
||||
// A cell still being edited is part of the workbook; Close keeps the unsaved prompt.
|
||||
if app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.close", json!({}));
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(Page::Options);
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon)).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 32, top: 26, bottom: 32 }).show(ui, |ui| {
|
||||
ui.set_max_width(CONTENT_W);
|
||||
ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text));
|
||||
ui.add_space(18.0);
|
||||
match bs.page {
|
||||
Page::Home => home(app, ui, &mut out),
|
||||
Page::New => new_page(app, ui, &mut out),
|
||||
Page::Open => open_page(app, ui, &mut out),
|
||||
Page::Info => info(app, ui, &mut bs),
|
||||
Page::SaveAs => save_as(app, ui, &mut out),
|
||||
Page::Print => print(app, ui, &mut bs, &mut out),
|
||||
Page::Export => export(app, ui, &mut out),
|
||||
Page::Options => options(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if !typing
|
||||
&& app.dialog.is_none()
|
||||
&& app.message.is_none()
|
||||
&& !egui::Popup::is_any_open(&ctx)
|
||||
&& ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
|
||||
{
|
||||
out.close = true;
|
||||
}
|
||||
if let Some(page) = out.page {
|
||||
bs.page = page;
|
||||
}
|
||||
// A command run from a page may itself have opened (or replaced) the view; keep that.
|
||||
if out.close {
|
||||
app.backstage = None;
|
||||
} else if app.backstage.is_none() {
|
||||
app.backstage = Some(bs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save: saves a workbook that has a file and returns to the sheet; a new workbook goes to the
|
||||
/// Save As page first, like Excel's.
|
||||
fn save(app: &mut SheetApp, out: &mut Outcome) {
|
||||
if !app.commit_edit(0, 0, false, false) {
|
||||
return;
|
||||
}
|
||||
if app.session.active().is_some_and(|d| d.path.is_some()) {
|
||||
app.run_or_alert("file.save", json!({}));
|
||||
out.close = true;
|
||||
} else {
|
||||
out.page = Some(Page::SaveAs);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- navigation
|
||||
|
||||
fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(rect, 0.0, sel);
|
||||
p.rect_filled(Rect::from_min_size(rect.min, vec2(4.0, rect.height())), 0.0, text);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(rect, 0.0, sel.gamma_multiply(0.55));
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(17.0, 17.0)), icon, text);
|
||||
p.text(pos2(rect.left() + 50.0, rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(14.0), text);
|
||||
resp
|
||||
}
|
||||
|
||||
fn nav_separator(ui: &mut Ui, text: Color32) {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(NAV_W, 17.0), Sense::hover());
|
||||
let y = rect.center().y;
|
||||
ui.painter().line_segment([pos2(rect.left() + 20.0, y), pos2(rect.right() - 20.0, y)], Stroke::new(1.0, text.gamma_multiply(0.35)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared pieces
|
||||
|
||||
fn section(ui: &mut Ui, lang: Language, title: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(lang.tr(title)).font(theme::ui_bold(15.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
fn dim(ui: &mut Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(RichText::new(text).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// A new-workbook tile: a small sheet drawn in code over its name.
|
||||
fn tile(ui: &mut Ui, label: &str, sample: Option<usize>) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
let p = ui.painter();
|
||||
let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0));
|
||||
p.rect_filled(thumb, 4.0, t.grid_bg);
|
||||
let edge = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.ribbon_border) };
|
||||
let inner = thumb.shrink(10.0);
|
||||
let (rows, cols) = (7, 4);
|
||||
let (rh, cw) = (inner.height() / rows as f32, inner.width() / cols as f32);
|
||||
if let Some(i) = sample {
|
||||
// A header band and a few "values" so each sample reads differently from a blank book.
|
||||
p.rect_filled(Rect::from_min_size(inner.min, vec2(inner.width(), rh)), 0.0, t.accent_soft);
|
||||
for r in 1..rows {
|
||||
let len = ((r * (i + 2) * 7 + i * 3) % 10) as f32 / 10.0;
|
||||
let bar = Rect::from_min_size(
|
||||
pos2(inner.left() + cw + 3.0, inner.top() + r as f32 * rh + 3.0),
|
||||
vec2((cw * 3.0 - 6.0) * (0.25 + 0.75 * len), rh - 6.0),
|
||||
);
|
||||
p.rect_filled(bar, 1.0, t.accent.gamma_multiply(0.75));
|
||||
}
|
||||
}
|
||||
for r in 0..=rows {
|
||||
let y = inner.top() + r as f32 * rh;
|
||||
p.line_segment([pos2(inner.left(), y), pos2(inner.right(), y)], Stroke::new(1.0, t.gridline));
|
||||
}
|
||||
for c in 0..=cols {
|
||||
let x = inner.left() + c as f32 * cw;
|
||||
p.line_segment([pos2(x, inner.top()), pos2(x, inner.bottom())], Stroke::new(1.0, t.gridline));
|
||||
}
|
||||
p.rect_stroke(thumb, 4.0, edge, StrokeKind::Inside);
|
||||
let galley = p.layout(label.to_string(), theme::ui_font(12.5), t.text, rect.width());
|
||||
p.galley(pos2(rect.left(), thumb.bottom() + 6.0), galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Blank workbook and the built-in samples.
|
||||
fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(18.0, 14.0);
|
||||
if tile(ui, lang.tr("Blank workbook"), None).clicked() {
|
||||
app.run_or_alert("file.new", json!({}));
|
||||
out.close = true;
|
||||
}
|
||||
for (i, (name, title)) in gridcraft_engine::sample::SAMPLES.iter().enumerate() {
|
||||
if tile(ui, title, Some(i)).clicked() {
|
||||
app.run_or_alert("file.new", json!({"sample": name}));
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// File name and folder of a recent path, split as text (nothing on disk is read).
|
||||
fn split_path(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||
(name, folder)
|
||||
}
|
||||
|
||||
/// Recent workbooks, newest first. Opening one is the user's explicit choice.
|
||||
fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
if app.ui.recent.is_empty() {
|
||||
dim(ui, lang.tr("No recent workbooks yet."));
|
||||
return;
|
||||
}
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
|
||||
let resp = resp.on_hover_text(&path);
|
||||
if resp.clicked() {
|
||||
app.open_path(&path);
|
||||
if app.session.active().and_then(|d| d.path.as_deref()) == Some(path.as_str()) {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A large clickable row (icon, title, one-line description).
|
||||
fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 24.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text);
|
||||
p.text(pos2(rect.left() + 52.0, rect.center().y - 9.0), Align2::LEFT_CENTER, title, theme::ui_bold(13.5), t.text);
|
||||
p.text(pos2(rect.left() + 52.0, rect.center().y + 10.0), Align2::LEFT_CENTER, detail, theme::ui_font(12.0), t.text_dim);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A bordered square command button (Browse…, Print, Protect Workbook).
|
||||
fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside);
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 32.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
let galley = p.layout(label.to_string(), theme::ui_font(12.0), t.text, rect.width() - 8.0);
|
||||
p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 56.0), galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pages
|
||||
|
||||
fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "New");
|
||||
new_tiles(app, ui, out);
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, 8);
|
||||
if app.ui.recent.len() > 8 {
|
||||
ui.add_space(6.0);
|
||||
if ui.link(lang.tr("More workbooks")).clicked() {
|
||||
out.page = Some(Page::Open);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
new_tiles(app, ui, out);
|
||||
}
|
||||
|
||||
fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() {
|
||||
let before = app.session.active().map(|d| d.uid);
|
||||
app.open_dialog("open", json!({}));
|
||||
// Desktop pickers return at once; the web picker delivers later (see `SheetApp::logic`).
|
||||
if app.session.active().map(|d| d.uid) != before {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, usize::MAX);
|
||||
}
|
||||
|
||||
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
let Some(d) = app.session.active() else {
|
||||
dim(ui, lang.tr("No workbook is open."));
|
||||
return;
|
||||
};
|
||||
let title = d.display_title();
|
||||
let path = d.path.clone();
|
||||
let sheets = d.wb.sheets.len();
|
||||
let protected = d.wb.protected_structure;
|
||||
let current: Vec<String> = {
|
||||
let pr = &d.wb.props;
|
||||
vec![pr.title.clone(), pr.subject.clone(), pr.author.clone(), pr.company.clone(), pr.keywords.clone(), pr.description.clone()]
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(ui.cursor().min, vec2(28.0, 28.0)), Icon::Book, t.text);
|
||||
ui.add_space(38.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(&title).font(theme::ui_bold(16.0)).color(t.text));
|
||||
match &path {
|
||||
Some(p) => dim(ui, p),
|
||||
None => dim(ui, lang.tr("Not saved yet")),
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
ui.horizontal(|ui| {
|
||||
if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() {
|
||||
app.run_or_alert("review.protectWorkbook", json!({"on": !protected}));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(14.0);
|
||||
ui.label(RichText::new(lang.tr("Protect Workbook")).font(theme::ui_bold(13.5)).color(t.text));
|
||||
let text = if protected {
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted."
|
||||
} else {
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted."
|
||||
};
|
||||
dim(ui, lang.tr(text));
|
||||
});
|
||||
});
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Properties");
|
||||
egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| {
|
||||
ui.label(RichText::new(lang.tr("Location")).color(t.text_dim));
|
||||
ui.label(path.as_deref().unwrap_or(lang.tr("Not saved yet")));
|
||||
ui.end_row();
|
||||
ui.label(RichText::new(lang.tr("Sheets")).color(t.text_dim));
|
||||
ui.label(sheets.to_string());
|
||||
ui.end_row();
|
||||
for (i, (key, label)) in PROPERTIES.iter().enumerate() {
|
||||
let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue };
|
||||
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
let id = ui.make_persistent_id(("backstage_prop", *key));
|
||||
// Show the workbook's value unless the field is being edited (undo, another window).
|
||||
if !ui.ctx().memory(|m| m.has_focus(id)) {
|
||||
draft.clone_from(saved);
|
||||
}
|
||||
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0));
|
||||
if resp.lost_focus() && draft != saved {
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert((*key).to_string(), json!(draft.clone()));
|
||||
app.run_or_alert("file.properties", Json::Object(params));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
dim(ui, lang.tr("Save the workbook under another name or in another folder."));
|
||||
ui.add_space(14.0);
|
||||
// Desktop: the system Save dialog. Web: the workbook downloads as .xlsx.
|
||||
let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" };
|
||||
if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) {
|
||||
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
|
||||
app.open_dialog("saveAs", json!({}));
|
||||
let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
|
||||
if after != before || app.services.pick_save.is_none() {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
dim(ui, lang.tr("PDF, CSV and web pages are on the Export page."));
|
||||
}
|
||||
|
||||
fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else {
|
||||
dim(ui, lang.tr("No workbook is open."));
|
||||
return;
|
||||
};
|
||||
let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }});
|
||||
if bs.fit_to_page {
|
||||
params["fitToPage"] = json!(true);
|
||||
}
|
||||
let key = (uid, revision, bs.print_all, bs.fit_to_page);
|
||||
if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null);
|
||||
bs.preview = Some((key, preview));
|
||||
}
|
||||
let pages: Vec<Json> = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default();
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(250.0);
|
||||
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.print", params.clone());
|
||||
out.close = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| {
|
||||
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
|
||||
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
|
||||
bs.print_all = all;
|
||||
bs.preview_page = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() {
|
||||
bs.preview_page = 0;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if ui.link(lang.tr("Page Setup…")).clicked() {
|
||||
app.open_dialog("pageSetup", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
ui.vertical(|ui| {
|
||||
if pages.is_empty() {
|
||||
dim(ui, lang.tr("Nothing to print."));
|
||||
return;
|
||||
}
|
||||
let last = pages.len().saturating_sub(1);
|
||||
bs.preview_page = bs.preview_page.min(last);
|
||||
let Some(page) = pages.get(bs.preview_page) else { return };
|
||||
// Paper proportions from the preview (points); hostile sizes fall back to Letter.
|
||||
let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32;
|
||||
let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32;
|
||||
let scale = (520.0 / h).min(440.0 / w);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow);
|
||||
p.rect_filled(rect, 2.0, t.grid_bg);
|
||||
p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
if let Some(d) = app.session.active() {
|
||||
paint_page(p, rect, w, page, &d.wb, &t);
|
||||
}
|
||||
let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or("");
|
||||
let range = page.get("range").and_then(Json::as_str).unwrap_or("");
|
||||
ui.add_space(4.0);
|
||||
dim(ui, &format!("{sheet} · {range}"));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() {
|
||||
bs.preview_page = bs.preview_page.saturating_sub(1);
|
||||
}
|
||||
let text = lang
|
||||
.tr("Page {page} of {pages}")
|
||||
.replace("{page}", &(bs.preview_page + 1).to_string())
|
||||
.replace("{pages}", &pages.len().to_string());
|
||||
ui.label(text);
|
||||
if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() {
|
||||
bs.preview_page = (bs.preview_page + 1).min(last);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale).
|
||||
const PREVIEW_ROWS: u32 = 200;
|
||||
const PREVIEW_COLS: u32 = 60;
|
||||
|
||||
/// Draws one printed page in miniature: the cells' displayed text at the page's print scale,
|
||||
/// inside the sheet's margins. `paper_w` is the page width in points.
|
||||
fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) {
|
||||
let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return };
|
||||
let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return };
|
||||
// Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel).
|
||||
let per_pt = rect.width() / paper_w;
|
||||
let print_scale =
|
||||
page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0);
|
||||
let per_px = per_pt * 0.75 * print_scale;
|
||||
let margin =
|
||||
|i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt;
|
||||
let body = Rect::from_min_max(
|
||||
pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)),
|
||||
pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)),
|
||||
);
|
||||
if !body.is_positive() {
|
||||
return;
|
||||
}
|
||||
let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0));
|
||||
let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS));
|
||||
let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS));
|
||||
let mut y = body.top();
|
||||
for row in range.start.row..=last_row {
|
||||
let h = sh.row_height(row) * per_px;
|
||||
if h <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if y >= body.bottom() {
|
||||
break;
|
||||
}
|
||||
let mut x = body.left();
|
||||
for col in range.start.col..=last_col {
|
||||
let w = sh.col_width(col) * per_px;
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if x >= body.right() {
|
||||
break;
|
||||
}
|
||||
let cell = gridcraft_engine::core::CellRef::new(row, col);
|
||||
let text = gridcraft_engine::display::cell_text(wb, sh, cell);
|
||||
if !text.is_empty() {
|
||||
let numeric = sh.value(cell).as_f64().is_some();
|
||||
// Text runs on over empty neighbours, as on the sheet; numbers stay in their cell.
|
||||
let mut span = w;
|
||||
if !numeric {
|
||||
let mut next = col.saturating_add(1);
|
||||
while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() {
|
||||
span += sh.col_width(next) * per_px;
|
||||
next = next.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body);
|
||||
let (anchor, at) =
|
||||
if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) };
|
||||
p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text);
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
y += h;
|
||||
}
|
||||
}
|
||||
|
||||
fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked()
|
||||
&& export_as(app, "pdf")
|
||||
{
|
||||
out.close = true;
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
section(ui, lang, "Change File Type");
|
||||
for (fmt, icon, title, detail) in [
|
||||
("csv", Icon::TextColumns, "CSV (Comma delimited)", "Save the active sheet as comma-separated text."),
|
||||
("html", Icon::Link, "Web Page (.html)", "Save the workbook as a web page."),
|
||||
] {
|
||||
if action_row(ui, icon, lang.tr(title), lang.tr(detail)).clicked() && export_as(app, fmt) {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports the active workbook as `pdf`, `csv` or `html`: through the save picker on the desktop,
|
||||
/// as a browser download on the web. Returns whether a file was written or handed over.
|
||||
pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
||||
if !app.commit_edit(0, 0, false, false) {
|
||||
return false;
|
||||
}
|
||||
let id = match fmt {
|
||||
"pdf" => "file.exportPdf",
|
||||
"csv" => "file.exportCsv",
|
||||
"html" => "file.exportHtml",
|
||||
_ => return false,
|
||||
};
|
||||
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
if let Some(pick) = &app.services.pick_save {
|
||||
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
|
||||
// The picker may suggest a workbook extension; the export writes the chosen format.
|
||||
let path = std::path::Path::new(&path).with_extension(fmt).to_string_lossy().into_owned();
|
||||
return match app.run(id, json!({"path": path})) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
false
|
||||
}
|
||||
};
|
||||
}
|
||||
if app.services.download.is_none() {
|
||||
app.message = Some(("GridCraft".into(), "Exporting needs a file picker, which this window doesn't have.".into()));
|
||||
return false;
|
||||
}
|
||||
if fmt == "pdf" {
|
||||
// Without a path the PDF comes back as bytes, which `SheetApp::run` hands to the browser.
|
||||
return match app.run(id, json!({})) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
false
|
||||
}
|
||||
};
|
||||
}
|
||||
match app.session.run("file.saveBytes", json!({"format": fmt})) {
|
||||
Ok(r) => {
|
||||
let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode);
|
||||
let name = r.get("name").and_then(Json::as_str).map_or_else(|| format!("{stem}.{fmt}"), str::to_string);
|
||||
match (bytes, &app.services.download) {
|
||||
(Some(b), Some(dl)) => {
|
||||
dl(&name, &b);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Options|General");
|
||||
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
|
||||
ui.label(lang.tr("Display theme"));
|
||||
let mode = app.ui.theme_mode();
|
||||
let current = match mode {
|
||||
"system" => "System",
|
||||
"dark" => "Dark",
|
||||
_ => "Light",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
app.run_or_alert("view.theme", json!({"mode": value}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(lang.tr("Interface language"));
|
||||
egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| {
|
||||
for l in i18n::Language::ALL {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
app.run_or_alert("app.language.set", json!({"language": l.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut formula_bar = app.session.prefs.formula_bar;
|
||||
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
|
||||
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Save");
|
||||
let mut autosave = app.grid.autosave();
|
||||
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
|
||||
app.grid.toggle_autosave();
|
||||
}
|
||||
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Formulas");
|
||||
ui.label(lang.tr("Workbook calculation"));
|
||||
use gridcraft_engine::model::CalcMode;
|
||||
for (mode, id, label) in [
|
||||
(CalcMode::Automatic, "automatic", "Automatic"),
|
||||
(CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"),
|
||||
(CalcMode::Manual, "manual", "Manual"),
|
||||
] {
|
||||
if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"mode": id}));
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut iterative = calc.iterative;
|
||||
if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
for page in Page::ALL {
|
||||
assert_eq!(Page::parse(page.id()), Some(page));
|
||||
}
|
||||
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
|
||||
assert_eq!(Page::parse("account"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_paths_split_without_touching_the_disk() {
|
||||
assert_eq!(split_path("/home/me/Budget.xlsx"), ("Budget.xlsx".into(), "/home/me".into()));
|
||||
assert_eq!(split_path("Budget.xlsx"), ("Budget.xlsx".into(), "".into()));
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,21 @@ impl gridcraft_chart::Measure for EguiMeasure<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paints `chart` (not necessarily on the sheet: the Recommended Charts preview) scaled to fit
|
||||
/// `rect`.
|
||||
pub fn paint_chart(p: &Painter, rect: Rect, wb: &Workbook, sheet: usize, chart: &gridcraft_engine::model::Chart) {
|
||||
let (w, h) = (chart.anchor.width.max(1.0), chart.anchor.height.max(1.0));
|
||||
let z = (rect.width() / w).min(rect.height() / h);
|
||||
if !(z.is_finite() && z > 0.0) {
|
||||
return;
|
||||
}
|
||||
let origin = rect.center() - vec2(w * z, h * z) / 2.0;
|
||||
let data = gridcraft_chart::resolve(wb, sheet, chart);
|
||||
let m = EguiMeasure { painter: p, z };
|
||||
let prims = gridcraft_chart::render(chart, &data, w, h, &m);
|
||||
paint_prims(&p.with_clip_rect(rect.intersect(p.clip_rect())), origin, &prims, z);
|
||||
}
|
||||
|
||||
/// Paints chart primitives at `origin`, scaled by `z`.
|
||||
pub fn paint_prims(p: &Painter, origin: Pos2, prims: &[Prim], z: f32) {
|
||||
let pt = |x: f32, y: f32| pos2(origin.x + x * z, origin.y + y * z);
|
||||
|
|
@ -188,12 +203,23 @@ pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
|
|||
|
||||
static TEXTURES: std::sync::LazyLock<Mutex<HashMap<(u32, usize), egui::TextureHandle>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
|
||||
/// The shapes one floating object painted (indices into the painter's layer) and its logical
|
||||
/// rectangle, so a right-to-left sheet can move it as a whole instead of mirroring it.
|
||||
pub(crate) struct PaintedObject {
|
||||
pub shapes: std::ops::Range<usize>,
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
|
||||
let mut painted = Vec::new();
|
||||
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
|
||||
for sp in &sh.shapes {
|
||||
let r = anchor_rect(geo, sh, &sp.anchor);
|
||||
if !r.intersects(p.clip_rect()) {
|
||||
continue;
|
||||
}
|
||||
let start = p.add(egui::Shape::Noop);
|
||||
let mut label = true;
|
||||
let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT);
|
||||
let line = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY);
|
||||
match sp.kind {
|
||||
|
|
@ -225,13 +251,13 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
|||
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
|
||||
p.add(PathShape::line(pts, PathStroke::new(w, col)));
|
||||
}
|
||||
continue;
|
||||
label = false;
|
||||
}
|
||||
ShapeKind::Icon => {
|
||||
if let Some(icon) = crate::icons::from_name(&sp.text) {
|
||||
crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill });
|
||||
}
|
||||
continue;
|
||||
label = false;
|
||||
}
|
||||
ShapeKind::Line | ShapeKind::Arrow => {
|
||||
p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line));
|
||||
|
|
@ -243,7 +269,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
|||
}
|
||||
}
|
||||
}
|
||||
if !sp.text.is_empty() && !app.editing_text_box(sp.id) {
|
||||
if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) {
|
||||
let job = egui::text::LayoutJob::simple(
|
||||
sp.text.clone(),
|
||||
FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())),
|
||||
|
|
@ -254,15 +280,17 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
|||
let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 };
|
||||
p.galley(pos, g, Color32::BLACK);
|
||||
}
|
||||
if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
|
||||
if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
|
||||
selection_frame(p, r);
|
||||
}
|
||||
done(start, r);
|
||||
}
|
||||
for im in &sh.images {
|
||||
let r = anchor_rect(geo, sh, &im.anchor);
|
||||
if !r.intersects(p.clip_rect()) {
|
||||
continue;
|
||||
}
|
||||
let start = p.add(egui::Shape::Noop);
|
||||
let key = (im.id, im.data.len());
|
||||
let mut cache = TEXTURES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !cache.contains_key(&key)
|
||||
|
|
@ -282,12 +310,14 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
|||
if app.selected_chart == Some(im.id) {
|
||||
selection_frame(p, r);
|
||||
}
|
||||
done(start, r);
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
let r = anchor_rect(geo, sh, &ch.anchor);
|
||||
if !r.intersects(p.clip_rect()) {
|
||||
continue;
|
||||
}
|
||||
let start = p.add(egui::Shape::Noop);
|
||||
// Soft shadow + white card.
|
||||
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
|
|
@ -297,17 +327,25 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
|||
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
|
||||
if app.selected_chart == Some(ch.id) {
|
||||
selection_frame(p, r);
|
||||
}
|
||||
// The source-data highlight below is in cell space: mirrored with the cells.
|
||||
done(start, r);
|
||||
if app.selected_chart == Some(ch.id) {
|
||||
// Highlight the source data like Excel does.
|
||||
for s in &ch.series {
|
||||
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
|
||||
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
||||
{
|
||||
let rr = geo.range_rect(sh, rf.range());
|
||||
p.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
|
||||
for pane in geo.range_panes(rf.range()) {
|
||||
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
painted
|
||||
}
|
||||
|
||||
fn selection_frame(p: &Painter, r: Rect) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
|
|||
"scroll": [v.scroll.x, v.scroll.y],
|
||||
"editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})),
|
||||
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
|
||||
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
|
||||
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
|
||||
"selectedChart": app.selected_chart,
|
||||
"mode": format!("{:?}", app.session.mode),
|
||||
|
|
@ -121,7 +122,7 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option<egui::Pos2> {
|
|||
let sh = d.wb.active()?;
|
||||
let rect = app.grid.rect?;
|
||||
let geo = crate::grid::Geo::new(sh, rect, app.view().scroll);
|
||||
Some(geo.cell_rect(sh, c).center())
|
||||
Some(geo.screen_cell_rect(sh, c).center())
|
||||
}
|
||||
|
||||
fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) {
|
||||
|
|
@ -168,13 +169,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
|||
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
for (id, label, params) in [
|
||||
("ui.ribbonTab", "Ribbon Tab", ""),
|
||||
("view.formulaBar", "Formula Bar", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.zoom100", "100%", ""),
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
|
||||
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
|
||||
("app.language.english", "Interface Language: English", ""),
|
||||
("app.language.japanese", "Interface Language: Japanese", ""),
|
||||
] {
|
||||
|
|
@ -196,12 +197,16 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
|||
app.ui.system_theme = false;
|
||||
}
|
||||
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
|
||||
app.ui.formula_bar = v;
|
||||
let _ = app.run("view.formulaBar", json!({"on": v}));
|
||||
}
|
||||
if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) {
|
||||
app.ui.ribbon_collapsed = v;
|
||||
}
|
||||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||||
let mut out = serde_json::to_value(&app.ui).unwrap_or_default();
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar));
|
||||
}
|
||||
ok(out)
|
||||
}
|
||||
"ui.dialog" => {
|
||||
app.open_dialog(s("name").unwrap_or(""), p.clone());
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! Every dialog is also reachable programmatically: `ui.dialog {name}` opens one,
|
||||
//! `ui.dialog.set {field, value}` edits a field and `ui.dialog.confirm` presses OK.
|
||||
|
||||
use egui::{Color32, Key, vec2};
|
||||
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
|
|
@ -13,6 +15,7 @@ use crate::theme;
|
|||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Field {
|
||||
Text { key: &'static str, label: &'static str },
|
||||
Password { key: &'static str, label: &'static str },
|
||||
Number { key: &'static str, label: &'static str },
|
||||
Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> },
|
||||
Check { key: &'static str, label: &'static str },
|
||||
|
|
@ -31,8 +34,12 @@ pub struct Dialog {
|
|||
pub search: String,
|
||||
pub list_index: usize,
|
||||
pub result: Option<Json>,
|
||||
/// "Show password" is ticked: password fields show their text.
|
||||
pub show_password: bool,
|
||||
picture_inbox: Option<crate::Inbox>,
|
||||
picture_target: Option<(u64, usize)>,
|
||||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||||
back: Option<Box<Dialog>>,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
|
|
@ -51,8 +58,10 @@ impl Dialog {
|
|||
search: String::new(),
|
||||
list_index: 0,
|
||||
result: None,
|
||||
show_password: false,
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
back: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +76,10 @@ impl Dialog {
|
|||
search: String::new(),
|
||||
list_index: 0,
|
||||
result: None,
|
||||
show_password: false,
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
back: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +162,7 @@ impl Dialog {
|
|||
"Protect Sheet",
|
||||
"review.protectSheet",
|
||||
vec![
|
||||
Text { key: "password", label: "Password (optional):" },
|
||||
Password { key: "password", label: "Password (optional):" },
|
||||
Note("Allow all users of this sheet to:".into()),
|
||||
Check { key: "formatCells", label: "Format cells" },
|
||||
Check { key: "formatColumns", label: "Format columns" },
|
||||
|
|
@ -163,11 +174,49 @@ impl Dialog {
|
|||
],
|
||||
json!({}),
|
||||
),
|
||||
"allowEditRanges" => {
|
||||
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
|
||||
let rows: Vec<String> = list
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|r| {
|
||||
let lock = match (r["password"].as_bool(), r["unlocked"].as_bool()) {
|
||||
(Some(true), Some(true)) => " (password, unlocked)",
|
||||
(Some(true), _) => " (password)",
|
||||
_ => "",
|
||||
};
|
||||
format!("{}: {}{lock}", r["title"].as_str().unwrap_or(""), r["range"].as_str().unwrap_or(""))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = if rows.is_empty() { "No ranges yet.".to_string() } else { rows.join("\n") };
|
||||
Dialog::form(
|
||||
"allowEditRanges",
|
||||
"Allow Edit Ranges",
|
||||
"review.allowEditRanges",
|
||||
vec![
|
||||
Note(format!("Ranges that stay editable when the sheet is protected:\n{summary}")),
|
||||
Choice {
|
||||
key: "action",
|
||||
label: "Action",
|
||||
options: vec![("add", "New"), ("modify", "Modify"), ("delete", "Delete"), ("unlock", "Unlock with password")],
|
||||
},
|
||||
Text { key: "title", label: "Title:" },
|
||||
Text { key: "range", label: "Refers to cells:" },
|
||||
Text { key: "password", label: "Range password:" },
|
||||
Text { key: "newTitle", label: "New title (Modify):" },
|
||||
Check { key: "removePassword", label: "Remove the password (Modify)" },
|
||||
],
|
||||
json!({"action": "add", "range": sel}),
|
||||
)
|
||||
}
|
||||
"unprotectSheet" => Dialog::form(
|
||||
"unprotectSheet",
|
||||
"Unprotect Sheet",
|
||||
"review.unprotectSheet",
|
||||
vec![Text { key: "password", label: "Password:" }],
|
||||
vec![Password { key: "password", label: "Password:" }],
|
||||
json!({}),
|
||||
),
|
||||
"unhideSheet" => {
|
||||
|
|
@ -317,6 +366,33 @@ impl Dialog {
|
|||
json!({"header": true}),
|
||||
),
|
||||
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
|
||||
"analysisToolPak" => {
|
||||
// Opened with a known `tool`, it goes straight to that tool's form.
|
||||
let tool = params.get("tool").and_then(Json::as_str).unwrap_or("");
|
||||
let mut d = Dialog::custom("analysisToolPak", "Data Analysis", json!({"tool": "", "pick": tool}));
|
||||
if gridcraft_engine::cmd::analysis::TOOLS.iter().any(|(id, _)| *id == tool) {
|
||||
d.values = toolpak_defaults(&sel, tool);
|
||||
}
|
||||
d.command = Some("data.analysisToolPak");
|
||||
d
|
||||
}
|
||||
"solver" => {
|
||||
// Starts from the sheet's saved model, else the active cell as objective.
|
||||
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet));
|
||||
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
|
||||
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
|
||||
for (k, x) in m {
|
||||
v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() });
|
||||
}
|
||||
}
|
||||
if let Some(v) = values.as_object_mut() {
|
||||
v.insert("newOp".into(), json!("<="));
|
||||
}
|
||||
let mut d = Dialog::custom("solver", "Solver Parameters", values);
|
||||
d.command = Some("data.solver");
|
||||
d
|
||||
}
|
||||
"solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),
|
||||
"subtotal" => Dialog::form(
|
||||
"subtotal",
|
||||
"Subtotal",
|
||||
|
|
@ -353,18 +429,32 @@ impl Dialog {
|
|||
],
|
||||
json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}),
|
||||
),
|
||||
"defineName" => Dialog::form(
|
||||
"defineName",
|
||||
"New Name",
|
||||
"formulas.defineName",
|
||||
vec![
|
||||
Text { key: "name", label: "Name:" },
|
||||
Text { key: "scope", label: "Scope (Workbook or sheet name):" },
|
||||
Text { key: "comment", label: "Comment:" },
|
||||
Text { key: "refersTo", label: "Refers to:" },
|
||||
],
|
||||
json!({"scope": "Workbook", "refersTo": app.session.active().and_then(|d| d.wb.active().map(|s| format!("={}!{}", gridcraft_engine::formula::quote_sheet(&s.name), abs(&d.selection.current()))))}),
|
||||
),
|
||||
"defineName" => {
|
||||
// Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and
|
||||
// its current fields; New… and Define Name start from the selection.
|
||||
let editing = params.get("oldName").and_then(Json::as_str).is_some();
|
||||
let selection = app.session.active().and_then(|d| {
|
||||
d.wb.active().map(|s| format!("={}!{}", gridcraft_engine::formula::quote_sheet(&s.name), abs(&d.selection.current())))
|
||||
});
|
||||
let mut defaults = json!({"scope": "Workbook", "refersTo": selection});
|
||||
for key in ["name", "scope", "comment", "refersTo", "oldName", "oldScope"] {
|
||||
if let Some(v) = params.get(key).filter(|v| v.is_string()) {
|
||||
defaults[key] = v.clone();
|
||||
}
|
||||
}
|
||||
Dialog::form(
|
||||
"defineName",
|
||||
if editing { "Edit Name" } else { "New Name" },
|
||||
"formulas.defineName",
|
||||
vec![
|
||||
Text { key: "name", label: "Name:" },
|
||||
Text { key: "scope", label: "Scope (Workbook or sheet name):" },
|
||||
Text { key: "comment", label: "Comment:" },
|
||||
Text { key: "refersTo", label: "Refers to:" },
|
||||
],
|
||||
defaults,
|
||||
)
|
||||
}
|
||||
"dataValidation" => Dialog::form(
|
||||
"dataValidation",
|
||||
"Data Validation",
|
||||
|
|
@ -480,6 +570,13 @@ impl Dialog {
|
|||
d
|
||||
}
|
||||
"goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
|
||||
"pivotCalculatedField" => Dialog::form(
|
||||
"pivotCalculatedField",
|
||||
"Insert Calculated Field",
|
||||
"pivot.calculatedField",
|
||||
vec![Text { key: "name", label: "Name:" }, Text { key: "formula", label: "Formula:" }],
|
||||
json!({"pivot": p("pivot"), "name": p("name"), "formula": p("formula")}),
|
||||
),
|
||||
"goToSpecial" => Dialog::form(
|
||||
"goToSpecial",
|
||||
"Go To Special",
|
||||
|
|
@ -517,6 +614,44 @@ impl Dialog {
|
|||
};
|
||||
d
|
||||
}
|
||||
"recommendedCharts" => {
|
||||
// Insert › Recommended Charts: chart types for the selected data, with previews.
|
||||
let recs = match app.session.run("insert.chartRecommendations", json!({})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
app.message = Some(("Insert Chart".into(), crate::clean_error(&e)));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut d = Dialog::custom("recommendedCharts", "Insert Chart", json!({}));
|
||||
d.tab = if p("tab").as_str() == Some("All Charts") { "All Charts".into() } else { "Recommended Charts".into() };
|
||||
d.result = Some(recs);
|
||||
select_chart(&mut d, 0);
|
||||
d
|
||||
}
|
||||
"selectData" => {
|
||||
// Chart Design › Select Data: data range, series to keep, category axis labels.
|
||||
let id = p("chart").as_u64();
|
||||
let found = app.session.active().and_then(|doc| {
|
||||
let si = doc.wb.active_sheet;
|
||||
let sh = doc.wb.sheet(si)?;
|
||||
let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;
|
||||
let data = gridcraft_chart::resolve(&doc.wb, si, ch);
|
||||
let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();
|
||||
let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();
|
||||
Some((ch.source.clone().unwrap_or_default(), cats, names))
|
||||
});
|
||||
let Some((range, cats, names)) = found else {
|
||||
app.message = Some(("Select Data".into(), "Select a chart first.".into()));
|
||||
return None;
|
||||
};
|
||||
let eq = |t: &str| if t.is_empty() { String::new() } else { format!("={t}") };
|
||||
Dialog::custom(
|
||||
"selectData",
|
||||
"Select Data Source",
|
||||
json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),
|
||||
)
|
||||
}
|
||||
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
|
||||
"statistics" => {
|
||||
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
|
||||
|
|
@ -614,6 +749,15 @@ fn build_params(d: &Dialog) -> Json {
|
|||
v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect());
|
||||
}
|
||||
}
|
||||
"allowEditRanges" => {
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
// Blank fields mean "unchanged"; the check box clears a password.
|
||||
m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty()));
|
||||
if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) {
|
||||
m.insert("password".into(), json!(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
|
||||
&& d.values.get("percent").is_none()
|
||||
|
|
@ -636,10 +780,12 @@ fn build_params(d: &Dialog) -> Json {
|
|||
}
|
||||
}
|
||||
"defineName" => {
|
||||
if d.values.get("scope").and_then(Json::as_str) == Some("Workbook")
|
||||
&& let Some(m) = v.as_object_mut()
|
||||
{
|
||||
m.remove("scope");
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
for key in ["scope", "oldScope"] {
|
||||
if m.get(key).and_then(Json::as_str) == Some("Workbook") {
|
||||
m.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -659,6 +805,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
"formatCells" => 560.0,
|
||||
"about" => 660.0,
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
|
||||
"recommendedCharts" => 600.0,
|
||||
"analysisToolPak" => 420.0,
|
||||
"solver" => 460.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(d.title.clone())
|
||||
|
|
@ -677,10 +826,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
"find" => find(app, ui, &mut d),
|
||||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||||
"sort" => sort(app, ui, &mut d, &mut confirm),
|
||||
"nameManager" => name_manager(app, ui),
|
||||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||||
"manageRules" => manage_rules(app, ui),
|
||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
|
||||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
|
||||
"solverResults" => solver_results(app, ui, &mut d, &mut open),
|
||||
"spelling" => spelling(app, ui, &mut d, &mut open),
|
||||
"agents" => {
|
||||
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
|
||||
|
|
@ -810,7 +964,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
if ui.button("Cancel").clicked() {
|
||||
open = false;
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
|
||||
if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.save", json!({}));
|
||||
if app.session.active().is_some_and(|x| !x.is_dirty()) {
|
||||
app.run_or_alert("file.close", json!({"force": true}));
|
||||
|
|
@ -877,14 +1032,36 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
if confirm && let Some(cmd) = d.command {
|
||||
let params = build_params(&d);
|
||||
match app.run(cmd, params) {
|
||||
Ok(_) => open = false,
|
||||
Ok(r) => {
|
||||
open = false;
|
||||
if d.name == "solver" {
|
||||
app.open_dialog("solverResults", r);
|
||||
}
|
||||
}
|
||||
// Data Analysis asks before replacing data in its output range.
|
||||
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
|
||||
d.values.insert("askOverwrite".into(), json!(true));
|
||||
}
|
||||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||||
}
|
||||
} else if confirm {
|
||||
open = false;
|
||||
}
|
||||
if let Some(child) = app.dialog.as_mut() {
|
||||
// The body opened another dialog (Name Manager › New…). It takes over; this one waits
|
||||
// behind it and comes back when it closes, as Excel returns to the Name Manager. Go To
|
||||
// hands over to Go To Special for good, as in Excel.
|
||||
if open && d.name != "goTo" {
|
||||
child.back = Some(Box::new(d));
|
||||
} else if child.back.is_none() {
|
||||
child.back = d.back.take();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if open {
|
||||
app.dialog = Some(d);
|
||||
} else if let Some(back) = d.back.take() {
|
||||
app.dialog = Some(*back);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -967,6 +1144,12 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
|||
}
|
||||
|
||||
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
form_fields(ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
for f in d.fields.clone() {
|
||||
match f {
|
||||
|
|
@ -989,6 +1172,17 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
|
|||
}
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(label);
|
||||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
ui.checkbox(&mut d.show_password, "Show password");
|
||||
});
|
||||
}
|
||||
Field::Number { key, label } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
|
|
@ -1022,7 +1216,6 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
|
|||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits
|
||||
|
|
@ -1485,29 +1678,243 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
|||
}
|
||||
}
|
||||
|
||||
fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||||
let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null);
|
||||
egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| {
|
||||
ui.strong("Name");
|
||||
ui.strong("Value");
|
||||
ui.strong("Refers To");
|
||||
ui.strong("Scope");
|
||||
ui.end_row();
|
||||
for n in list.as_array().cloned().unwrap_or_default() {
|
||||
ui.label(n["name"].as_str().unwrap_or(""));
|
||||
ui.label(n["value"].to_string().chars().take(30).collect::<String>());
|
||||
ui.label(n["refersTo"].as_str().unwrap_or(""));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(n["scope"].as_str().unwrap_or(""));
|
||||
if ui.small_button("Delete").clicked() {
|
||||
app.run_or_alert("formulas.deleteName", json!({"name": n["name"], "scope": n["scope"]}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
let names = list.as_array().cloned().unwrap_or_default();
|
||||
let selected = names.get(d.list_index).cloned();
|
||||
let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("New…").clicked() {
|
||||
app.open_dialog("defineName", json!({}));
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked()
|
||||
&& let Some(n) = &selected
|
||||
{
|
||||
app.open_dialog(
|
||||
"defineName",
|
||||
json!({
|
||||
"name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"),
|
||||
"refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked()
|
||||
&& let Some(n) = &selected
|
||||
{
|
||||
app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")}));
|
||||
}
|
||||
});
|
||||
if ui.button("New…").clicked() {
|
||||
app.open_dialog("defineName", json!({}));
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| {
|
||||
ui.strong("Name");
|
||||
ui.strong("Value");
|
||||
ui.strong("Refers To");
|
||||
ui.strong("Scope");
|
||||
ui.end_row();
|
||||
if names.is_empty() {
|
||||
ui.label(egui::RichText::new("No names defined").italics());
|
||||
ui.end_row();
|
||||
}
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() {
|
||||
d.list_index = i;
|
||||
}
|
||||
ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::<String>());
|
||||
ui.label(field(n, "refersTo"));
|
||||
ui.label(field(n, "scope"));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(" Close ").clicked() {
|
||||
*open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
|
||||
const ALL_CHARTS: [(&str, &str, &str); 20] = [
|
||||
("column", "", "Clustered Column"),
|
||||
("column", "stacked", "Stacked Column"),
|
||||
("column", "stacked100", "100% Stacked Column"),
|
||||
("bar", "", "Clustered Bar"),
|
||||
("bar", "stacked", "Stacked Bar"),
|
||||
("bar", "stacked100", "100% Stacked Bar"),
|
||||
("line", "", "Line"),
|
||||
("line", "markers", "Line with Markers"),
|
||||
("line", "stacked", "Stacked Line"),
|
||||
("pie", "", "Pie"),
|
||||
("doughnut", "", "Doughnut"),
|
||||
("area", "", "Area"),
|
||||
("area", "stacked", "Stacked Area"),
|
||||
("scatter", "", "Scatter"),
|
||||
("scatter", "lines", "Scatter with Lines"),
|
||||
("radar", "", "Radar"),
|
||||
("histogram", "", "Histogram"),
|
||||
("waterfall", "", "Waterfall"),
|
||||
("funnel", "", "Funnel"),
|
||||
("combo", "", "Combo"),
|
||||
];
|
||||
|
||||
/// The chart types listed on the dialog's current tab.
|
||||
fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> {
|
||||
if d.tab == "All Charts" {
|
||||
return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect();
|
||||
}
|
||||
let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
d.result
|
||||
.as_ref()
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Selects entry `i` of the current tab: OK inserts it.
|
||||
fn select_chart(d: &mut Dialog, i: usize) {
|
||||
let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return };
|
||||
d.list_index = i;
|
||||
d.values.insert("type".into(), json!(t));
|
||||
d.values.insert("subtype".into(), json!(sub));
|
||||
}
|
||||
|
||||
fn chart_icon(t: &str) -> Icon {
|
||||
match t {
|
||||
"column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar,
|
||||
"line" | "area" | "radar" => Icon::ChartLine,
|
||||
"pie" | "doughnut" => Icon::ChartPie,
|
||||
"scatter" | "bubble" => Icon::ChartScatter,
|
||||
_ => Icon::Chart,
|
||||
}
|
||||
}
|
||||
|
||||
fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
ui.horizontal(|ui| {
|
||||
for tab in ["Recommended Charts", "All Charts"] {
|
||||
if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab {
|
||||
d.tab = tab.to_string();
|
||||
select_chart(d, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let choices = chart_choices(d);
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(190.0);
|
||||
egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| {
|
||||
for (i, (kind, _, label)) in choices.iter().enumerate() {
|
||||
let resp = ui
|
||||
.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text);
|
||||
ui.selectable_label(i == d.list_index, label.as_str())
|
||||
})
|
||||
.inner;
|
||||
if resp.clicked() {
|
||||
select_chart(d, i);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
select_chart(d, i);
|
||||
*confirm = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default();
|
||||
ui.strong(label);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
|
||||
if let Some(doc) = app.session.active()
|
||||
&& let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms)
|
||||
{
|
||||
crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart);
|
||||
}
|
||||
});
|
||||
});
|
||||
ok_cancel(ui, confirm, open);
|
||||
if *confirm {
|
||||
*confirm = false;
|
||||
let params =
|
||||
json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)});
|
||||
match app.run("insert.chart", params) {
|
||||
Ok(r) => {
|
||||
// The new chart is selected, as after inserting any object.
|
||||
app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok());
|
||||
*open = false;
|
||||
}
|
||||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
|
||||
ui.label(label);
|
||||
let mut v = text(d, key);
|
||||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
|
||||
d.values.insert(key.into(), json!(v));
|
||||
}
|
||||
};
|
||||
edit(ui, d, "range", "Chart data range:");
|
||||
ui.add_space(6.0);
|
||||
ui.strong("Legend Entries (Series)");
|
||||
let names: Vec<String> =
|
||||
d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default();
|
||||
let mut remove: Vec<u64> =
|
||||
d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();
|
||||
let kept = names.len().saturating_sub(remove.len());
|
||||
egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| {
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let i = i as u64;
|
||||
let removed = remove.contains(&i);
|
||||
ui.horizontal(|ui| {
|
||||
let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() };
|
||||
ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) });
|
||||
if removed {
|
||||
if ui.small_button("Keep").clicked() {
|
||||
remove.retain(|x| *x != i);
|
||||
}
|
||||
} else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() {
|
||||
remove.push(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
d.values.insert("remove".into(), json!(remove));
|
||||
ui.add_space(6.0);
|
||||
edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");
|
||||
ok_cancel(ui, confirm, open);
|
||||
if *confirm {
|
||||
*confirm = false;
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null));
|
||||
let range_changed = text(d, "range") != text(d, "range0");
|
||||
if range_changed {
|
||||
params.insert("range".into(), json!(text(d, "range")));
|
||||
} else if !remove.is_empty() {
|
||||
// A new range rebuilds the series; removals only apply to the current ones.
|
||||
params.insert("removeSeries".into(), json!(remove));
|
||||
}
|
||||
if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {
|
||||
params.insert("categories".into(), json!(text(d, "categories")));
|
||||
}
|
||||
if params.len() == 1 {
|
||||
*open = false;
|
||||
return;
|
||||
}
|
||||
match app.run("chart.selectData", Json::Object(params)) {
|
||||
Ok(_) => *open = false,
|
||||
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1607,6 +2014,282 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu
|
|||
}
|
||||
}
|
||||
|
||||
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
|
||||
fn toolpak_fields(tool: &str) -> Vec<Field> {
|
||||
use Field::*;
|
||||
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
|
||||
let labels = || Check { key: "labels", label: "Labels in first row" };
|
||||
let range = || Text { key: "range", label: "Input Range:" };
|
||||
let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }];
|
||||
match tool {
|
||||
"descriptive" => vec![
|
||||
range(),
|
||||
grouped(),
|
||||
labels(),
|
||||
Check { key: "summary", label: "Summary statistics" },
|
||||
Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" },
|
||||
Text { key: "kthLargest", label: "Kth Largest (blank for none):" },
|
||||
Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" },
|
||||
],
|
||||
"histogram" => vec![
|
||||
range(),
|
||||
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||||
labels(),
|
||||
Check { key: "pareto", label: "Pareto (sorted histogram)" },
|
||||
Check { key: "cumulative", label: "Cumulative Percentage" },
|
||||
],
|
||||
"correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()],
|
||||
"anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }],
|
||||
"regression" => vec![
|
||||
Text { key: "inputY", label: "Input Y Range:" },
|
||||
Text { key: "inputX", label: "Input X Range:" },
|
||||
labels(),
|
||||
Check { key: "constantZero", label: "Constant is Zero" },
|
||||
Text { key: "confidence", label: "Confidence Level (%):" },
|
||||
Check { key: "residuals", label: "Residuals" },
|
||||
Check { key: "standardizedResiduals", label: "Standardized Residuals" },
|
||||
Check { key: "normalProbability", label: "Normal Probability" },
|
||||
],
|
||||
"movingAverage" => {
|
||||
vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }]
|
||||
}
|
||||
"exponentialSmoothing" => {
|
||||
vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }]
|
||||
}
|
||||
"fTest" => {
|
||||
let mut v = two();
|
||||
v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]);
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let mut v = two();
|
||||
v.extend([
|
||||
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
|
||||
labels(),
|
||||
Text { key: "alpha", label: "Alpha:" },
|
||||
]);
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The starting values of a tool's form: the selection as its input, a new sheet as output.
|
||||
fn toolpak_defaults(sel: &str, tool: &str) -> Map<String, Json> {
|
||||
let mut v = json!({
|
||||
"tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "",
|
||||
"groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "",
|
||||
"interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0",
|
||||
"outputTo": "newSheet", "output": "", "newSheet": "",
|
||||
});
|
||||
if tool == "regression" {
|
||||
v["confidence"] = json!("95");
|
||||
v["inputY"] = json!(sel);
|
||||
}
|
||||
v.as_object().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak`
|
||||
/// (through the dialog's command); a report that would replace data asks first.
|
||||
fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
use gridcraft_engine::cmd::analysis::TOOLS;
|
||||
if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) {
|
||||
ui.label("The output range already holds data. Replace it with the report?");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Replace").clicked() {
|
||||
d.values.remove("askOverwrite");
|
||||
d.values.insert("overwrite".into(), json!(true));
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
d.values.remove("askOverwrite");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else {
|
||||
ui.label(egui::RichText::new("Analysis Tools").strong());
|
||||
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut chosen = false;
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (id, label) in TOOLS {
|
||||
let r = ui.selectable_label(pick == *id, *label);
|
||||
if r.clicked() {
|
||||
d.values.insert("pick".into(), json!(id));
|
||||
}
|
||||
if r.double_clicked() {
|
||||
chosen = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut ok = false;
|
||||
ok_cancel(ui, &mut ok, open);
|
||||
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
|
||||
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
|
||||
d.values = toolpak_defaults(&sel, &pick);
|
||||
}
|
||||
return;
|
||||
};
|
||||
d.title = label.to_string();
|
||||
d.fields = toolpak_fields(&tool);
|
||||
form_fields(ui, d);
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Output options").strong());
|
||||
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
|
||||
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(to == choice, text_label).clicked() {
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
|
||||
d.values.insert(key.into(), json!(t));
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
});
|
||||
}
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
|
||||
/// command; the result opens Solver Results.
|
||||
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
|
||||
let mut t = text(d, k);
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
|
||||
d.values.insert(k.into(), json!(t));
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Set Objective:");
|
||||
edit(ui, d, "objective", 200.0);
|
||||
});
|
||||
let goal = text(d, "goal");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("To:");
|
||||
for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] {
|
||||
if ui.radio(goal == g, label).clicked() {
|
||||
d.values.insert("goal".into(), json!(g));
|
||||
}
|
||||
}
|
||||
edit(ui, d, "value", 80.0);
|
||||
});
|
||||
ui.label("By Changing Variable Cells:");
|
||||
edit(ui, d, "variables", 420.0);
|
||||
ui.label("Subject to the Constraints:");
|
||||
let list: Vec<String> = d
|
||||
.values
|
||||
.get("constraints")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_size(vec2(420.0, 90.0));
|
||||
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if ui.selectable_label(d.list_index == i, c).clicked() {
|
||||
d.list_index = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
edit(ui, d, "newLhs", 110.0);
|
||||
let op = text(d, "newOp");
|
||||
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
|
||||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||||
if ui.selectable_label(op == o, o).clicked() {
|
||||
d.values.insert("newOp".into(), json!(o));
|
||||
}
|
||||
}
|
||||
});
|
||||
if !matches!(op.as_str(), "int" | "bin") {
|
||||
edit(ui, d, "newRhs", 110.0);
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let lhs = text(d, "newLhs");
|
||||
if !lhs.trim().is_empty() {
|
||||
let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) };
|
||||
let mut list = list.clone();
|
||||
list.push(format!("{} {op}{rhs}", lhs.trim()));
|
||||
d.values.insert("constraints".into(), json!(list));
|
||||
d.values.insert("newLhs".into(), json!(""));
|
||||
d.values.insert("newRhs".into(), json!(""));
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() {
|
||||
let mut list = list.clone();
|
||||
if d.list_index < list.len() {
|
||||
list.remove(d.list_index);
|
||||
}
|
||||
d.values.insert("constraints".into(), json!(list));
|
||||
}
|
||||
});
|
||||
let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true);
|
||||
if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() {
|
||||
d.values.insert("nonNegative".into(), json!(nonneg));
|
||||
}
|
||||
let method = text(d, "method");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Select a Solving Method:");
|
||||
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
|
||||
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
|
||||
for m in ["grg", "simplex"] {
|
||||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||||
d.values.insert("method".into(), json!(m));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(if method == "simplex" {
|
||||
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
|
||||
} else {
|
||||
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
|
||||
})
|
||||
.small(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let solve =
|
||||
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||||
if solve.clicked() && !text(d, "objective").trim().is_empty() {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(" Close ").clicked() {
|
||||
*open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Solver Results: the outcome, and keeping or restoring the variable cells.
|
||||
fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||||
let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.label(message);
|
||||
let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false);
|
||||
let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false);
|
||||
if changed {
|
||||
ui.add_space(4.0);
|
||||
if ui.radio(!restore, "Keep Solver Solution").clicked() {
|
||||
d.values.insert("restore".into(), json!(false));
|
||||
}
|
||||
if ui.radio(restore, "Restore Original Values").clicked() {
|
||||
d.values.insert("restore".into(), json!(true));
|
||||
}
|
||||
}
|
||||
let mut confirm = false;
|
||||
ok_cancel(ui, &mut confirm, open);
|
||||
if confirm {
|
||||
// The solution (and the saved model) is one undo step.
|
||||
if changed && restore {
|
||||
app.run_or_alert("edit.undo", json!({}));
|
||||
}
|
||||
*open = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
|
||||
let r = d.result.clone().unwrap_or(Json::Null);
|
||||
if let Some(e) = r.get("error").and_then(Json::as_str) {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||
}
|
||||
if resp.lost_focus() {
|
||||
if ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
// The Enter that commits the Name Box is used up here. Left in the frame's input, the
|
||||
// grid (drawn after the formula bar, and focused again now the box let go) would read
|
||||
// it as its own Enter and move the active cell one row down from the jump target (#247).
|
||||
ui.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Enter, .. })));
|
||||
let target = text.trim().to_string();
|
||||
if !target.is_empty() && target != shown {
|
||||
// A new name for the selection, or a place to go.
|
||||
|
|
@ -226,6 +230,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||
} else {
|
||||
(0, 1)
|
||||
};
|
||||
// Left/Right follow the screen on a right-to-left sheet.
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
|
||||
let dc = if rtl { -dc } else { dc };
|
||||
if ed.can_point() {
|
||||
ui.input_mut(|i| {
|
||||
for k in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] {
|
||||
|
|
@ -336,9 +343,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||
ui.painter().galley(pos, g, Color32::WHITE);
|
||||
}
|
||||
// Autocomplete list and argument hint under the editor.
|
||||
if mine && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
|
||||
egui::Area::new(id.with("ac")).fixed_pos(below).order(egui::Order::Tooltip).show(ui.ctx(), |ui| {
|
||||
// Areas have their own clip rect: keep cell helpers inside the worksheet.
|
||||
let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() };
|
||||
egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| {
|
||||
if !ed.autocomplete.is_empty() {
|
||||
let mut pick = None;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ pub enum Drag {
|
|||
Fill {
|
||||
target: Option<RangeRef>,
|
||||
},
|
||||
/// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze.
|
||||
Freeze {
|
||||
cols: bool,
|
||||
to: Option<u32>,
|
||||
},
|
||||
/// Moving the selection by its border (cut & paste).
|
||||
Move {
|
||||
grab: CellRef,
|
||||
|
|
@ -108,6 +113,11 @@ pub struct Geo {
|
|||
pub frozen_w: f32,
|
||||
pub frozen_h: f32,
|
||||
pub scroll: egui::Vec2,
|
||||
/// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical"
|
||||
/// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s
|
||||
/// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by
|
||||
/// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution).
|
||||
pub rtl: bool,
|
||||
/// Prefix sums over custom row heights / column widths, built once per frame.
|
||||
ri: std::sync::Arc<LineIndex>,
|
||||
ci: std::sync::Arc<LineIndex>,
|
||||
|
|
@ -127,7 +137,21 @@ impl Geo {
|
|||
let frozen_w = (ci.start(fc) as f32) * z;
|
||||
let frozen_h = (ri.start(fr) as f32) * z;
|
||||
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
|
||||
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci }
|
||||
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci }
|
||||
}
|
||||
/// Screen x for a logical x (and back): identity unless the sheet is right-to-left.
|
||||
pub fn mirror_x(&self, x: f32) -> f32 {
|
||||
if self.rtl { self.rect.left() + self.rect.right() - x } else { x }
|
||||
}
|
||||
pub fn mirror_pos(&self, p: Pos2) -> Pos2 {
|
||||
pos2(self.mirror_x(p.x), p.y)
|
||||
}
|
||||
pub fn mirror_rect(&self, r: Rect) -> Rect {
|
||||
if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r }
|
||||
}
|
||||
/// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets).
|
||||
pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
|
||||
self.mirror_rect(self.cell_rect(sh, c))
|
||||
}
|
||||
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
|
||||
if col < self.fc {
|
||||
|
|
@ -186,6 +210,33 @@ impl Geo {
|
|||
let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1);
|
||||
(c0, c1)
|
||||
}
|
||||
/// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`.
|
||||
/// The panes share one coordinate system, so a range that is not clipped to these is also drawn
|
||||
/// over the frozen rows/columns when it is scrolled under them.
|
||||
pub fn range_panes(&self, r: RangeRef) -> Vec<Rect> {
|
||||
let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h);
|
||||
let mut xs = Vec::new();
|
||||
if r.start.col < self.fc {
|
||||
xs.push((self.cells.left(), split.x));
|
||||
}
|
||||
if r.end.col >= self.fc {
|
||||
xs.push((split.x, self.cells.right()));
|
||||
}
|
||||
let mut ys = Vec::new();
|
||||
if r.start.row < self.fr {
|
||||
ys.push((self.cells.top(), split.y));
|
||||
}
|
||||
if r.end.row >= self.fr {
|
||||
ys.push((split.y, self.cells.bottom()));
|
||||
}
|
||||
ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect()
|
||||
}
|
||||
/// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns.
|
||||
pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool {
|
||||
let r = self.cell_rect(sh, c);
|
||||
(self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w)
|
||||
|| (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved look of a cell for painting.
|
||||
|
|
@ -294,7 +345,8 @@ fn display_text(
|
|||
return (String::new(), None, false, None);
|
||||
}
|
||||
let code = st.num_fmt.as_str();
|
||||
if code == "checkbox" {
|
||||
// A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General.
|
||||
if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
|
||||
return (String::new(), None, false, None);
|
||||
}
|
||||
if code == "General"
|
||||
|
|
@ -313,7 +365,14 @@ fn display_text(
|
|||
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let available = ui.available_rect_before_wrap();
|
||||
let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom()));
|
||||
// A right-to-left sheet has its vertical scrollbar on the left.
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
|
||||
let bar = 14.0_f32.min(available.width());
|
||||
let rect = if rtl {
|
||||
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
|
||||
} else {
|
||||
Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom()))
|
||||
};
|
||||
let resp = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
app.grid.rect = Some(rect);
|
||||
let Some(d) = app.session.active() else { return };
|
||||
|
|
@ -331,6 +390,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
} else if scroll_delta != egui::Vec2::ZERO {
|
||||
let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE;
|
||||
let mut d = scroll_delta;
|
||||
// A horizontal swipe moves the content the way the fingers go, which is towards
|
||||
// higher columns on a mirrored sheet.
|
||||
if sh.right_to_left {
|
||||
d.x = -d.x;
|
||||
}
|
||||
if modifiers.shift && d.x == 0.0 {
|
||||
d = vec2(d.y, 0.0);
|
||||
}
|
||||
|
|
@ -370,9 +434,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
if let Some(v) = app.view_mut() {
|
||||
*v = view;
|
||||
}
|
||||
app.grid.cells_rect = Some(geo.cells);
|
||||
app.grid.cells_rect = Some(geo.mirror_rect(geo.cells));
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, 0.0, t.grid_bg);
|
||||
// Everything up to the headers is painted in logical (left-to-right) coordinates.
|
||||
let mirror_from = painter.add(egui::Shape::Noop);
|
||||
let mut cf = CfCache::new();
|
||||
// Quadrants: (rows, cols, clip).
|
||||
let (sr0, sr1) = geo.scroll_rows(sh);
|
||||
|
|
@ -396,7 +462,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
let p = painter.with_clip_rect(*clip);
|
||||
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t);
|
||||
paint_selection(&p, &geo, sh, &sel, &t, app);
|
||||
paint_selection(&p, &geo, sh, &sel, &t, app, *rows, *cols);
|
||||
}
|
||||
// Freeze lines.
|
||||
if geo.fr > 0 {
|
||||
|
|
@ -406,7 +472,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150)));
|
||||
}
|
||||
// Objects above cells.
|
||||
crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
|
||||
let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
|
||||
paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
|
||||
if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" {
|
||||
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
|
||||
|
|
@ -414,6 +480,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
// Headers.
|
||||
if sh.show_headings {
|
||||
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
|
||||
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
|
||||
}
|
||||
if geo.rtl {
|
||||
let mirror_to = painter.add(egui::Shape::Noop);
|
||||
mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects);
|
||||
}
|
||||
// Interaction.
|
||||
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
|
||||
|
|
@ -426,7 +497,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
|
||||
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
|
||||
context_menu(app, ui);
|
||||
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
|
||||
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
|
||||
vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t);
|
||||
if app.session.clipboard.is_some() {
|
||||
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
|
||||
|
|
@ -435,8 +507,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
|
||||
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
|
||||
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
|
||||
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, right: f32, limit: f32, t: &Tokens) {
|
||||
let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom()));
|
||||
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
|
||||
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
|
||||
if track.height() <= 0.0 || track.width() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -482,6 +554,59 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
|
|||
}
|
||||
}
|
||||
|
||||
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
|
||||
/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction
|
||||
/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index
|
||||
/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture
|
||||
/// isn't flipped.
|
||||
fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range<usize>, geo: &Geo, objects: &[crate::chartview::PaintedObject]) {
|
||||
let axis = geo.rect.left() + geo.rect.right();
|
||||
ctx.graphics_mut(|g| {
|
||||
let list = g.entry(layer);
|
||||
for i in range {
|
||||
let object = objects.iter().find(|o| o.shapes.contains(&i));
|
||||
list.mutate_shape(egui::layers::ShapeIdx(i), |cs| {
|
||||
cs.clip_rect = geo.mirror_rect(cs.clip_rect);
|
||||
match object {
|
||||
Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)),
|
||||
None => mirror_shape(&mut cs.shape, axis),
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reflect one shape across the vertical line `x = axis / 2`.
|
||||
fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
|
||||
use egui::Shape;
|
||||
let flip = |p: &mut Pos2| p.x = axis - p.x;
|
||||
match shape {
|
||||
Shape::Noop | Shape::Callback(_) => {}
|
||||
Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)),
|
||||
Shape::Circle(c) => flip(&mut c.center),
|
||||
Shape::Ellipse(e) => flip(&mut e.center),
|
||||
Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip),
|
||||
Shape::Path(p) => {
|
||||
p.points.iter_mut().for_each(flip);
|
||||
// Keep the preferred (clockwise) winding for filled polygons.
|
||||
p.points.reverse();
|
||||
}
|
||||
Shape::Rect(r) => {
|
||||
r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom()));
|
||||
let c = r.corner_radius;
|
||||
r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw };
|
||||
}
|
||||
Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip),
|
||||
Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip),
|
||||
_ => {
|
||||
let b = shape.visual_bounding_rect();
|
||||
if b.is_finite() {
|
||||
shape.translate(vec2(axis - b.right() - b.left(), 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) {
|
||||
let z = geo.z;
|
||||
|
|
@ -582,7 +707,7 @@ fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet,
|
|||
paint_cf_icon(p, r, set, *idx);
|
||||
text_left += 15.0 * z;
|
||||
}
|
||||
if lk.style.num_fmt.as_str() == "checkbox" {
|
||||
if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
|
||||
let s = (11.0 * z * 1.2).clamp(8.0, 24.0);
|
||||
let b = Rect::from_center_size(rect.center(), vec2(s, s));
|
||||
let on = v == Value::Bool(true);
|
||||
|
|
@ -901,21 +1026,40 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) {
|
||||
/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive).
|
||||
/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the
|
||||
/// scrolling pane would otherwise be drawn over the frozen pane as well.
|
||||
fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool {
|
||||
r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_selection(
|
||||
p: &Painter,
|
||||
geo: &Geo,
|
||||
sh: &Sheet,
|
||||
sel: &gridcraft_engine::Selection,
|
||||
t: &Tokens,
|
||||
app: &SheetApp,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
) {
|
||||
// Fill all areas except the active cell.
|
||||
for r in &sel.ranges {
|
||||
if !range_in_pane(*r, rows, cols) {
|
||||
continue;
|
||||
}
|
||||
if r.is_single() && sel.ranges.len() == 1 {
|
||||
continue;
|
||||
}
|
||||
let rect = geo.range_rect(sh, *r);
|
||||
p.rect_filled(rect, 0.0, t.sel_fill);
|
||||
}
|
||||
let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active));
|
||||
let active = match sh.merge_at(sel.active) {
|
||||
Some(m) => geo.range_rect(sh, m),
|
||||
None => active,
|
||||
};
|
||||
if !(sel.ranges.len() == 1 && sel.current().is_single()) {
|
||||
let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active));
|
||||
let active = geo.range_rect(sh, active_range);
|
||||
let active_here = range_in_pane(active_range, rows, cols);
|
||||
let cur_here = range_in_pane(sel.current(), rows, cols);
|
||||
if active_here && !(sel.ranges.len() == 1 && sel.current().is_single()) {
|
||||
p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0));
|
||||
}
|
||||
let cur = geo.range_rect(sh, sel.current());
|
||||
|
|
@ -927,6 +1071,9 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
|
|||
if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
|
||||
continue;
|
||||
}
|
||||
if !range_in_pane(r, rows, cols) {
|
||||
continue;
|
||||
}
|
||||
let rr = geo.range_rect(sh, r);
|
||||
let col = REF_COLORS[idx];
|
||||
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
|
||||
|
|
@ -936,18 +1083,22 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
|
|||
}
|
||||
}
|
||||
}
|
||||
p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
|
||||
if sel.ranges.len() > 1 {
|
||||
if cur_here {
|
||||
p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
|
||||
}
|
||||
if active_here && sel.ranges.len() > 1 {
|
||||
p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside);
|
||||
}
|
||||
// Fill handle (bottom-right of the current area).
|
||||
if app.editor.is_none() {
|
||||
if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) {
|
||||
let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0));
|
||||
p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE);
|
||||
p.rect_filled(h, 0.0, t.sel_border);
|
||||
}
|
||||
// Fill-handle drag preview.
|
||||
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag {
|
||||
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag
|
||||
&& range_in_pane(*tr, rows, cols)
|
||||
{
|
||||
let rr = geo.range_rect(sh, *tr);
|
||||
p.extend(egui::Shape::dashed_line(
|
||||
&[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()],
|
||||
|
|
@ -959,17 +1110,24 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
|
|||
if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag {
|
||||
let r = sel.current();
|
||||
let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1));
|
||||
let rr = geo.range_rect(sh, moved);
|
||||
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
|
||||
if range_in_pane(moved, rows, cols) {
|
||||
let rr = geo.range_rect(sh, moved);
|
||||
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
// Marching ants around the copied range.
|
||||
if let Some(clip) = &app.session.clipboard
|
||||
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
|
||||
&& range_in_pane(clip.range, rows, cols)
|
||||
{
|
||||
let rr = geo.range_rect(sh, clip.range).expand(0.5);
|
||||
let phase = app.grid.marching_phase;
|
||||
let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()];
|
||||
p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase));
|
||||
// A multi-area copy marks each area as selected.
|
||||
let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() };
|
||||
for area in areas {
|
||||
let rr = geo.range_rect(sh, *area).expand(0.5);
|
||||
let phase = app.grid.marching_phase;
|
||||
let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()];
|
||||
p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1047,6 +1205,9 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel
|
|||
|
||||
// ------------------------------------------------------------------ interaction
|
||||
|
||||
/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it.
|
||||
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
|
||||
|
||||
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
||||
// Text selection and editing must not also move the object or edit worksheet cells.
|
||||
if app.text_box_editor.is_some() {
|
||||
|
|
@ -1057,7 +1218,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let primary_clicked = resp.clicked_by(egui::PointerButton::Primary);
|
||||
let pointer = ctx.input(|i| i.pointer.clone());
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
let pos = pointer.interact_pos().or(pointer.hover_pos());
|
||||
// Hit-testing works in logical coordinates (see `Geo::rtl`).
|
||||
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
|
||||
let in_cells = pos.is_some_and(|p| geo.cells.contains(p));
|
||||
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left());
|
||||
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top());
|
||||
|
|
@ -1065,23 +1227,48 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
|
||||
let cur_rect = geo.range_rect(sh, sel.current());
|
||||
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
|
||||
// The fill handle and the move border only react in the panes where they are painted, not
|
||||
// over the frozen rows/columns that a scrolled selection lies under.
|
||||
let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end));
|
||||
let border_panes = geo.range_panes(sel.current());
|
||||
let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p));
|
||||
let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
|
||||
|
||||
let (freeze_cols, freeze_rows) = freeze_handles(geo);
|
||||
// Hover cursors.
|
||||
if resp.hovered()
|
||||
&& let Some(p) = pos
|
||||
&& (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. }))
|
||||
{
|
||||
let cols = match app.grid.drag {
|
||||
Drag::Freeze { cols, .. } => cols,
|
||||
_ => freeze_cols.contains(p),
|
||||
};
|
||||
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
|
||||
resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip));
|
||||
}
|
||||
} else if resp.hovered()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
|
||||
let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some();
|
||||
if near_col_edge {
|
||||
let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id));
|
||||
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
|
||||
let near_object_corner = app.grid.drag == Drag::None && near_corner;
|
||||
if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
|
||||
} else if near_col_edge {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
|
||||
} else if near_row_edge {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeRow);
|
||||
} else if handle.contains(p) && app.editor.is_none() {
|
||||
} else if on_handle(p) && app.editor.is_none() {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
|
||||
}
|
||||
} else if in_cells && app.editor.is_none() && on_border(cur_rect, p) {
|
||||
} else if in_cells && app.editor.is_none() && on_cur_border(p) {
|
||||
ctx.set_cursor_icon(CursorIcon::Move);
|
||||
} else if in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Cell);
|
||||
|
|
@ -1109,10 +1296,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
.ink
|
||||
.iter()
|
||||
.map(|q| {
|
||||
pos2(
|
||||
geo.mirror_pos(pos2(
|
||||
geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z,
|
||||
geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z,
|
||||
)
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
|
||||
|
|
@ -1135,7 +1322,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
}
|
||||
// Objects (charts, pictures, shapes) take clicks first.
|
||||
if resp.drag_started() || primary_clicked {
|
||||
if let Some(p) = pos
|
||||
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
|
||||
let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos };
|
||||
if let Some(p) = at
|
||||
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
|
||||
{
|
||||
app.selected_chart = Some(id);
|
||||
|
|
@ -1151,7 +1340,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
}
|
||||
if resp.drag_started() {
|
||||
let a = object_anchor(sh, kind, id);
|
||||
let resize = (p - rect.right_bottom()).length() < 10.0;
|
||||
let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS;
|
||||
if let Some(a) = a {
|
||||
app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize };
|
||||
}
|
||||
|
|
@ -1191,7 +1380,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
// Press: decide the drag mode.
|
||||
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
|
||||
// Where the press began (a drag is only recognised after the pointer has moved).
|
||||
let Some(p) = pointer.press_origin().or(pos) else { return };
|
||||
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
|
||||
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
|
||||
if freeze_cols.contains(p) || freeze_rows.contains(p) {
|
||||
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
|
||||
return;
|
||||
}
|
||||
if in_corner {
|
||||
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
|
||||
return;
|
||||
|
|
@ -1231,7 +1425,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
}
|
||||
// Filter dropdown buttons.
|
||||
if is_filter_button(sh, geo, p) {
|
||||
app.grid.filter_menu = Some((c.col, p));
|
||||
app.grid.filter_menu = Some((c.col, geo.mirror_pos(p)));
|
||||
return;
|
||||
}
|
||||
// Point mode while editing a formula: clicking inserts a reference.
|
||||
|
|
@ -1258,11 +1452,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
|
||||
return;
|
||||
}
|
||||
if handle.contains(p) {
|
||||
if on_handle(p) {
|
||||
app.grid.drag = Drag::Fill { target: None };
|
||||
return;
|
||||
}
|
||||
if on_border(cur_rect, p) && resp.drag_started() {
|
||||
if on_cur_border(p) && resp.drag_started() {
|
||||
app.grid.drag = Drag::Move { grab: c, to: None };
|
||||
return;
|
||||
}
|
||||
|
|
@ -1359,8 +1553,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let w = (orig + (p.x - start) / geo.z).max(0.0);
|
||||
app.toast =
|
||||
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
|
||||
// Inside a selection of whole columns, every selected column takes the new width
|
||||
// (Ctrl-selected areas included); otherwise only the dragged one.
|
||||
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
|
||||
sel.current().a1()
|
||||
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
|
||||
} else {
|
||||
RangeRef::cols(col, col).a1()
|
||||
};
|
||||
|
|
@ -1371,13 +1567,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let h = (orig + (p.y - start) / geo.z).max(0.0);
|
||||
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
|
||||
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
|
||||
format!("{}:{}", sel.current().start.row + 1, sel.current().end.row + 1)
|
||||
sel.ranges
|
||||
.iter()
|
||||
.filter(|r| r.is_full_rows())
|
||||
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
} else {
|
||||
format!("{}:{}", row + 1, row + 1)
|
||||
};
|
||||
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
|
||||
coalesce_last_undo(app);
|
||||
}
|
||||
Drag::Freeze { cols, .. } => {
|
||||
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
|
||||
}
|
||||
Drag::Fill { .. } => {
|
||||
let src = sel.current();
|
||||
let target = fill_target(src, pc);
|
||||
|
|
@ -1421,12 +1625,21 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
Drag::Fill { target: Some(t) } => {
|
||||
let src = sel.current();
|
||||
if t != src {
|
||||
let mode = if mods.command { "copy" } else { "series" };
|
||||
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
|
||||
let mode = if mods.command { "alternate" } else { "series" };
|
||||
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Drag::Freeze { cols, to: Some(to) } => {
|
||||
let (rows_now, cols_now) = (geo.fr, geo.fc);
|
||||
let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) };
|
||||
if (rows, cols) != (rows_now, cols_now) {
|
||||
// A1 (nothing left frozen) unfreezes.
|
||||
app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()}));
|
||||
}
|
||||
}
|
||||
Drag::Move { to: Some(to), .. } => {
|
||||
let src = sel.current();
|
||||
if to != src.start {
|
||||
|
|
@ -1442,7 +1655,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
// Double-click the fill handle.
|
||||
if resp.double_clicked()
|
||||
&& let Some(p) = pos
|
||||
&& handle.contains(p)
|
||||
&& on_handle(p)
|
||||
{
|
||||
// Fill down as far as the neighbouring column goes.
|
||||
let src = sel.current();
|
||||
|
|
@ -1478,9 +1691,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) };
|
||||
let _ = app.session.run("selection.set", json!({"range": range.a1()}));
|
||||
}
|
||||
app.grid.header_menu = Some((p, rows));
|
||||
app.grid.header_menu = Some((geo.mirror_pos(p), rows));
|
||||
} else {
|
||||
app.grid.context_menu = Some(p);
|
||||
app.grid.context_menu = Some(geo.mirror_pos(p));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1568,6 +1781,60 @@ fn on_border(r: Rect, p: Pos2) -> bool {
|
|||
outer.contains(p) && !inner.contains(p)
|
||||
}
|
||||
|
||||
/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip
|
||||
/// on the frozen columns' right edge, and one in the left part of the row-header strip on the
|
||||
/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the
|
||||
/// header edge keeps resizing the column or row.
|
||||
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
|
||||
let x = geo.cells.left() + geo.frozen_w;
|
||||
let y = geo.cells.top() + geo.frozen_h;
|
||||
let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4));
|
||||
let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0));
|
||||
(cols, rows)
|
||||
}
|
||||
|
||||
/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest
|
||||
/// the pointer, limited so the frozen pane leaves room for the scrolling one.
|
||||
fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
|
||||
if cols {
|
||||
if p.x <= geo.cells.left() {
|
||||
return 0;
|
||||
}
|
||||
let c = geo.col_at(sh, p.x);
|
||||
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
|
||||
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
|
||||
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
|
||||
b.min(room).min(MAX_COLS - 1)
|
||||
} else {
|
||||
if p.y <= geo.cells.top() {
|
||||
return 0;
|
||||
}
|
||||
let rr = geo.row_at(sh, p.y);
|
||||
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
|
||||
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
|
||||
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
|
||||
b.min(room).min(MAX_ROWS - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the freeze handles and, while one is dragged, where the freeze line will go.
|
||||
fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) {
|
||||
let (cols, rows) = freeze_handles(geo);
|
||||
let bar = Color32::from_gray(150);
|
||||
p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar);
|
||||
p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar);
|
||||
if let Drag::Freeze { cols, to: Some(to) } = *drag {
|
||||
let stroke = Stroke::new(2.0, t.accent);
|
||||
if cols {
|
||||
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
|
||||
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke);
|
||||
} else {
|
||||
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
|
||||
p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
|
||||
let c = geo.col_at(sh, x);
|
||||
for cc in [c, c.saturating_sub(1)] {
|
||||
|
|
@ -1608,6 +1875,22 @@ fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::mo
|
|||
}
|
||||
}
|
||||
|
||||
fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
|
||||
let Some(id) = app.selected_chart else { return false };
|
||||
let kind = if sh.charts.iter().any(|object| object.id == id) {
|
||||
"chart"
|
||||
} else if sh.images.iter().any(|object| object.id == id) {
|
||||
"image"
|
||||
} else if sh.shapes.iter().any(|object| object.id == id) {
|
||||
"shape"
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
|
||||
app.selected_chart = None;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn follow_link(app: &mut SheetApp, target: &str) {
|
||||
if target.contains("://") || target.starts_with("mailto:") {
|
||||
if let Some(f) = &app.services.open_url {
|
||||
|
|
@ -1656,7 +1939,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
egui::Event::Cut => {
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})),
|
||||
egui::Event::Paste(text) => app.paste_external(text),
|
||||
egui::Event::Text(text) => {
|
||||
if text.chars().all(|c| !c.is_control()) && !text.is_empty() {
|
||||
app.begin_edit(Some(text), false);
|
||||
|
|
@ -1666,8 +1949,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
return;
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
|
||||
egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
|
||||
use egui::Key;
|
||||
if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) {
|
||||
continue;
|
||||
}
|
||||
let cmd = m.command;
|
||||
let shift = m.shift;
|
||||
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
|
||||
|
|
@ -1677,8 +1963,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
match key {
|
||||
Key::ArrowUp => mv(app, -1, 0),
|
||||
Key::ArrowDown => mv(app, 1, 0),
|
||||
Key::ArrowLeft => mv(app, 0, -1),
|
||||
Key::ArrowRight => mv(app, 0, 1),
|
||||
// Arrows follow the screen: on a right-to-left sheet Left goes to the next column.
|
||||
Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }),
|
||||
Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }),
|
||||
Key::PageDown if !cmd => mv(app, page, 0),
|
||||
Key::PageUp if !cmd => mv(app, -page, 0),
|
||||
Key::PageDown => app.run_or_alert("sheet.next", json!({})),
|
||||
|
|
@ -1708,7 +1995,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
}
|
||||
Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }),
|
||||
Key::F2 => app.begin_edit(None, false),
|
||||
Key::Delete if delete_selected_object(app, sh) => {}
|
||||
Key::Delete => app.run_or_alert("edit.clearContents", json!({})),
|
||||
Key::Backspace if delete_selected_object(app, sh) => {}
|
||||
Key::Backspace => {
|
||||
// Excel for Mac: Delete clears the cell and starts editing it.
|
||||
app.run_or_alert("edit.clearContents", json!({}));
|
||||
|
|
@ -1751,15 +2040,24 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
|||
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
|
||||
let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left());
|
||||
let h = (size * 1.3 * lines + 4.0).max(rect.height());
|
||||
let erect = Rect::from_min_size(rect.min, vec2(w, h));
|
||||
// Grows to the left on a right-to-left sheet.
|
||||
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
|
||||
let fill = match st.fill.pattern {
|
||||
PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE),
|
||||
_ => Color32::WHITE,
|
||||
};
|
||||
ui.painter().rect_filled(erect, 0.0, fill);
|
||||
ui.painter().rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
|
||||
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
|
||||
let quadrant = Rect::from_min_max(
|
||||
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
|
||||
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
|
||||
);
|
||||
let clip = ui.clip_rect().intersect(quadrant);
|
||||
let painter = ui.painter().with_clip_rect(clip);
|
||||
painter.rect_filled(erect, 0.0, fill);
|
||||
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
|
||||
let id = egui::Id::new("gridcraft.cell_editor");
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
|
||||
child.set_clip_rect(clip);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0);
|
||||
}
|
||||
|
||||
|
|
@ -1803,6 +2101,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
|||
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
|
||||
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let search_key = egui::Id::new(("filter_search", col));
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let response = area.show(ui.ctx(), |ui| {
|
||||
|
|
@ -1819,19 +2118,32 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
|||
ui.separator();
|
||||
ui.label(egui::RichText::new("Filter").strong());
|
||||
let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());
|
||||
let all = checks.iter().all(|(_, b)| *b);
|
||||
// A search narrows the list; Select All and Apply then only cover the matches.
|
||||
let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default();
|
||||
let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY));
|
||||
if !was_open {
|
||||
field.request_focus();
|
||||
}
|
||||
let needle = search.trim().to_lowercase();
|
||||
let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle);
|
||||
let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b);
|
||||
let mut all_new = all;
|
||||
if ui.checkbox(&mut all_new, "(Select All)").changed() {
|
||||
for c in checks.iter_mut() {
|
||||
let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" };
|
||||
if ui.checkbox(&mut all_new, label).changed() {
|
||||
for c in checks.iter_mut().filter(|(v, _)| matches(v)) {
|
||||
c.1 = all_new;
|
||||
}
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
for (v, on) in checks.iter_mut() {
|
||||
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
|
||||
ui.checkbox(on, v.as_str());
|
||||
}
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(key, checks.clone()));
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(key, checks.clone());
|
||||
d.insert_temp(search_key, search.clone());
|
||||
});
|
||||
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Clear Filter").clicked() {
|
||||
|
|
@ -1853,7 +2165,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
|||
});
|
||||
});
|
||||
if close || popup_dismissed(&response.response, was_open) {
|
||||
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.remove::<Vec<(String, bool)>>(key);
|
||||
d.remove::<String>(search_key);
|
||||
});
|
||||
app.grid.filter_menu = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -1954,7 +2269,7 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
|
|||
/// The dropdown arrow beside a cell with an in-cell list validation.
|
||||
pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
|
||||
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?;
|
||||
if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown {
|
||||
if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) {
|
||||
return None;
|
||||
}
|
||||
let r = geo.cell_rect(sh, c);
|
||||
|
|
@ -1986,10 +2301,11 @@ fn paint_overlays(
|
|||
&& let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active)))
|
||||
&& dv.show_input
|
||||
&& !(dv.input_title.is_empty() && dv.input_message.is_empty())
|
||||
&& !geo.hidden_under_frozen(sh, sel.active)
|
||||
{
|
||||
let r = geo.cell_rect(sh, sel.active);
|
||||
let at = pos2(r.left() + 8.0, r.bottom() + 6.0);
|
||||
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
|
||||
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
|
||||
ui.set_max_width(220.0);
|
||||
if !dv.input_title.is_empty() {
|
||||
|
|
@ -2007,22 +2323,26 @@ fn paint_overlays(
|
|||
continue;
|
||||
}
|
||||
let r = geo.cell_rect(sh, *c);
|
||||
if !p.clip_rect().intersects(r) {
|
||||
if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) {
|
||||
continue;
|
||||
}
|
||||
let at = pos2(r.right() + 10.0, r.top());
|
||||
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
|
||||
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
|
||||
ui.set_max_width(240.0);
|
||||
ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
|
||||
ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
|
||||
for (a, t) in &cm.replies {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
|
||||
}
|
||||
});
|
||||
});
|
||||
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
|
||||
ui.set_max_width(240.0);
|
||||
ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
|
||||
ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
|
||||
for (a, t) in &cm.replies {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
// Hyperlink tooltip.
|
||||
if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c))
|
||||
|
|
@ -2047,7 +2367,9 @@ fn paint_overlays(
|
|||
let Some(t) = target else { continue };
|
||||
let tr = geo.range_rect(sh, t);
|
||||
if !t.is_single() {
|
||||
p.rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
|
||||
for pane in geo.range_panes(t) {
|
||||
p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) };
|
||||
p.line_segment([from, to], Stroke::new(1.5, blue));
|
||||
|
|
@ -2072,8 +2394,9 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
|||
};
|
||||
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
|
||||
let r = geo.cell_rect(sh, c);
|
||||
let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
let mut close = false;
|
||||
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_width(r.width().max(100.0));
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
|
|
@ -2223,3 +2546,67 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
|
|||
}
|
||||
p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pane_tests {
|
||||
use super::*;
|
||||
|
||||
fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
|
||||
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() {
|
||||
// Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10.
|
||||
let frozen_cols = ((2, 20), (0, 1));
|
||||
let frozen_rows = ((0, 1), (2, 10));
|
||||
let corner = ((0, 1), (0, 1));
|
||||
let scrolled = ((2, 20), (2, 10));
|
||||
let sel = rng(5, 4, 7, 6);
|
||||
assert!(range_in_pane(sel, scrolled.0, scrolled.1));
|
||||
assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1));
|
||||
assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1));
|
||||
assert!(!range_in_pane(sel, corner.0, corner.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_across_the_freeze_line_is_drawn_in_both_panes() {
|
||||
let sel = rng(5, 0, 7, 6);
|
||||
assert!(range_in_pane(sel, (2, 20), (0, 1)));
|
||||
assert!(range_in_pane(sel, (2, 20), (2, 10)));
|
||||
}
|
||||
|
||||
/// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns.
|
||||
fn frozen_geo(sh: &mut Sheet) -> Geo {
|
||||
sh.freeze = Some((2, 2));
|
||||
let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0);
|
||||
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_skip_cells_scrolled_under_the_frozen_columns() {
|
||||
let mut sh = Sheet::new("S");
|
||||
let geo = frozen_geo(&mut sh);
|
||||
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2)));
|
||||
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4)));
|
||||
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5)));
|
||||
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0)));
|
||||
let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0));
|
||||
assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_are_clipped_to_the_panes_that_hold_them() {
|
||||
let mut sh = Sheet::new("S");
|
||||
let geo = frozen_geo(&mut sh);
|
||||
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
|
||||
assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]);
|
||||
assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2);
|
||||
assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4);
|
||||
assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]);
|
||||
// The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown.
|
||||
let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom();
|
||||
assert!(handle.x < split.x);
|
||||
assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod cs;
|
||||
mod de;
|
||||
mod es;
|
||||
mod ko;
|
||||
mod pt;
|
||||
mod ru;
|
||||
|
|
@ -26,10 +29,12 @@ pub enum Language {
|
|||
Ru,
|
||||
#[serde(rename = "pt")]
|
||||
PtBr,
|
||||
De,
|
||||
Cs,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 7] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr];
|
||||
pub const ALL: [Self; 9] = [Self::En, Self::Es, Self::Zh, Self::Ja, Self::Ko, Self::Ru, Self::PtBr, Self::De, Self::Cs];
|
||||
|
||||
/// The language's own name, shown in the switcher.
|
||||
pub fn name(self) -> &'static str {
|
||||
|
|
@ -41,6 +46,8 @@ impl Language {
|
|||
Self::Ko => "한국어",
|
||||
Self::Ru => "Русский",
|
||||
Self::PtBr => "Português (Brasil)",
|
||||
Self::De => "Deutsch",
|
||||
Self::Cs => "Čeština",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +61,8 @@ impl Language {
|
|||
Self::Ko => "ko",
|
||||
Self::Ru => "ru",
|
||||
Self::PtBr => "pt",
|
||||
Self::De => "de",
|
||||
Self::Cs => "cs",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +79,8 @@ impl Language {
|
|||
"ko" => Some(Self::Ko),
|
||||
"ru" => Some(Self::Ru),
|
||||
"pt" => Some(Self::PtBr),
|
||||
"de" => Some(Self::De),
|
||||
"cs" => Some(Self::Cs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -97,12 +108,15 @@ impl Language {
|
|||
/// "Orientation") is keyed `context|label`: English shows only the part after the `|`.
|
||||
pub fn tr(self, text: &str) -> &str {
|
||||
let translated = match self {
|
||||
Self::En | Self::Es => None,
|
||||
Self::En => None,
|
||||
Self::Es => es::translate(text),
|
||||
Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated),
|
||||
Self::Zh => zh::translate(text),
|
||||
Self::Ko => ko::translate(text),
|
||||
Self::Ru => ru::translate(text),
|
||||
Self::PtBr => pt::translate(text),
|
||||
Self::De => de::translate(text),
|
||||
Self::Cs => cs::translate(text),
|
||||
};
|
||||
translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label))
|
||||
}
|
||||
|
|
@ -257,6 +271,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Link", "リンク"),
|
||||
("Symbol", "記号"),
|
||||
("Sparklines", "スパークライン"),
|
||||
("Checkbox", "チェックボックス"),
|
||||
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
|
||||
("Header & Footer", "ヘッダーとフッター"),
|
||||
("Draw with ink", "インクで描画"),
|
||||
("Eraser", "消しゴム"),
|
||||
|
|
@ -272,6 +288,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Remove Page Break", "改ページの解除"),
|
||||
("Reset All Page Breaks", "すべての改ページを解除"),
|
||||
("Print\nTitles", "印刷タイトル"),
|
||||
("Sheet\nRight-to-Left", "シートを右から左へ"),
|
||||
("Sheet Right-to-Left", "シートを右から左へ"),
|
||||
("Narrow", "狭い"),
|
||||
("Wide", "広い"),
|
||||
("Normal", "標準"),
|
||||
|
|
@ -313,6 +331,9 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Group", "グループ化"),
|
||||
("Ungroup", "グループ解除"),
|
||||
("Subtotal", "小計"),
|
||||
("Data\nAnalysis", "データ分析"),
|
||||
("Data Analysis", "データ分析"),
|
||||
("Solver", "ソルバー"),
|
||||
("What-If\nAnalysis", "What-If 分析"),
|
||||
("Data Table…", "データ テーブル…"),
|
||||
("Scenario Manager…", "シナリオ マネージャー…"),
|
||||
|
|
@ -322,6 +343,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Threaded Comments", "スレッド化されたコメント"),
|
||||
("Show\nComments", "コメントの表示"),
|
||||
("Protect\nWorkbook", "ブックの保護"),
|
||||
("Allow Edit\nRanges", "範囲の編集を許可"),
|
||||
("Allow Edit Ranges", "範囲の編集を許可"),
|
||||
("Unprotect\nSheet", "シート保護の解除"),
|
||||
("Protect Sheet…", "シートの保護…"),
|
||||
("Comments", "コメント"),
|
||||
|
|
@ -345,6 +368,10 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Light", "ライト"),
|
||||
("Dark", "ダーク"),
|
||||
("Interface language", "表示言語"),
|
||||
("Interface size", "表示サイズ"),
|
||||
("Auto", "自動"),
|
||||
("Drag to freeze columns", "ドラッグして列を固定"),
|
||||
("Drag to freeze rows", "ドラッグして行を固定"),
|
||||
("Command\nPalette", "コマンド パレット"),
|
||||
("Agent\nControl", "エージェント制御"),
|
||||
("Action\nJournal", "操作ジャーナル"),
|
||||
|
|
@ -353,6 +380,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Add Chart\nElement", "グラフ要素を追加"),
|
||||
("Change\nChart Type", "グラフの種類の変更"),
|
||||
("Switch\nRow/Column", "行/列の切り替え"),
|
||||
("Select\nData", "データの選択"),
|
||||
("Data Labels: None", "データ ラベル: なし"),
|
||||
("Data Labels: Show", "データ ラベル: 表示"),
|
||||
("Legend: None", "凡例: なし"),
|
||||
|
|
@ -416,6 +444,67 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("More\nFunctions", "その他の関数"),
|
||||
("Sheet Options|View", "画面"),
|
||||
("Print", "印刷"),
|
||||
// File › Backstage view.
|
||||
("File", "ファイル"),
|
||||
("Back", "戻る"),
|
||||
("New", "新規"),
|
||||
("Open", "開く"),
|
||||
("Info", "情報"),
|
||||
("Save", "上書き保存"),
|
||||
("Save As", "名前を付けて保存"),
|
||||
("Export", "エクスポート"),
|
||||
("Close", "閉じる"),
|
||||
("Options", "オプション"),
|
||||
("Blank workbook", "空白のブック"),
|
||||
("Recent", "最近使ったアイテム"),
|
||||
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
|
||||
("More workbooks", "その他のブック"),
|
||||
("Browse…", "参照…"),
|
||||
("Not saved yet", "まだ保存されていません"),
|
||||
("Location", "場所"),
|
||||
("Sheets", "シート"),
|
||||
("Protect Workbook", "ブックの保護"),
|
||||
(
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted.",
|
||||
"シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。",
|
||||
),
|
||||
(
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted.",
|
||||
"シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。",
|
||||
),
|
||||
("Properties", "プロパティ"),
|
||||
("Title", "タイトル"),
|
||||
("Subject", "件名"),
|
||||
("Author", "作成者"),
|
||||
("Company", "会社名"),
|
||||
("Keywords", "キーワード"),
|
||||
("Description", "説明"),
|
||||
("No workbook is open.", "ブックが開かれていません。"),
|
||||
("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"),
|
||||
("Download (.xlsx)", "ダウンロード (.xlsx)"),
|
||||
("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"),
|
||||
("Print Active Sheets", "作業中のシートを印刷"),
|
||||
("Print Entire Workbook", "ブック全体を印刷"),
|
||||
("Fit Sheet on One Page", "シートを 1 ページに印刷"),
|
||||
("Page Setup…", "ページ設定…"),
|
||||
("Page {page} of {pages}", "{page} / {pages} ページ"),
|
||||
("Nothing to print.", "印刷する内容がありません。"),
|
||||
("Previous page", "前のページ"),
|
||||
("Next page", "次のページ"),
|
||||
("Create PDF", "PDF の作成"),
|
||||
("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"),
|
||||
("Change File Type", "ファイルの種類の変更"),
|
||||
("CSV (Comma delimited)", "CSV (コンマ区切り)"),
|
||||
("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"),
|
||||
("Web Page (.html)", "Web ページ (.html)"),
|
||||
("Save the workbook as a web page.", "ブックを Web ページとして保存します。"),
|
||||
("Options|General", "全般"),
|
||||
("Options|Save", "保存"),
|
||||
("Show formula bar", "数式バーを表示する"),
|
||||
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
|
||||
("Workbook calculation", "ブックの計算"),
|
||||
("Enable iterative calculation", "反復計算を行う"),
|
||||
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -434,22 +523,36 @@ mod tests {
|
|||
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
|
||||
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
|
||||
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
|
||||
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
|
||||
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
|
||||
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("Data"), "データ");
|
||||
assert_eq!(Language::Zh.tr("Data"), "数据");
|
||||
assert_eq!(Language::Ko.tr("Data"), "데이터");
|
||||
assert_eq!(Language::Ru.tr("Data"), "Данные");
|
||||
assert_eq!(Language::PtBr.tr("Data"), "Dados");
|
||||
assert_eq!(Language::Es.tr("Data"), "Datos");
|
||||
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
|
||||
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::De.tr("Data"), "Daten");
|
||||
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::PtBr.tr("Date"), "Data");
|
||||
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
// Czech "Data" is spelled as in English, so probe it with a label that differs.
|
||||
assert_eq!(Language::Cs.tr("Data"), "Data");
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
// A `context|label` key shows only its label in English and splits the translation.
|
||||
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
|
||||
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
|
||||
assert_eq!(Language::Ja.tr("Orientation"), "方向");
|
||||
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
|
||||
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
}
|
||||
|
||||
|
|
@ -476,10 +579,15 @@ mod tests {
|
|||
assert_eq!(Language::from_tag("zh-Hans-CN"), Some(Language::Zh));
|
||||
assert_eq!(Language::from_tag("ko-KR"), Some(Language::Ko));
|
||||
assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru));
|
||||
assert_eq!(Language::from_tag("de-DE"), None);
|
||||
assert_eq!(Language::from_tag("de-DE"), Some(Language::De));
|
||||
assert_eq!(Language::from_tag("de_AT"), Some(Language::De));
|
||||
assert_eq!(Language::from_tag("de-CH"), Some(Language::De));
|
||||
assert_eq!(Language::from_tag("pt-BR"), Some(Language::PtBr));
|
||||
assert_eq!(Language::parse("pt"), Some(Language::PtBr));
|
||||
assert_eq!(Language::parse("pt-br"), Some(Language::PtBr));
|
||||
assert_eq!(Language::from_tag("cs-CZ"), Some(Language::Cs));
|
||||
assert_eq!(Language::from_tag("cs_CZ.UTF-8"), Some(Language::Cs));
|
||||
assert_eq!(Language::parse("cs"), Some(Language::Cs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
//! Czech translations for the interface strings in i18n.rs.
|
||||
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Vložit",
|
||||
"Paste Values" => "Vložit hodnoty",
|
||||
"Paste Formulas" => "Vložit vzorce",
|
||||
"Paste Formatting" => "Vložit formátování",
|
||||
"Transpose" => "Transponovat",
|
||||
"Paste Link" => "Vložit propojení",
|
||||
"Paste Special…" => "Vložit jinak…",
|
||||
"Cut (⌘X)" => "Vyjmout (⌘X)",
|
||||
"Copy (⌘C)" => "Kopírovat (⌘C)",
|
||||
"Format Painter (double-click to keep it on)" => "Štětec formátu (dvojklikem zůstane zapnutý)",
|
||||
"Bold (⌘B)" => "Tučné (⌘B)",
|
||||
"Italic (⌘I)" => "Kurzíva (⌘I)",
|
||||
"Underline (⌘U)" => "Podtržení (⌘U)",
|
||||
"Double Underline" => "Dvojité podtržení",
|
||||
"Strikethrough" => "Přeškrtnutí",
|
||||
"Underline" => "Podtržení",
|
||||
"Increase Font Size" => "Zvětšit písmo",
|
||||
"Decrease Font Size" => "Zmenšit písmo",
|
||||
"Wrap Text" => "Zalamovat text",
|
||||
"Merge & Center" => "Sloučit a vycentrovat",
|
||||
"Merge Cells" => "Sloučit buňky",
|
||||
"Merge Across" => "Sloučit po řádcích",
|
||||
"Unmerge Cells" => "Zrušit sloučení buněk",
|
||||
"Fill Color" => "Barva výplně",
|
||||
"Font Color" => "Barva písma",
|
||||
"Borders" => "Ohraničení",
|
||||
"Top\nAlign" => "Zarovnat\nnahoru",
|
||||
"Middle\nAlign" => "Zarovnat\ndoprostřed",
|
||||
"Bottom\nAlign" => "Zarovnat\ndolů",
|
||||
"Left" => "Doleva",
|
||||
"Right" => "Doprava",
|
||||
"Center" => "Zarovnat na střed",
|
||||
"Horizontal" => "Vodorovně",
|
||||
"Vertical Text" => "Svislý text",
|
||||
"Decrease Indent" => "Zmenšit odsazení",
|
||||
"Increase Indent" => "Zvětšit odsazení",
|
||||
"Orientation" => "Orientace textu",
|
||||
"Page Layout|Orientation" => "Orientace",
|
||||
"Angle Counterclockwise" => "Natočit proti směru hodin",
|
||||
"Angle Clockwise" => "Natočit po směru hodin",
|
||||
"Rotate Text Up" => "Otočit text nahoru",
|
||||
"Rotate Text Down" => "Otočit text dolů",
|
||||
"Accounting Number Format" => "Účetnický formát čísla",
|
||||
"Percent Style" => "Styl procent",
|
||||
"Comma Style" => "Styl s oddělovačem tisíců",
|
||||
"Increase Decimal" => "Přidat desetinné místo",
|
||||
"Decrease Decimal" => "Odebrat desetinné místo",
|
||||
"More Accounting Formats…" => "Další účetnické formáty…",
|
||||
"Conditional\nFormatting" => "Podmíněné\nformátování",
|
||||
"Conditional Formatting" => "Podmíněné formátování",
|
||||
"Format\nas Table" => "Formátovat\njako tabulku",
|
||||
"Cell\nStyles" => "Styly\nbuněk",
|
||||
"Format Cells…" => "Formát buněk…",
|
||||
"Format Cell Alignment…" => "Zarovnání buněk…",
|
||||
"Format\nPane" => "Podokno\nformátu",
|
||||
"Format" => "Formát",
|
||||
"Insert" => "Vložit",
|
||||
"Insert Cells…" => "Vložit buňky…",
|
||||
"Delete" => "Odstranit",
|
||||
"Delete Cells…" => "Odstranit buňky…",
|
||||
"Insert Sheet Rows" => "Vložit řádky listu",
|
||||
"Insert Sheet Columns" => "Vložit sloupce listu",
|
||||
"Delete Sheet Rows" => "Odstranit řádky listu",
|
||||
"Delete Sheet Columns" => "Odstranit sloupce listu",
|
||||
"Row Height…" => "Výška řádku…",
|
||||
"AutoFit Row Height" => "Přizpůsobit výšku řádku",
|
||||
"Column Width…" => "Šířka sloupce…",
|
||||
"AutoFit Column Width" => "Přizpůsobit šířku sloupce",
|
||||
"Default Width…" => "Výchozí šířka…",
|
||||
"Hide Rows" => "Skrýt řádky",
|
||||
"Unhide Rows" => "Zobrazit řádky",
|
||||
"Hide Columns" => "Skrýt sloupce",
|
||||
"Unhide Columns" => "Zobrazit sloupce",
|
||||
"Lock Cell" => "Zamknout buňku",
|
||||
"Clear" => "Vymazat",
|
||||
"Clear All" => "Vymazat vše",
|
||||
"Clear Formats" => "Vymazat formáty",
|
||||
"Clear Contents" => "Vymazat obsah",
|
||||
"Clear Comments and Notes" => "Vymazat komentáře a poznámky",
|
||||
"Clear Hyperlinks" => "Vymazat hypertextové odkazy",
|
||||
"AutoSum" => "Automatický\nsoučet",
|
||||
"AutoSum (⌘⇧T)" => "Automatický součet (⌘⇧T)",
|
||||
"Flash\nFill" => "Dynamické\ndoplňování",
|
||||
"Flash Fill" => "Dynamické doplňování",
|
||||
"Fill" => "Vyplnit",
|
||||
"Clear Print Area" => "Vymazat oblast tisku",
|
||||
"Find &\nSelect" => "Najít a\nvybrat",
|
||||
"Find…" => "Najít…",
|
||||
"Replace…" => "Nahradit…",
|
||||
"Go To…" => "Přejít na…",
|
||||
"Go To Special…" => "Přejít na jinak…",
|
||||
"PivotTable" => "Kontingenční\ntabulka",
|
||||
"Recommended\nCharts" => "Doporučené\ngrafy",
|
||||
"Table" => "Tabulka",
|
||||
"Pictures" => "Obrázky",
|
||||
"Shapes" => "Obrazce",
|
||||
"Icons" => "Ikony",
|
||||
"Text\nBox" => "Textové\npole",
|
||||
"Comment" => "Komentář",
|
||||
"New\nComment" => "Nový\nkomentář",
|
||||
"New Note" => "Nová poznámka",
|
||||
"Show/Hide Note" => "Zobrazit/skrýt poznámku",
|
||||
"Header &\nFooter" => "Záhlaví a\nzápatí",
|
||||
"Text to\nColumns" => "Text do\nsloupců",
|
||||
"Link" => "Odkaz",
|
||||
"Symbol" => "Symbol",
|
||||
"Sparklines" => "Minigrafy",
|
||||
"Header & Footer" => "Záhlaví a zápatí",
|
||||
"Draw with ink" => "Kreslit od ruky",
|
||||
"Eraser" => "Guma",
|
||||
"Ink to\nShape" => "Rukopis\nna obrazec",
|
||||
"Select\nObjects" => "Vybrat\nobjekty",
|
||||
"Themes" => "Motivy",
|
||||
"Margins" => "Okraje",
|
||||
"Size" => "Velikost",
|
||||
"Print\nArea" => "Oblast\ntisku",
|
||||
"Set Print Area" => "Nastavit oblast tisku",
|
||||
"Breaks" => "Konce",
|
||||
"Insert Page Break" => "Vložit konec stránky",
|
||||
"Remove Page Break" => "Odebrat konec stránky",
|
||||
"Reset All Page Breaks" => "Obnovit všechny konce stránek",
|
||||
"Print\nTitles" => "Tisk\nnázvů",
|
||||
"Narrow" => "Úzké",
|
||||
"Wide" => "Široké",
|
||||
"Normal" => "Normální",
|
||||
"Portrait" => "Na výšku",
|
||||
"Landscape" => "Na šířku",
|
||||
"Custom Margins…" => "Vlastní okraje…",
|
||||
"Insert\nFunction" => "Vložit\nfunkci",
|
||||
"Name\nManager" => "Správce\nnázvů",
|
||||
"Define Name" => "Definovat název",
|
||||
"Use in Formula" => "Použít ve vzorci",
|
||||
"Create from Selection" => "Vytvořit z výběru",
|
||||
"Trace Precedents" => "Sledovat předchůdce",
|
||||
"Trace Dependents" => "Sledovat následníky",
|
||||
"Remove Arrows" => "Odebrat šipky",
|
||||
"Show Formulas" => "Zobrazit vzorce",
|
||||
"Error Checking" => "Kontrola chyb",
|
||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||
"Watch Window" => "Okno sledování",
|
||||
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
||||
"Calculate Now" => "Přepočítat",
|
||||
"Calculate Sheet" => "Přepočítat list",
|
||||
"Automatic" => "Automaticky",
|
||||
"Automatic Except for Data Tables" => "Automaticky kromě tabulek dat",
|
||||
"Manual" => "Ručně",
|
||||
"Sort &\nFilter" => "Seřadit a\nfiltrovat",
|
||||
"Sort" => "Seřadit",
|
||||
"Sort A to Z" => "Seřadit od A do Z",
|
||||
"Sort Z to A" => "Seřadit od Z do A",
|
||||
"Custom Sort…" => "Vlastní řazení…",
|
||||
"Filter" => "Filtr",
|
||||
"Reapply" => "Použít znovu",
|
||||
"Get Data\n(Text/CSV)" => "Načíst data\n(text/CSV)",
|
||||
"Remove\nDuplicates" => "Odebrat\nduplicity",
|
||||
"Data\nValidation" => "Ověření\ndat",
|
||||
"Data Validation" => "Ověření dat",
|
||||
"Data Validation…" => "Ověření dat…",
|
||||
"Circle Invalid Data" => "Zakroužkovat neplatná data",
|
||||
"Clear Validation" => "Vymazat ověření",
|
||||
"Group" => "Seskupit",
|
||||
"Ungroup" => "Zrušit seskupení",
|
||||
"Subtotal" => "Mezisoučet",
|
||||
"What-If\nAnalysis" => "Citlivostní\nanalýza",
|
||||
"Data Table…" => "Tabulka dat…",
|
||||
"Scenario Manager…" => "Správce scénářů…",
|
||||
"Goal Seek…" => "Hledat řešení…",
|
||||
"Spelling" => "Pravopis",
|
||||
"Check\nAccessibility" => "Zkontrolovat\npřístupnost",
|
||||
"Threaded Comments" => "Komentáře ve vláknech",
|
||||
"Show\nComments" => "Zobrazit\nkomentáře",
|
||||
"Protect\nWorkbook" => "Zamknout\nsešit",
|
||||
"Unprotect\nSheet" => "Odemknout\nlist",
|
||||
"Protect Sheet…" => "Zamknout list…",
|
||||
"Comments" => "Komentáře",
|
||||
"Notes" => "Poznámky",
|
||||
"Page Break\nPreview" => "Náhled\nkonců stránek",
|
||||
"Page\nLayout" => "Rozložení\nstránky",
|
||||
"Formula Bar" => "Řádek vzorců",
|
||||
"Gridlines" => "Mřížka",
|
||||
"Headings" => "Záhlaví",
|
||||
"Zoom" => "Lupa",
|
||||
"Checkbox" => "Zaškrtávací políčko",
|
||||
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
|
||||
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
|
||||
"Sheet Right-to-Left" => "List zprava doleva",
|
||||
"Data\nAnalysis" => "Analýza\ndat",
|
||||
"Data Analysis" => "Analýza dat",
|
||||
"Solver" => "Řešitel",
|
||||
"Allow Edit\nRanges" => "Povolit úpravy\noblastí",
|
||||
"Allow Edit Ranges" => "Povolit úpravy oblastí",
|
||||
"Interface size" => "Velikost rozhraní",
|
||||
"Auto" => "Automaticky",
|
||||
"Drag to freeze columns" => "Přetažením ukotvíte sloupce",
|
||||
"Drag to freeze rows" => "Přetažením ukotvíte řádky",
|
||||
"Select\nData" => "Vybrat\ndata",
|
||||
"File" => "Soubor",
|
||||
"Back" => "Zpět",
|
||||
"New" => "Nový",
|
||||
"Open" => "Otevřít",
|
||||
"Info" => "Informace",
|
||||
"Save" => "Uložit",
|
||||
"Save As" => "Uložit jako",
|
||||
"Export" => "Exportovat",
|
||||
"Close" => "Zavřít",
|
||||
"Options" => "Možnosti",
|
||||
"Blank workbook" => "Prázdný sešit",
|
||||
"Recent" => "Naposledy použité",
|
||||
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
|
||||
"More workbooks" => "Další sešity",
|
||||
"Browse…" => "Procházet…",
|
||||
"Not saved yet" => "Zatím neuloženo",
|
||||
"Location" => "Umístění",
|
||||
"Sheets" => "Listy",
|
||||
"Protect Workbook" => "Zamknout sešit",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"Struktura listů je zamčená: listy nelze přidávat, přesouvat, přejmenovávat ani odstraňovat."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"Zamkne strukturu listů, aby listy nebylo možné přidávat, přesouvat, přejmenovávat ani odstraňovat."
|
||||
}
|
||||
"Properties" => "Vlastnosti",
|
||||
"Title" => "Název",
|
||||
"Subject" => "Předmět",
|
||||
"Author" => "Autor",
|
||||
"Company" => "Společnost",
|
||||
"Keywords" => "Klíčová slova",
|
||||
"Description" => "Popis",
|
||||
"No workbook is open." => "Není otevřený žádný sešit.",
|
||||
"Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.",
|
||||
"Download (.xlsx)" => "Stáhnout (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.",
|
||||
"Print Active Sheets" => "Vytisknout aktivní listy",
|
||||
"Print Entire Workbook" => "Vytisknout celý sešit",
|
||||
"Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku",
|
||||
"Page Setup…" => "Vzhled stránky…",
|
||||
"Page {page} of {pages}" => "Stránka {page} z {pages}",
|
||||
"Nothing to print." => "Není co tisknout.",
|
||||
"Previous page" => "Předchozí stránka",
|
||||
"Next page" => "Další stránka",
|
||||
"Create PDF" => "Vytvořit PDF",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Uloží list jako dokument PDF se zachováním rozložení stránky.",
|
||||
"Change File Type" => "Změnit typ souboru",
|
||||
"CSV (Comma delimited)" => "CSV (oddělený čárkami)",
|
||||
"Save the active sheet as comma-separated text." => "Uloží aktivní list jako text oddělený čárkami.",
|
||||
"Web Page (.html)" => "Webová stránka (.html)",
|
||||
"Save the workbook as a web page." => "Uloží sešit jako webovou stránku.",
|
||||
"Options|General" => "Obecné",
|
||||
"Options|Save" => "Uložit",
|
||||
"Show formula bar" => "Zobrazit řádek vzorců",
|
||||
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
|
||||
"Workbook calculation" => "Výpočet sešitu",
|
||||
"Enable iterative calculation" => "Povolit iterativní výpočet",
|
||||
"Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.",
|
||||
"100%" => "100 %",
|
||||
"Zoom to\nSelection" => "Přiblížit\nna výběr",
|
||||
"Freeze\nPanes" => "Ukotvit\npříčky",
|
||||
"Freeze Panes" => "Ukotvit příčky",
|
||||
"Freeze Top Row" => "Ukotvit horní řádek",
|
||||
"Freeze First Column" => "Ukotvit první sloupec",
|
||||
"Unfreeze Panes" => "Uvolnit příčky",
|
||||
"Dark Mode" => "Tmavý režim",
|
||||
"Display theme" => "Vzhled",
|
||||
"System" => "Podle systému",
|
||||
"Light" => "Světlý",
|
||||
"Dark" => "Tmavý",
|
||||
"Interface language" => "Jazyk rozhraní",
|
||||
"Command\nPalette" => "Paleta\npříkazů",
|
||||
"Agent\nControl" => "Řízení\nagenty",
|
||||
"Action\nJournal" => "Deník\nakcí",
|
||||
"About\nGridCraft" => "O aplikaci\nGridCraft",
|
||||
"Share" => "Sdílet",
|
||||
"Add Chart\nElement" => "Přidat prvek\ngrafu",
|
||||
"Change\nChart Type" => "Změnit\ntyp grafu",
|
||||
"Switch\nRow/Column" => "Prohodit\nřádky/sloupce",
|
||||
"Data Labels: None" => "Popisky dat: žádné",
|
||||
"Data Labels: Show" => "Popisky dat: zobrazit",
|
||||
"Legend: None" => "Legenda: žádná",
|
||||
"Legend: Bottom" => "Legenda: dole",
|
||||
"Legend: Top" => "Legenda: nahoře",
|
||||
"Legend: Right" => "Legenda: vpravo",
|
||||
"Gridlines: None" => "Mřížka: žádná",
|
||||
"Gridlines: Show" => "Mřížka: zobrazit",
|
||||
"Chart\nTitle" => "Název\ngrafu",
|
||||
"Delete\nChart" => "Odstranit\ngraf",
|
||||
"Convert\nto Range" => "Převést\nna oblast",
|
||||
"Table\nStyles" => "Styly\ntabulky",
|
||||
"Top 10 Items" => "Prvních 10 položek",
|
||||
"Top 10%" => "Prvních 10 %",
|
||||
"Bottom 10 Items" => "Posledních 10 položek",
|
||||
"Bottom 10%" => "Posledních 10 %",
|
||||
"Above Average" => "Nad průměrem",
|
||||
"Below Average" => "Pod průměrem",
|
||||
"Clear Rules from Entire Sheet" => "Vymazat pravidla z celého listu",
|
||||
"Clear Rules from Selected Cells" => "Vymazat pravidla z vybraných buněk",
|
||||
"Constants" => "Konstanty",
|
||||
"Data Bars" => "Datové pruhy",
|
||||
"Color Scales" => "Barevné škály",
|
||||
"Icon Sets" => "Sady ikon",
|
||||
"Home" => "Domů",
|
||||
"Draw" => "Kreslení",
|
||||
"Page Layout" => "Rozložení stránky",
|
||||
"Formulas" => "Vzorce",
|
||||
"Data" => "Data",
|
||||
"Review" => "Revize",
|
||||
"View" => "Zobrazení",
|
||||
"Automate" => "Automatizace",
|
||||
"Table Design" => "Návrh tabulky",
|
||||
"Chart Design" => "Návrh grafu",
|
||||
"Ready" => "Připraveno",
|
||||
"Enter" => "Zadat",
|
||||
"Edit" => "Úpravy",
|
||||
"Point" => "Výběr odkazu",
|
||||
"Calculate" => "Přepočítat",
|
||||
"Select destination and press Enter or choose Paste" => "Vyberte cíl a stiskněte Enter nebo zvolte Vložit",
|
||||
"General" => "Obecný",
|
||||
"Number" => "Číslo",
|
||||
"Currency" => "Měna",
|
||||
"Accounting" => "Účetnický",
|
||||
"Date" => "Datum",
|
||||
"Time" => "Čas",
|
||||
"Percentage" => "Procenta",
|
||||
"Fraction" => "Zlomek",
|
||||
"Scientific" => "Vědecký",
|
||||
"Text" => "Text",
|
||||
"Special" => "Speciální",
|
||||
"Custom" => "Vlastní",
|
||||
"Short Date" => "Krátké datum",
|
||||
"Long Date" => "Dlouhé datum",
|
||||
"More Number Formats…" => "Další formáty čísel…",
|
||||
"Financial" => "Finanční",
|
||||
"Logical" => "Logické",
|
||||
"Date &\nTime" => "Datum a\nčas",
|
||||
"Lookup &\nReference" => "Vyhledávání\na odkazy",
|
||||
"Math &\nTrig" => "Matematika a\ntrigonometrie",
|
||||
"More\nFunctions" => "Další\nfunkce",
|
||||
"Sheet Options|View" => "Zobrazit",
|
||||
"Print" => "Tisknout",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
//! German translations for the interface strings in i18n.rs. Labels follow the wording German
|
||||
//! spreadsheet users know; anything absent falls back to English.
|
||||
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Einfügen",
|
||||
"Paste Values" => "Werte einfügen",
|
||||
"Paste Formulas" => "Formeln einfügen",
|
||||
"Paste Formatting" => "Formatierung einfügen",
|
||||
"Transpose" => "Transponieren",
|
||||
"Paste Link" => "Verknüpfung einfügen",
|
||||
"Paste Special…" => "Inhalte einfügen…",
|
||||
"Cut (⌘X)" => "Ausschneiden (⌘X)",
|
||||
"Copy (⌘C)" => "Kopieren (⌘C)",
|
||||
"Format Painter (double-click to keep it on)" => "Format übertragen (Doppelklick zum Beibehalten)",
|
||||
"Bold (⌘B)" => "Fett (⌘B)",
|
||||
"Italic (⌘I)" => "Kursiv (⌘I)",
|
||||
"Underline (⌘U)" => "Unterstrichen (⌘U)",
|
||||
"Double Underline" => "Doppelt unterstrichen",
|
||||
"Strikethrough" => "Durchgestrichen",
|
||||
"Underline" => "Unterstrichen",
|
||||
"Increase Font Size" => "Schriftgrad vergrößern",
|
||||
"Decrease Font Size" => "Schriftgrad verkleinern",
|
||||
"Wrap Text" => "Zeilenumbruch",
|
||||
"Merge & Center" => "Verbinden und zentrieren",
|
||||
"Merge Cells" => "Zellen verbinden",
|
||||
"Merge Across" => "Zeilenweise verbinden",
|
||||
"Unmerge Cells" => "Zellverbindung aufheben",
|
||||
"Fill Color" => "Füllfarbe",
|
||||
"Font Color" => "Schriftfarbe",
|
||||
"Borders" => "Rahmen",
|
||||
"Top\nAlign" => "Oben\nausrichten",
|
||||
"Middle\nAlign" => "Vertikal\nzentrieren",
|
||||
"Bottom\nAlign" => "Unten\nausrichten",
|
||||
"Left" => "Links",
|
||||
"Right" => "Rechts",
|
||||
"Center" => "Zentriert",
|
||||
"Horizontal" => "Horizontal",
|
||||
"Vertical Text" => "Vertikaler Text",
|
||||
"Decrease Indent" => "Einzug verkleinern",
|
||||
"Increase Indent" => "Einzug vergrößern",
|
||||
"Orientation" => "Ausrichtung",
|
||||
"Angle Counterclockwise" => "Gegen den Uhrzeigersinn neigen",
|
||||
"Angle Clockwise" => "Im Uhrzeigersinn neigen",
|
||||
"Rotate Text Up" => "Text nach oben drehen",
|
||||
"Rotate Text Down" => "Text nach unten drehen",
|
||||
"Accounting Number Format" => "Buchhaltungszahlenformat",
|
||||
"Percent Style" => "Prozentformat",
|
||||
"Comma Style" => "Kommaformat",
|
||||
"Increase Decimal" => "Dezimalstelle hinzufügen",
|
||||
"Decrease Decimal" => "Dezimalstelle entfernen",
|
||||
"More Accounting Formats…" => "Weitere Buchhaltungsformate…",
|
||||
"Conditional\nFormatting" => "Bedingte\nFormatierung",
|
||||
"Conditional Formatting" => "Bedingte Formatierung",
|
||||
"Format\nas Table" => "Als Tabelle\nformatieren",
|
||||
"Cell\nStyles" => "Zell-\nformate",
|
||||
"Format Cells…" => "Zellen formatieren…",
|
||||
"Format Cell Alignment…" => "Zellausrichtung…",
|
||||
"Format\nPane" => "Format-\nbereich",
|
||||
"Format" => "Format",
|
||||
"Insert" => "Einfügen",
|
||||
"Insert Cells…" => "Zellen einfügen…",
|
||||
"Delete" => "Löschen",
|
||||
"Delete Cells…" => "Zellen löschen…",
|
||||
"Insert Sheet Rows" => "Blattzeilen einfügen",
|
||||
"Insert Sheet Columns" => "Blattspalten einfügen",
|
||||
"Delete Sheet Rows" => "Blattzeilen löschen",
|
||||
"Delete Sheet Columns" => "Blattspalten löschen",
|
||||
"Row Height…" => "Zeilenhöhe…",
|
||||
"AutoFit Row Height" => "Zeilenhöhe automatisch anpassen",
|
||||
"Column Width…" => "Spaltenbreite…",
|
||||
"AutoFit Column Width" => "Spaltenbreite automatisch anpassen",
|
||||
"Default Width…" => "Standardbreite…",
|
||||
"Hide Rows" => "Zeilen ausblenden",
|
||||
"Unhide Rows" => "Zeilen einblenden",
|
||||
"Hide Columns" => "Spalten ausblenden",
|
||||
"Unhide Columns" => "Spalten einblenden",
|
||||
"Lock Cell" => "Zelle sperren",
|
||||
"Clear" => "Löschen",
|
||||
"Clear All" => "Alles löschen",
|
||||
"Clear Formats" => "Formate löschen",
|
||||
"Clear Contents" => "Inhalte löschen",
|
||||
"Clear Comments and Notes" => "Kommentare und Notizen löschen",
|
||||
"Clear Hyperlinks" => "Hyperlinks löschen",
|
||||
"AutoSum" => "AutoSumme",
|
||||
"AutoSum (⌘⇧T)" => "AutoSumme (⌘⇧T)",
|
||||
"Flash\nFill" => "Blitz-\nvorschau",
|
||||
"Flash Fill" => "Blitzvorschau",
|
||||
"Fill" => "Ausfüllen",
|
||||
"Clear Print Area" => "Druckbereich aufheben",
|
||||
"Find &\nSelect" => "Suchen und\nAuswählen",
|
||||
"Find…" => "Suchen…",
|
||||
"Replace…" => "Ersetzen…",
|
||||
"Go To…" => "Gehe zu…",
|
||||
"Go To Special…" => "Inhalte auswählen…",
|
||||
"PivotTable" => "PivotTable",
|
||||
"Recommended\nCharts" => "Empfohlene\nDiagramme",
|
||||
"Table" => "Tabelle",
|
||||
"Pictures" => "Bilder",
|
||||
"Shapes" => "Formen",
|
||||
"Icons" => "Symbole",
|
||||
"Text\nBox" => "Text-\nfeld",
|
||||
"Comment" => "Kommentar",
|
||||
"New\nComment" => "Neuer\nKommentar",
|
||||
"New Note" => "Neue Notiz",
|
||||
"Show/Hide Note" => "Notiz anzeigen/ausblenden",
|
||||
"Header &\nFooter" => "Kopf- und\nFußzeile",
|
||||
"Text to\nColumns" => "Text in\nSpalten",
|
||||
"Link" => "Link",
|
||||
"Symbol" => "Symbol",
|
||||
"Sparklines" => "Sparklines",
|
||||
"Header & Footer" => "Kopf- und Fußzeile",
|
||||
"Draw with ink" => "Mit Freihand zeichnen",
|
||||
"Eraser" => "Radierer",
|
||||
"Ink to\nShape" => "Freihand in\nForm umwandeln",
|
||||
"Select\nObjects" => "Objekte\nauswählen",
|
||||
"Themes" => "Designs",
|
||||
"Margins" => "Seitenränder",
|
||||
"Size" => "Größe",
|
||||
"Print\nArea" => "Druck-\nbereich",
|
||||
"Set Print Area" => "Druckbereich festlegen",
|
||||
"Breaks" => "Umbrüche",
|
||||
"Insert Page Break" => "Seitenumbruch einfügen",
|
||||
"Remove Page Break" => "Seitenumbruch entfernen",
|
||||
"Reset All Page Breaks" => "Alle Seitenumbrüche zurücksetzen",
|
||||
"Print\nTitles" => "Druck-\ntitel",
|
||||
"Narrow" => "Schmal",
|
||||
"Wide" => "Breit",
|
||||
"Normal" => "Normal",
|
||||
"Portrait" => "Hochformat",
|
||||
"Landscape" => "Querformat",
|
||||
"Custom Margins…" => "Benutzerdefinierte Seitenränder…",
|
||||
"Insert\nFunction" => "Funktion\neinfügen",
|
||||
"Name\nManager" => "Namens-\nmanager",
|
||||
"Define Name" => "Namen definieren",
|
||||
"Use in Formula" => "In Formel verwenden",
|
||||
"Create from Selection" => "Aus Auswahl erstellen",
|
||||
"Trace Precedents" => "Spur zum Vorgänger",
|
||||
"Trace Dependents" => "Spur zum Nachfolger",
|
||||
"Remove Arrows" => "Pfeile entfernen",
|
||||
"Show Formulas" => "Formeln anzeigen",
|
||||
"Error Checking" => "Fehlerüberprüfung",
|
||||
"Evaluate Formula" => "Formel auswerten",
|
||||
"Watch Window" => "Überwachungsfenster",
|
||||
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
||||
"Calculate Now" => "Jetzt berechnen",
|
||||
"Calculate Sheet" => "Blatt berechnen",
|
||||
"Automatic" => "Automatisch",
|
||||
"Automatic Except for Data Tables" => "Automatisch, außer für Datentabellen",
|
||||
"Manual" => "Manuell",
|
||||
"Sort &\nFilter" => "Sortieren und\nFiltern",
|
||||
"Sort" => "Sortieren",
|
||||
"Sort A to Z" => "Von A bis Z sortieren",
|
||||
"Sort Z to A" => "Von Z bis A sortieren",
|
||||
"Custom Sort…" => "Benutzerdefiniertes Sortieren…",
|
||||
"Filter" => "Filtern",
|
||||
"Reapply" => "Erneut anwenden",
|
||||
"Get Data\n(Text/CSV)" => "Daten abrufen\n(Text/CSV)",
|
||||
"Remove\nDuplicates" => "Duplikate\nentfernen",
|
||||
"Data\nValidation" => "Daten-\nüberprüfung",
|
||||
"Data Validation" => "Datenüberprüfung",
|
||||
"Data Validation…" => "Datenüberprüfung…",
|
||||
"Circle Invalid Data" => "Ungültige Daten einkreisen",
|
||||
"Clear Validation" => "Datenüberprüfung löschen",
|
||||
"Group" => "Gruppieren",
|
||||
"Ungroup" => "Gruppierung aufheben",
|
||||
"Subtotal" => "Teilergebnis",
|
||||
"What-If\nAnalysis" => "Was-wäre-wenn-\nAnalyse",
|
||||
"Data Table…" => "Datentabelle…",
|
||||
"Scenario Manager…" => "Szenario-Manager…",
|
||||
"Goal Seek…" => "Zielwertsuche…",
|
||||
"Spelling" => "Rechtschreibung",
|
||||
"Check\nAccessibility" => "Barrierefreiheit\nprüfen",
|
||||
"Threaded Comments" => "Threadkommentare",
|
||||
"Show\nComments" => "Kommentare\nanzeigen",
|
||||
"Protect\nWorkbook" => "Arbeitsmappe\nschützen",
|
||||
"Unprotect\nSheet" => "Blattschutz\naufheben",
|
||||
"Protect Sheet…" => "Blatt schützen…",
|
||||
"Comments" => "Kommentare",
|
||||
"Notes" => "Notizen",
|
||||
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
|
||||
"Page\nLayout" => "Seiten-\nlayout",
|
||||
"Formula Bar" => "Formelleiste",
|
||||
"Gridlines" => "Gitternetzlinien",
|
||||
"Headings" => "Überschriften",
|
||||
"Zoom" => "Zoom",
|
||||
"Checkbox" => "Kontrollkästchen",
|
||||
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
|
||||
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
|
||||
"Sheet Right-to-Left" => "Blatt von rechts nach links",
|
||||
"Data\nAnalysis" => "Daten-\nanalyse",
|
||||
"Data Analysis" => "Datenanalyse",
|
||||
"Solver" => "Solver",
|
||||
"Allow Edit\nRanges" => "Bearbeiten von\nBereichen zulassen",
|
||||
"Allow Edit Ranges" => "Bearbeiten von Bereichen zulassen",
|
||||
"Drag to freeze columns" => "Ziehen, um Spalten zu fixieren",
|
||||
"Drag to freeze rows" => "Ziehen, um Zeilen zu fixieren",
|
||||
"Select\nData" => "Daten\nauswählen",
|
||||
"File" => "Datei",
|
||||
"Back" => "Zurück",
|
||||
"New" => "Neu",
|
||||
"Open" => "Öffnen",
|
||||
"Info" => "Informationen",
|
||||
"Save" => "Speichern",
|
||||
"Save As" => "Speichern unter",
|
||||
"Export" => "Exportieren",
|
||||
"Close" => "Schließen",
|
||||
"Options" => "Optionen",
|
||||
"Blank workbook" => "Leere Arbeitsmappe",
|
||||
"Recent" => "Zuletzt verwendet",
|
||||
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
|
||||
"More workbooks" => "Weitere Arbeitsmappen",
|
||||
"Browse…" => "Durchsuchen…",
|
||||
"Not saved yet" => "Noch nicht gespeichert",
|
||||
"Location" => "Speicherort",
|
||||
"Sheets" => "Blätter",
|
||||
"Protect Workbook" => "Arbeitsmappe schützen",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"Die Blattstruktur ist gesperrt: Blätter können nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"Sperrt die Blattstruktur, sodass Blätter nicht hinzugefügt, verschoben, umbenannt oder gelöscht werden können."
|
||||
}
|
||||
"Properties" => "Eigenschaften",
|
||||
"Title" => "Titel",
|
||||
"Subject" => "Thema",
|
||||
"Author" => "Autor",
|
||||
"Company" => "Firma",
|
||||
"Keywords" => "Stichwörter",
|
||||
"Description" => "Beschreibung",
|
||||
"No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.",
|
||||
"Save the workbook under another name or in another folder." => {
|
||||
"Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner."
|
||||
}
|
||||
"Download (.xlsx)" => "Herunterladen (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.",
|
||||
"Print Active Sheets" => "Aktive Blätter drucken",
|
||||
"Print Entire Workbook" => "Gesamte Arbeitsmappe drucken",
|
||||
"Fit Sheet on One Page" => "Blatt auf einer Seite darstellen",
|
||||
"Page Setup…" => "Seite einrichten…",
|
||||
"Page {page} of {pages}" => "Seite {page} von {pages}",
|
||||
"Nothing to print." => "Nichts zu drucken.",
|
||||
"Previous page" => "Vorherige Seite",
|
||||
"Next page" => "Nächste Seite",
|
||||
"Create PDF" => "PDF erstellen",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Speichert das Blatt als PDF-Dokument mit unverändertem Seitenlayout.",
|
||||
"Change File Type" => "Dateityp ändern",
|
||||
"CSV (Comma delimited)" => "CSV (Trennzeichen-getrennt)",
|
||||
"Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Kommas getrennten Text.",
|
||||
"Web Page (.html)" => "Webseite (.html)",
|
||||
"Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.",
|
||||
"Options|General" => "Allgemein",
|
||||
"Options|Save" => "Speichern",
|
||||
"Show formula bar" => "Bearbeitungsleiste anzeigen",
|
||||
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
|
||||
"Workbook calculation" => "Arbeitsmappenberechnung",
|
||||
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
|
||||
"Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.",
|
||||
"Interface size" => "Größe der Benutzeroberfläche",
|
||||
"Auto" => "Automatisch",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Zoom auf die\nAuswahl",
|
||||
"Freeze\nPanes" => "Fenster\nfixieren",
|
||||
"Freeze Panes" => "Fenster fixieren",
|
||||
"Freeze Top Row" => "Oberste Zeile fixieren",
|
||||
"Freeze First Column" => "Erste Spalte fixieren",
|
||||
"Unfreeze Panes" => "Fixierung aufheben",
|
||||
"Dark Mode" => "Dunkler Modus",
|
||||
"Interface language" => "Anzeigesprache",
|
||||
"Command\nPalette" => "Befehls-\npalette",
|
||||
"Agent\nControl" => "Agenten-\nsteuerung",
|
||||
"Action\nJournal" => "Aktions-\nprotokoll",
|
||||
"About\nGridCraft" => "Über\nGridCraft",
|
||||
"Share" => "Teilen",
|
||||
"Add Chart\nElement" => "Diagrammelement\nhinzufügen",
|
||||
"Change\nChart Type" => "Diagrammtyp\nändern",
|
||||
"Switch\nRow/Column" => "Zeile/Spalte\nwechseln",
|
||||
"Data Labels: None" => "Datenbeschriftungen: Keine",
|
||||
"Data Labels: Show" => "Datenbeschriftungen: Anzeigen",
|
||||
"Legend: None" => "Legende: Keine",
|
||||
"Legend: Bottom" => "Legende: Unten",
|
||||
"Legend: Top" => "Legende: Oben",
|
||||
"Legend: Right" => "Legende: Rechts",
|
||||
"Gridlines: None" => "Gitternetzlinien: Keine",
|
||||
"Gridlines: Show" => "Gitternetzlinien: Anzeigen",
|
||||
"Chart\nTitle" => "Diagramm-\ntitel",
|
||||
"Delete\nChart" => "Diagramm\nlöschen",
|
||||
"Convert\nto Range" => "In Bereich\nkonvertieren",
|
||||
"Table\nStyles" => "Tabellen-\nformate",
|
||||
"Top 10 Items" => "Obere 10 Elemente",
|
||||
"Top 10%" => "Obere 10 %",
|
||||
"Bottom 10 Items" => "Untere 10 Elemente",
|
||||
"Bottom 10%" => "Untere 10 %",
|
||||
"Above Average" => "Überdurchschnittlich",
|
||||
"Below Average" => "Unterdurchschnittlich",
|
||||
"Clear Rules from Entire Sheet" => "Regeln im gesamten Blatt löschen",
|
||||
"Clear Rules from Selected Cells" => "Regeln in den ausgewählten Zellen löschen",
|
||||
"Constants" => "Konstanten",
|
||||
"Data Bars" => "Datenbalken",
|
||||
"Color Scales" => "Farbskalen",
|
||||
"Icon Sets" => "Symbolsätze",
|
||||
"Home" => "Start",
|
||||
"Draw" => "Zeichnen",
|
||||
"Page Layout" => "Seitenlayout",
|
||||
"Formulas" => "Formeln",
|
||||
"Data" => "Daten",
|
||||
"Review" => "Überprüfen",
|
||||
"View" => "Ansicht",
|
||||
"Automate" => "Automatisieren",
|
||||
"Table Design" => "Tabellenentwurf",
|
||||
"Chart Design" => "Diagrammentwurf",
|
||||
"Ready" => "Bereit",
|
||||
"Enter" => "Eingeben",
|
||||
"Edit" => "Bearbeiten",
|
||||
"Point" => "Zeigen",
|
||||
"Calculate" => "Berechnen",
|
||||
"Select destination and press Enter or choose Paste" => "Ziel auswählen und die Eingabetaste drücken oder „Einfügen“ wählen",
|
||||
"General" => "Standard",
|
||||
"Number" => "Zahl",
|
||||
"Currency" => "Währung",
|
||||
"Accounting" => "Buchhaltung",
|
||||
"Date" => "Datum",
|
||||
"Time" => "Uhrzeit",
|
||||
"Percentage" => "Prozentsatz",
|
||||
"Fraction" => "Bruch",
|
||||
"Scientific" => "Wissenschaftlich",
|
||||
"Text" => "Text",
|
||||
"Special" => "Sonderformat",
|
||||
"Custom" => "Benutzerdefiniert",
|
||||
"Short Date" => "Kurzes Datum",
|
||||
"Long Date" => "Langes Datum",
|
||||
"More Number Formats…" => "Weitere Zahlenformate…",
|
||||
"Financial" => "Finanzmathematik",
|
||||
"Logical" => "Logisch",
|
||||
"Date &\nTime" => "Datum &\nUhrzeit",
|
||||
"Lookup &\nReference" => "Nachschlagen &\nVerweisen",
|
||||
"Math &\nTrig" => "Mathematik &\nTrigonometrie",
|
||||
"More\nFunctions" => "Weitere\nFunktionen",
|
||||
"Page Layout|Orientation" => "Seitenausrichtung",
|
||||
"Display theme" => "Anzeigedesign",
|
||||
"System" => "System",
|
||||
"Light" => "Hell",
|
||||
"Dark" => "Dunkel",
|
||||
"Sheet Options|View" => "Anzeige",
|
||||
"Print" => "Drucken",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
//! Spanish translations for the interface strings in i18n.rs. Labels follow Excel's Spanish
|
||||
//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back
|
||||
//! to English. Formula names and separators are localized separately (`formula_locale`).
|
||||
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Pegar",
|
||||
"Paste Values" => "Pegar valores",
|
||||
"Paste Formulas" => "Pegar fórmulas",
|
||||
"Paste Formatting" => "Pegar formato",
|
||||
"Transpose" => "Transponer",
|
||||
"Paste Link" => "Pegar vínculo",
|
||||
"Paste Special…" => "Pegado especial…",
|
||||
"Cut (⌘X)" => "Cortar (⌘X)",
|
||||
"Copy (⌘C)" => "Copiar (⌘C)",
|
||||
"Format Painter (double-click to keep it on)" => "Copiar formato (doble clic para mantenerlo activo)",
|
||||
"Bold (⌘B)" => "Negrita (⌘B)",
|
||||
"Italic (⌘I)" => "Cursiva (⌘I)",
|
||||
"Underline (⌘U)" => "Subrayado (⌘U)",
|
||||
"Double Underline" => "Subrayado doble",
|
||||
"Strikethrough" => "Tachado",
|
||||
"Underline" => "Subrayado",
|
||||
"Increase Font Size" => "Aumentar tamaño de fuente",
|
||||
"Decrease Font Size" => "Disminuir tamaño de fuente",
|
||||
"Wrap Text" => "Ajustar texto",
|
||||
"Merge & Center" => "Combinar y centrar",
|
||||
"Merge Cells" => "Combinar celdas",
|
||||
"Merge Across" => "Combinar horizontalmente",
|
||||
"Unmerge Cells" => "Separar celdas",
|
||||
"Fill Color" => "Color de relleno",
|
||||
"Font Color" => "Color de fuente",
|
||||
"Borders" => "Bordes",
|
||||
"Top\nAlign" => "Alinear en la\nparte superior",
|
||||
"Middle\nAlign" => "Alinear en\nel medio",
|
||||
"Bottom\nAlign" => "Alinear en la\nparte inferior",
|
||||
"Left" => "Izquierda",
|
||||
"Right" => "Derecha",
|
||||
"Center" => "Centro",
|
||||
"Horizontal" => "Horizontal",
|
||||
"Vertical Text" => "Texto vertical",
|
||||
"Decrease Indent" => "Disminuir sangría",
|
||||
"Increase Indent" => "Aumentar sangría",
|
||||
"Orientation" => "Orientación",
|
||||
"Angle Counterclockwise" => "Ángulo ascendente",
|
||||
"Angle Clockwise" => "Ángulo descendente",
|
||||
"Rotate Text Up" => "Girar texto hacia arriba",
|
||||
"Rotate Text Down" => "Girar texto hacia abajo",
|
||||
"Accounting Number Format" => "Formato de número de contabilidad",
|
||||
"Percent Style" => "Estilo porcentual",
|
||||
"Comma Style" => "Estilo de millares",
|
||||
"Increase Decimal" => "Aumentar decimales",
|
||||
"Decrease Decimal" => "Disminuir decimales",
|
||||
"More Accounting Formats…" => "Más formatos de contabilidad…",
|
||||
"Conditional\nFormatting" => "Formato\ncondicional",
|
||||
"Conditional Formatting" => "Formato condicional",
|
||||
"Format\nas Table" => "Dar formato\ncomo tabla",
|
||||
"Cell\nStyles" => "Estilos\nde celda",
|
||||
"Format Cells…" => "Formato de celdas…",
|
||||
"Format Cell Alignment…" => "Alineación de celdas…",
|
||||
"Format\nPane" => "Panel\nde formato",
|
||||
"Format" => "Formato",
|
||||
"Insert" => "Insertar",
|
||||
"Insert Cells…" => "Insertar celdas…",
|
||||
"Delete" => "Eliminar",
|
||||
"Delete Cells…" => "Eliminar celdas…",
|
||||
"Insert Sheet Rows" => "Insertar filas de hoja",
|
||||
"Insert Sheet Columns" => "Insertar columnas de hoja",
|
||||
"Delete Sheet Rows" => "Eliminar filas de hoja",
|
||||
"Delete Sheet Columns" => "Eliminar columnas de hoja",
|
||||
"Row Height…" => "Alto de fila…",
|
||||
"AutoFit Row Height" => "Autoajustar alto de fila",
|
||||
"Column Width…" => "Ancho de columna…",
|
||||
"AutoFit Column Width" => "Autoajustar ancho de columna",
|
||||
"Default Width…" => "Ancho predeterminado…",
|
||||
"Hide Rows" => "Ocultar filas",
|
||||
"Unhide Rows" => "Mostrar filas",
|
||||
"Hide Columns" => "Ocultar columnas",
|
||||
"Unhide Columns" => "Mostrar columnas",
|
||||
"Lock Cell" => "Bloquear celda",
|
||||
"Clear" => "Borrar",
|
||||
"Clear All" => "Borrar todo",
|
||||
"Clear Formats" => "Borrar formatos",
|
||||
"Clear Contents" => "Borrar contenido",
|
||||
"Clear Comments and Notes" => "Borrar comentarios y notas",
|
||||
"Clear Hyperlinks" => "Borrar hipervínculos",
|
||||
"AutoSum" => "Autosuma",
|
||||
"AutoSum (⌘⇧T)" => "Autosuma (⌘⇧T)",
|
||||
"Flash\nFill" => "Relleno\nrápido",
|
||||
"Flash Fill" => "Relleno rápido",
|
||||
"Fill" => "Rellenar",
|
||||
"Clear Print Area" => "Borrar área de impresión",
|
||||
"Find &\nSelect" => "Buscar y\nseleccionar",
|
||||
"Find…" => "Buscar…",
|
||||
"Replace…" => "Reemplazar…",
|
||||
"Go To…" => "Ir a…",
|
||||
"Go To Special…" => "Ir a Especial…",
|
||||
"PivotTable" => "Tabla dinámica",
|
||||
"Recommended\nCharts" => "Gráficos\nrecomendados",
|
||||
"Table" => "Tabla",
|
||||
"Pictures" => "Imágenes",
|
||||
"Shapes" => "Formas",
|
||||
"Icons" => "Iconos",
|
||||
"Text\nBox" => "Cuadro\nde texto",
|
||||
"Comment" => "Comentario",
|
||||
"New\nComment" => "Nuevo\ncomentario",
|
||||
"New Note" => "Nueva nota",
|
||||
"Show/Hide Note" => "Mostrar u ocultar nota",
|
||||
"Header &\nFooter" => "Encabezado y\npie de página",
|
||||
"Text to\nColumns" => "Texto en\ncolumnas",
|
||||
"Link" => "Vínculo",
|
||||
"Symbol" => "Símbolo",
|
||||
"Sparklines" => "Minigráficos",
|
||||
"Header & Footer" => "Encabezado y pie de página",
|
||||
"Draw with ink" => "Dibujar con lápiz",
|
||||
"Eraser" => "Borrador",
|
||||
"Ink to\nShape" => "Entrada de lápiz\na forma",
|
||||
"Select\nObjects" => "Seleccionar\nobjetos",
|
||||
"Themes" => "Temas",
|
||||
"Margins" => "Márgenes",
|
||||
"Size" => "Tamaño",
|
||||
"Print\nArea" => "Área de\nimpresión",
|
||||
"Set Print Area" => "Establecer área de impresión",
|
||||
"Breaks" => "Saltos",
|
||||
"Insert Page Break" => "Insertar salto de página",
|
||||
"Remove Page Break" => "Quitar salto de página",
|
||||
"Reset All Page Breaks" => "Restablecer todos los saltos de página",
|
||||
"Print\nTitles" => "Imprimir\ntítulos",
|
||||
"Narrow" => "Estrecho",
|
||||
"Wide" => "Ancho",
|
||||
"Normal" => "Normal",
|
||||
"Portrait" => "Vertical",
|
||||
"Landscape" => "Horizontal",
|
||||
"Custom Margins…" => "Márgenes personalizados…",
|
||||
"Insert\nFunction" => "Insertar\nfunción",
|
||||
"Name\nManager" => "Administrador\nde nombres",
|
||||
"Define Name" => "Definir nombre",
|
||||
"Use in Formula" => "Utilizar en la fórmula",
|
||||
"Create from Selection" => "Crear desde la selección",
|
||||
"Trace Precedents" => "Rastrear precedentes",
|
||||
"Trace Dependents" => "Rastrear dependientes",
|
||||
"Remove Arrows" => "Quitar flechas",
|
||||
"Show Formulas" => "Mostrar fórmulas",
|
||||
"Error Checking" => "Comprobación de errores",
|
||||
"Evaluate Formula" => "Evaluar fórmula",
|
||||
"Watch Window" => "Ventana Inspección",
|
||||
"Calculation\nOptions" => "Opciones\nde cálculo",
|
||||
"Calculate Now" => "Calcular ahora",
|
||||
"Calculate Sheet" => "Calcular hoja",
|
||||
"Automatic" => "Automático",
|
||||
"Automatic Except for Data Tables" => "Automático excepto en las tablas de datos",
|
||||
"Manual" => "Manual",
|
||||
"Sort &\nFilter" => "Ordenar\ny filtrar",
|
||||
"Sort" => "Ordenar",
|
||||
"Sort A to Z" => "Ordenar de A a Z",
|
||||
"Sort Z to A" => "Ordenar de Z a A",
|
||||
"Custom Sort…" => "Orden personalizado…",
|
||||
"Filter" => "Filtro",
|
||||
"Reapply" => "Volver a aplicar",
|
||||
"Get Data\n(Text/CSV)" => "Obtener datos\n(texto/CSV)",
|
||||
"Remove\nDuplicates" => "Quitar\nduplicados",
|
||||
"Data\nValidation" => "Validación\nde datos",
|
||||
"Data Validation" => "Validación de datos",
|
||||
"Data Validation…" => "Validación de datos…",
|
||||
"Circle Invalid Data" => "Rodear con un círculo datos no válidos",
|
||||
"Clear Validation" => "Borrar círculos de validación",
|
||||
"Group" => "Agrupar",
|
||||
"Ungroup" => "Desagrupar",
|
||||
"Subtotal" => "Subtotal",
|
||||
"What-If\nAnalysis" => "Análisis de\nhipótesis",
|
||||
"Data Table…" => "Tabla de datos…",
|
||||
"Scenario Manager…" => "Administrador de escenarios…",
|
||||
"Goal Seek…" => "Buscar objetivo…",
|
||||
"Spelling" => "Ortografía",
|
||||
"Check\nAccessibility" => "Comprobar\naccesibilidad",
|
||||
"Threaded Comments" => "Comentarios en conversación",
|
||||
"Show\nComments" => "Mostrar\ncomentarios",
|
||||
"Protect\nWorkbook" => "Proteger\nlibro",
|
||||
"Unprotect\nSheet" => "Desproteger\nhoja",
|
||||
"Protect Sheet…" => "Proteger hoja…",
|
||||
"Comments" => "Comentarios",
|
||||
"Notes" => "Notas",
|
||||
"Page Break\nPreview" => "Vista previa de\nsalto de página",
|
||||
"Page\nLayout" => "Diseño\nde página",
|
||||
"Formula Bar" => "Barra de fórmulas",
|
||||
"Gridlines" => "Líneas de cuadrícula",
|
||||
"Headings" => "Encabezados",
|
||||
"Zoom" => "Zoom",
|
||||
"Checkbox" => "Casilla",
|
||||
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
|
||||
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
|
||||
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
|
||||
"Data\nAnalysis" => "Análisis\nde datos",
|
||||
"Data Analysis" => "Análisis de datos",
|
||||
"Solver" => "Solver",
|
||||
"Allow Edit\nRanges" => "Permitir editar\nrangos",
|
||||
"Allow Edit Ranges" => "Permitir editar rangos",
|
||||
"Drag to freeze columns" => "Arrastrar para inmovilizar columnas",
|
||||
"Drag to freeze rows" => "Arrastrar para inmovilizar filas",
|
||||
"Select\nData" => "Seleccionar\ndatos",
|
||||
"File" => "Archivo",
|
||||
"Back" => "Atrás",
|
||||
"New" => "Nuevo",
|
||||
"Open" => "Abrir",
|
||||
"Info" => "Información",
|
||||
"Save" => "Guardar",
|
||||
"Save As" => "Guardar como",
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Cerrar",
|
||||
"Options" => "Opciones",
|
||||
"Blank workbook" => "Libro en blanco",
|
||||
"Recent" => "Recientes",
|
||||
"No recent workbooks yet." => "Todavía no hay libros recientes.",
|
||||
"More workbooks" => "Más libros",
|
||||
"Browse…" => "Examinar…",
|
||||
"Not saved yet" => "Aún no se ha guardado",
|
||||
"Location" => "Ubicación",
|
||||
"Sheets" => "Hojas",
|
||||
"Protect Workbook" => "Proteger libro",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"La estructura de hojas está bloqueada: no se pueden agregar, mover, cambiar de nombre ni eliminar hojas."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"Bloquea la estructura de hojas para que no se puedan agregar, mover, cambiar de nombre ni eliminar hojas."
|
||||
}
|
||||
"Properties" => "Propiedades",
|
||||
"Title" => "Título",
|
||||
"Subject" => "Asunto",
|
||||
"Author" => "Autor",
|
||||
"Company" => "Organización",
|
||||
"Keywords" => "Palabras clave",
|
||||
"Description" => "Descripción",
|
||||
"No workbook is open." => "No hay ningún libro abierto.",
|
||||
"Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.",
|
||||
"Download (.xlsx)" => "Descargar (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.",
|
||||
"Print Active Sheets" => "Imprimir hojas activas",
|
||||
"Print Entire Workbook" => "Imprimir todo el libro",
|
||||
"Fit Sheet on One Page" => "Ajustar hoja en una página",
|
||||
"Page Setup…" => "Configurar página…",
|
||||
"Page {page} of {pages}" => "Página {page} de {pages}",
|
||||
"Nothing to print." => "No hay nada que imprimir.",
|
||||
"Previous page" => "Página anterior",
|
||||
"Next page" => "Página siguiente",
|
||||
"Create PDF" => "Crear PDF",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Guarda la hoja como documento PDF conservando el diseño de página.",
|
||||
"Change File Type" => "Cambiar el tipo de archivo",
|
||||
"CSV (Comma delimited)" => "CSV (delimitado por comas)",
|
||||
"Save the active sheet as comma-separated text." => "Guarda la hoja activa como texto separado por comas.",
|
||||
"Web Page (.html)" => "Página web (.html)",
|
||||
"Save the workbook as a web page." => "Guarda el libro como página web.",
|
||||
"Options|General" => "General",
|
||||
"Options|Save" => "Guardar",
|
||||
"Show formula bar" => "Mostrar barra de fórmulas",
|
||||
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
|
||||
"Workbook calculation" => "Cálculo del libro",
|
||||
"Enable iterative calculation" => "Habilitar cálculo iterativo",
|
||||
"Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.",
|
||||
"Interface size" => "Tamaño de la interfaz",
|
||||
"Auto" => "Automático",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Ampliar\nselección",
|
||||
"Freeze\nPanes" => "Inmovilizar\npaneles",
|
||||
"Freeze Panes" => "Inmovilizar paneles",
|
||||
"Freeze Top Row" => "Inmovilizar fila superior",
|
||||
"Freeze First Column" => "Inmovilizar primera columna",
|
||||
"Unfreeze Panes" => "Movilizar paneles",
|
||||
"Dark Mode" => "Modo oscuro",
|
||||
"Interface language" => "Idioma de la interfaz",
|
||||
"Command\nPalette" => "Paleta de\ncomandos",
|
||||
"Agent\nControl" => "Control\ndel agente",
|
||||
"Action\nJournal" => "Registro\nde acciones",
|
||||
"About\nGridCraft" => "Acerca de\nGridCraft",
|
||||
"Share" => "Compartir",
|
||||
"Add Chart\nElement" => "Agregar elemento\nde gráfico",
|
||||
"Change\nChart Type" => "Cambiar tipo\nde gráfico",
|
||||
"Switch\nRow/Column" => "Cambiar entre\nfilas y columnas",
|
||||
"Data Labels: None" => "Etiquetas de datos: ninguna",
|
||||
"Data Labels: Show" => "Etiquetas de datos: mostrar",
|
||||
"Legend: None" => "Leyenda: ninguna",
|
||||
"Legend: Bottom" => "Leyenda: abajo",
|
||||
"Legend: Top" => "Leyenda: arriba",
|
||||
"Legend: Right" => "Leyenda: derecha",
|
||||
"Gridlines: None" => "Líneas de cuadrícula: ninguna",
|
||||
"Gridlines: Show" => "Líneas de cuadrícula: mostrar",
|
||||
"Chart\nTitle" => "Título\ndel gráfico",
|
||||
"Delete\nChart" => "Eliminar\ngráfico",
|
||||
"Convert\nto Range" => "Convertir\nen rango",
|
||||
"Table\nStyles" => "Estilos\nde tabla",
|
||||
"Top 10 Items" => "10 superiores",
|
||||
"Top 10%" => "10% superior",
|
||||
"Bottom 10 Items" => "10 inferiores",
|
||||
"Bottom 10%" => "10% inferior",
|
||||
"Above Average" => "Por encima del promedio",
|
||||
"Below Average" => "Por debajo del promedio",
|
||||
"Clear Rules from Entire Sheet" => "Borrar reglas de toda la hoja",
|
||||
"Clear Rules from Selected Cells" => "Borrar reglas de las celdas seleccionadas",
|
||||
"Constants" => "Constantes",
|
||||
"Data Bars" => "Barras de datos",
|
||||
"Color Scales" => "Escalas de color",
|
||||
"Icon Sets" => "Conjuntos de iconos",
|
||||
"Home" => "Inicio",
|
||||
"Draw" => "Dibujar",
|
||||
"Page Layout" => "Disposición de página",
|
||||
"Formulas" => "Fórmulas",
|
||||
"Data" => "Datos",
|
||||
"Review" => "Revisar",
|
||||
"View" => "Vista",
|
||||
"Automate" => "Automatizar",
|
||||
"Table Design" => "Diseño de tabla",
|
||||
"Chart Design" => "Diseño de gráfico",
|
||||
"Ready" => "Listo",
|
||||
"Enter" => "Introducir",
|
||||
"Edit" => "Modificar",
|
||||
"Point" => "Señalar",
|
||||
"Calculate" => "Calcular",
|
||||
"Select destination and press Enter or choose Paste" => "Seleccione el destino y presione Entrar o elija Pegar",
|
||||
"General" => "General",
|
||||
"Number" => "Número",
|
||||
"Currency" => "Moneda",
|
||||
"Accounting" => "Contabilidad",
|
||||
"Date" => "Fecha",
|
||||
"Time" => "Hora",
|
||||
"Percentage" => "Porcentaje",
|
||||
"Fraction" => "Fracción",
|
||||
"Scientific" => "Científica",
|
||||
"Text" => "Texto",
|
||||
"Special" => "Especial",
|
||||
"Custom" => "Personalizada",
|
||||
"Short Date" => "Fecha corta",
|
||||
"Long Date" => "Fecha larga",
|
||||
"More Number Formats…" => "Más formatos de número…",
|
||||
"Financial" => "Financieras",
|
||||
"Logical" => "Lógicas",
|
||||
"Date &\nTime" => "Fecha\ny hora",
|
||||
"Lookup &\nReference" => "Búsqueda y\nreferencia",
|
||||
"Math &\nTrig" => "Matemáticas y\ntrigonométricas",
|
||||
"More\nFunctions" => "Más\nfunciones",
|
||||
"Page Layout|Orientation" => "Orientación",
|
||||
"Display theme" => "Tema de la interfaz",
|
||||
"System" => "Sistema",
|
||||
"Light" => "Claro",
|
||||
"Dark" => "Oscuro",
|
||||
"Sheet Options|View" => "Ver",
|
||||
"Print" => "Imprimir",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Link" => "링크",
|
||||
"Symbol" => "기호",
|
||||
"Sparklines" => "스파크라인",
|
||||
"Checkbox" => "확인란",
|
||||
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
|
||||
"Header & Footer" => "머리글 및 바닥글",
|
||||
"Draw with ink" => "잉크로 그리기",
|
||||
"Eraser" => "지우개",
|
||||
|
|
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Remove Page Break" => "페이지 나누기 제거",
|
||||
"Reset All Page Breaks" => "모든 페이지 나누기 다시 설정",
|
||||
"Print\nTitles" => "인쇄\n제목",
|
||||
"Sheet\nRight-to-Left" => "시트\n오른쪽에서 왼쪽으로",
|
||||
"Sheet Right-to-Left" => "시트 오른쪽에서 왼쪽으로",
|
||||
"Narrow" => "좁게",
|
||||
"Wide" => "넓게",
|
||||
"Normal" => "보통",
|
||||
|
|
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "그룹",
|
||||
"Ungroup" => "그룹 해제",
|
||||
"Subtotal" => "부분합",
|
||||
"Data\nAnalysis" => "데이터 분석",
|
||||
"Data Analysis" => "데이터 분석",
|
||||
"Solver" => "해 찾기",
|
||||
"What-If\nAnalysis" => "가상 분석",
|
||||
"Data Table…" => "데이터 표…",
|
||||
"Scenario Manager…" => "시나리오 관리자…",
|
||||
|
|
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "스레드 댓글",
|
||||
"Show\nComments" => "댓글\n표시",
|
||||
"Protect\nWorkbook" => "통합 문서\n보호",
|
||||
"Allow Edit\nRanges" => "범위 편집\n허용",
|
||||
"Allow Edit Ranges" => "범위 편집 허용",
|
||||
"Unprotect\nSheet" => "시트 보호\n해제",
|
||||
"Protect Sheet…" => "시트 보호…",
|
||||
"Comments" => "댓글",
|
||||
|
|
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Unfreeze Panes" => "틀 고정 취소",
|
||||
"Dark Mode" => "다크 모드",
|
||||
"Interface language" => "인터페이스 언어",
|
||||
"Interface size" => "인터페이스 크기",
|
||||
"Auto" => "자동",
|
||||
"Drag to freeze columns" => "끌어서 열 고정",
|
||||
"Drag to freeze rows" => "끌어서 행 고정",
|
||||
"Command\nPalette" => "명령\n팔레트",
|
||||
"Agent\nControl" => "에이전트\n제어",
|
||||
"Action\nJournal" => "작업\n기록",
|
||||
|
|
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Add Chart\nElement" => "차트\n요소 추가",
|
||||
"Change\nChart Type" => "차트 종류\n변경",
|
||||
"Switch\nRow/Column" => "행/열\n전환",
|
||||
"Select\nData" => "데이터\n선택",
|
||||
"Data Labels: None" => "데이터 레이블: 없음",
|
||||
"Data Labels: Show" => "데이터 레이블: 표시",
|
||||
"Legend: None" => "범례: 없음",
|
||||
|
|
@ -268,6 +282,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Dark" => "어둡게",
|
||||
"Sheet Options|View" => "화면",
|
||||
"Print" => "인쇄",
|
||||
// File › Backstage view.
|
||||
"File" => "파일",
|
||||
"Back" => "뒤로",
|
||||
"New" => "새로 만들기",
|
||||
"Open" => "열기",
|
||||
"Info" => "정보",
|
||||
"Save" => "저장",
|
||||
"Save As" => "다른 이름으로 저장",
|
||||
"Export" => "내보내기",
|
||||
"Close" => "닫기",
|
||||
"Options" => "옵션",
|
||||
"Blank workbook" => "새 통합 문서",
|
||||
"Recent" => "최근 항목",
|
||||
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
|
||||
"More workbooks" => "더 많은 통합 문서",
|
||||
"Browse…" => "찾아보기…",
|
||||
"Not saved yet" => "아직 저장되지 않음",
|
||||
"Location" => "위치",
|
||||
"Sheets" => "시트",
|
||||
"Protect Workbook" => "통합 문서 보호",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"시트 구조가 잠겨 있습니다. 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없습니다."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"시트 구조를 잠가 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없게 합니다."
|
||||
}
|
||||
"Properties" => "속성",
|
||||
"Title" => "제목",
|
||||
"Subject" => "주제",
|
||||
"Author" => "만든 이",
|
||||
"Company" => "회사",
|
||||
"Keywords" => "키워드",
|
||||
"Description" => "설명",
|
||||
"No workbook is open." => "열려 있는 통합 문서가 없습니다.",
|
||||
"Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.",
|
||||
"Download (.xlsx)" => "다운로드(.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.",
|
||||
"Print Active Sheets" => "활성 시트 인쇄",
|
||||
"Print Entire Workbook" => "전체 통합 문서 인쇄",
|
||||
"Fit Sheet on One Page" => "한 페이지에 시트 맞추기",
|
||||
"Page Setup…" => "페이지 설정…",
|
||||
"Page {page} of {pages}" => "{page}/{pages} 페이지",
|
||||
"Nothing to print." => "인쇄할 내용이 없습니다.",
|
||||
"Previous page" => "이전 페이지",
|
||||
"Next page" => "다음 페이지",
|
||||
"Create PDF" => "PDF 만들기",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "페이지 레이아웃을 유지한 채 시트를 PDF 문서로 저장합니다.",
|
||||
"Change File Type" => "파일 형식 변경",
|
||||
"CSV (Comma delimited)" => "CSV(쉼표로 분리)",
|
||||
"Save the active sheet as comma-separated text." => "활성 시트를 쉼표로 구분된 텍스트로 저장합니다.",
|
||||
"Web Page (.html)" => "웹 페이지(.html)",
|
||||
"Save the workbook as a web page." => "통합 문서를 웹 페이지로 저장합니다.",
|
||||
"Options|General" => "일반",
|
||||
"Options|Save" => "저장",
|
||||
"Show formula bar" => "수식 입력줄 표시",
|
||||
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
|
||||
"Workbook calculation" => "통합 문서 계산",
|
||||
"Enable iterative calculation" => "반복 계산 사용",
|
||||
"Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Link" => "Vínculo",
|
||||
"Symbol" => "Símbolo",
|
||||
"Sparklines" => "Minigráficos",
|
||||
"Checkbox" => "Caixa de Seleção",
|
||||
"Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas",
|
||||
"Header & Footer" => "Cabeçalho e Rodapé",
|
||||
"Draw with ink" => "Desenhar com Tinta",
|
||||
"Eraser" => "Borracha",
|
||||
|
|
@ -124,6 +126,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Remove Page Break" => "Remover Quebra de Página",
|
||||
"Reset All Page Breaks" => "Redefinir Todas as Quebras de Página",
|
||||
"Print\nTitles" => "Imprimir\nTítulos",
|
||||
"Sheet\nRight-to-Left" => "Planilha da Direita\npara a Esquerda",
|
||||
"Sheet Right-to-Left" => "Planilha da Direita para a Esquerda",
|
||||
"Narrow" => "Estreita",
|
||||
"Wide" => "Ampla",
|
||||
"Normal" => "Normal",
|
||||
|
|
@ -165,6 +169,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "Agrupar",
|
||||
"Ungroup" => "Desagrupar",
|
||||
"Subtotal" => "Subtotal",
|
||||
"Data\nAnalysis" => "Análise de Dados",
|
||||
"Data Analysis" => "Análise de Dados",
|
||||
"Solver" => "Solver",
|
||||
"What-If\nAnalysis" => "Análise de\nHipóteses",
|
||||
"Data Table…" => "Tabela de Dados…",
|
||||
"Scenario Manager…" => "Gerenciador de Cenários…",
|
||||
|
|
@ -174,6 +181,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "Comentários Encadeados",
|
||||
"Show\nComments" => "Mostrar\nComentários",
|
||||
"Protect\nWorkbook" => "Proteger\nPasta de Trabalho",
|
||||
"Allow Edit\nRanges" => "Permitir Edição\nde Intervalos",
|
||||
"Allow Edit Ranges" => "Permitir Edição de Intervalos",
|
||||
"Unprotect\nSheet" => "Desproteger\nPlanilha",
|
||||
"Protect Sheet…" => "Proteger Planilha…",
|
||||
"Comments" => "Comentários",
|
||||
|
|
@ -193,6 +202,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Unfreeze Panes" => "Descongelar Painéis",
|
||||
"Dark Mode" => "Modo Escuro",
|
||||
"Interface language" => "Idioma da interface",
|
||||
"Interface size" => "Tamanho da interface",
|
||||
"Auto" => "Automático",
|
||||
"Drag to freeze columns" => "Arraste para congelar colunas",
|
||||
"Drag to freeze rows" => "Arraste para congelar linhas",
|
||||
"Command\nPalette" => "Paleta de\nComandos",
|
||||
"Agent\nControl" => "Controle\ndo Agente",
|
||||
"Action\nJournal" => "Registro\nde Ações",
|
||||
|
|
@ -201,6 +214,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Add Chart\nElement" => "Adicionar\nElemento de Gráfico",
|
||||
"Change\nChart Type" => "Alterar\nTipo de Gráfico",
|
||||
"Switch\nRow/Column" => "Alternar\nLinha/Coluna",
|
||||
"Select\nData" => "Selecionar\nDados",
|
||||
"Data Labels: None" => "Rótulos de Dados: Nenhum",
|
||||
"Data Labels: Show" => "Rótulos de Dados: Exibir",
|
||||
"Legend: None" => "Legenda: Nenhuma",
|
||||
|
|
@ -269,6 +283,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Dark" => "Escuro",
|
||||
"Sheet Options|View" => "Exibir",
|
||||
"Print" => "Imprimir",
|
||||
// File › Backstage view.
|
||||
"File" => "Arquivo",
|
||||
"Back" => "Voltar",
|
||||
"New" => "Novo",
|
||||
"Open" => "Abrir",
|
||||
"Info" => "Informações",
|
||||
"Save" => "Salvar",
|
||||
"Save As" => "Salvar como",
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Fechar",
|
||||
"Options" => "Opções",
|
||||
"Blank workbook" => "Pasta de trabalho em branco",
|
||||
"Recent" => "Recentes",
|
||||
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
|
||||
"More workbooks" => "Mais pastas de trabalho",
|
||||
"Browse…" => "Procurar…",
|
||||
"Not saved yet" => "Ainda não salvo",
|
||||
"Location" => "Local",
|
||||
"Sheets" => "Planilhas",
|
||||
"Protect Workbook" => "Proteger pasta de trabalho",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"A estrutura das planilhas está bloqueada: não é possível adicionar, mover, renomear nem excluir planilhas."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"Bloqueie a estrutura das planilhas para que não possam ser adicionadas, movidas, renomeadas nem excluídas."
|
||||
}
|
||||
"Properties" => "Propriedades",
|
||||
"Title" => "Título",
|
||||
"Subject" => "Assunto",
|
||||
"Author" => "Autor",
|
||||
"Company" => "Empresa",
|
||||
"Keywords" => "Palavras-chave",
|
||||
"Description" => "Descrição",
|
||||
"No workbook is open." => "Nenhuma pasta de trabalho está aberta.",
|
||||
"Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.",
|
||||
"Download (.xlsx)" => "Baixar (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.",
|
||||
"Print Active Sheets" => "Imprimir planilhas ativas",
|
||||
"Print Entire Workbook" => "Imprimir toda a pasta de trabalho",
|
||||
"Fit Sheet on One Page" => "Ajustar planilha em uma página",
|
||||
"Page Setup…" => "Configurar página…",
|
||||
"Page {page} of {pages}" => "Página {page} de {pages}",
|
||||
"Nothing to print." => "Nada para imprimir.",
|
||||
"Previous page" => "Página anterior",
|
||||
"Next page" => "Próxima página",
|
||||
"Create PDF" => "Criar PDF",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Salve a planilha como um documento PDF que mantém o layout da página.",
|
||||
"Change File Type" => "Alterar tipo de arquivo",
|
||||
"CSV (Comma delimited)" => "CSV (separado por vírgulas)",
|
||||
"Save the active sheet as comma-separated text." => "Salve a planilha ativa como texto separado por vírgulas.",
|
||||
"Web Page (.html)" => "Página da Web (.html)",
|
||||
"Save the workbook as a web page." => "Salve a pasta de trabalho como uma página da Web.",
|
||||
"Options|General" => "Geral",
|
||||
"Options|Save" => "Salvar",
|
||||
"Show formula bar" => "Mostrar barra de fórmulas",
|
||||
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
|
||||
"Workbook calculation" => "Cálculo da pasta de trabalho",
|
||||
"Enable iterative calculation" => "Habilitar cálculo iterativo",
|
||||
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Link" => "Ссылка",
|
||||
"Symbol" => "Символ",
|
||||
"Sparklines" => "Спарклайны",
|
||||
"Checkbox" => "Флажок",
|
||||
"Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки",
|
||||
"Header & Footer" => "Верхний и нижний колонтитулы",
|
||||
"Draw with ink" => "Рисование от руки",
|
||||
"Eraser" => "Ластик",
|
||||
|
|
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Remove Page Break" => "Удалить разрыв страницы",
|
||||
"Reset All Page Breaks" => "Сбросить все разрывы страниц",
|
||||
"Print\nTitles" => "Печатать\nзаголовки",
|
||||
"Sheet\nRight-to-Left" => "Лист\nсправа налево",
|
||||
"Sheet Right-to-Left" => "Лист справа налево",
|
||||
"Narrow" => "Узкие",
|
||||
"Wide" => "Широкие",
|
||||
"Normal" => "Обычные",
|
||||
|
|
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "Группировать",
|
||||
"Ungroup" => "Разгруппировать",
|
||||
"Subtotal" => "Промежуточный итог",
|
||||
"Data\nAnalysis" => "Анализ данных",
|
||||
"Data Analysis" => "Анализ данных",
|
||||
"Solver" => "Поиск решения",
|
||||
"What-If\nAnalysis" => "Анализ «что\nесли»",
|
||||
"Data Table…" => "Таблица данных…",
|
||||
"Scenario Manager…" => "Диспетчер сценариев…",
|
||||
|
|
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "Комментарии с ветками",
|
||||
"Show\nComments" => "Показать\nкомментарии",
|
||||
"Protect\nWorkbook" => "Защитить\nкнигу",
|
||||
"Allow Edit\nRanges" => "Разрешить\nизменение диапазонов",
|
||||
"Allow Edit Ranges" => "Разрешить изменение диапазонов",
|
||||
"Unprotect\nSheet" => "Снять защиту\nлиста",
|
||||
"Protect Sheet…" => "Защитить лист…",
|
||||
"Comments" => "Комментарии",
|
||||
|
|
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Unfreeze Panes" => "Снять закрепление областей",
|
||||
"Dark Mode" => "Тёмный режим",
|
||||
"Interface language" => "Язык интерфейса",
|
||||
"Interface size" => "Размер интерфейса",
|
||||
"Auto" => "Авто",
|
||||
"Drag to freeze columns" => "Перетащите, чтобы закрепить столбцы",
|
||||
"Drag to freeze rows" => "Перетащите, чтобы закрепить строки",
|
||||
"Command\nPalette" => "Палитра\nкоманд",
|
||||
"Agent\nControl" => "Управление\nагентом",
|
||||
"Action\nJournal" => "Журнал\nдействий",
|
||||
|
|
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Add Chart\nElement" => "Добавить элемент\nдиаграммы",
|
||||
"Change\nChart Type" => "Изменить тип\nдиаграммы",
|
||||
"Switch\nRow/Column" => "Поменять\nстроки/столбцы",
|
||||
"Select\nData" => "Выбрать\nданные",
|
||||
"Data Labels: None" => "Подписи данных: нет",
|
||||
"Data Labels: Show" => "Подписи данных: показать",
|
||||
"Legend: None" => "Легенда: нет",
|
||||
|
|
@ -268,6 +282,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Dark" => "Тёмная",
|
||||
"Sheet Options|View" => "На экране",
|
||||
"Print" => "Печать",
|
||||
// File › Backstage view.
|
||||
"File" => "Файл",
|
||||
"Back" => "Назад",
|
||||
"New" => "Создать",
|
||||
"Open" => "Открыть",
|
||||
"Info" => "Сведения",
|
||||
"Save" => "Сохранить",
|
||||
"Save As" => "Сохранить как",
|
||||
"Export" => "Экспорт",
|
||||
"Close" => "Закрыть",
|
||||
"Options" => "Параметры",
|
||||
"Blank workbook" => "Пустая книга",
|
||||
"Recent" => "Последние",
|
||||
"No recent workbooks yet." => "Недавних книг пока нет.",
|
||||
"More workbooks" => "Другие книги",
|
||||
"Browse…" => "Обзор…",
|
||||
"Not saved yet" => "Ещё не сохранено",
|
||||
"Location" => "Расположение",
|
||||
"Sheets" => "Листы",
|
||||
"Protect Workbook" => "Защитить книгу",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => {
|
||||
"Структура листов заблокирована: листы нельзя добавлять, перемещать, переименовывать или удалять."
|
||||
}
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => {
|
||||
"Заблокировать структуру листов, чтобы их нельзя было добавлять, перемещать, переименовывать или удалять."
|
||||
}
|
||||
"Properties" => "Свойства",
|
||||
"Title" => "Название",
|
||||
"Subject" => "Тема",
|
||||
"Author" => "Автор",
|
||||
"Company" => "Организация",
|
||||
"Keywords" => "Ключевые слова",
|
||||
"Description" => "Описание",
|
||||
"No workbook is open." => "Нет открытых книг.",
|
||||
"Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.",
|
||||
"Download (.xlsx)" => "Скачать (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».",
|
||||
"Print Active Sheets" => "Напечатать активные листы",
|
||||
"Print Entire Workbook" => "Напечатать всю книгу",
|
||||
"Fit Sheet on One Page" => "Вписать лист на одну страницу",
|
||||
"Page Setup…" => "Параметры страницы…",
|
||||
"Page {page} of {pages}" => "Страница {page} из {pages}",
|
||||
"Nothing to print." => "Нечего печатать.",
|
||||
"Previous page" => "Предыдущая страница",
|
||||
"Next page" => "Следующая страница",
|
||||
"Create PDF" => "Создать PDF",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "Сохранить лист как PDF-документ с сохранением разметки страниц.",
|
||||
"Change File Type" => "Изменить тип файла",
|
||||
"CSV (Comma delimited)" => "CSV (разделители — запятые)",
|
||||
"Save the active sheet as comma-separated text." => "Сохранить активный лист как текст с разделителями-запятыми.",
|
||||
"Web Page (.html)" => "Веб-страница (.html)",
|
||||
"Save the workbook as a web page." => "Сохранить книгу как веб-страницу.",
|
||||
"Options|General" => "Общие",
|
||||
"Options|Save" => "Сохранение",
|
||||
"Show formula bar" => "Показывать строку формул",
|
||||
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
|
||||
"Workbook calculation" => "Вычисления в книге",
|
||||
"Enable iterative calculation" => "Включить итеративные вычисления",
|
||||
"Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Link" => "链接",
|
||||
"Symbol" => "符号",
|
||||
"Sparklines" => "迷你图",
|
||||
"Checkbox" => "复选框",
|
||||
"Insert checkboxes in the selected cells" => "在所选单元格中插入复选框",
|
||||
"Header & Footer" => "页眉和页脚",
|
||||
"Draw with ink" => "墨迹绘制",
|
||||
"Eraser" => "橡皮擦",
|
||||
|
|
@ -123,6 +125,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Remove Page Break" => "删除分页符",
|
||||
"Reset All Page Breaks" => "重置所有分页符",
|
||||
"Print\nTitles" => "打印\n标题",
|
||||
"Sheet\nRight-to-Left" => "工作表\n从右到左",
|
||||
"Sheet Right-to-Left" => "工作表从右到左",
|
||||
"Narrow" => "窄",
|
||||
"Wide" => "宽",
|
||||
"Normal" => "普通",
|
||||
|
|
@ -164,6 +168,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "组合",
|
||||
"Ungroup" => "取消组合",
|
||||
"Subtotal" => "分类汇总",
|
||||
"Data\nAnalysis" => "数据分析",
|
||||
"Data Analysis" => "数据分析",
|
||||
"Solver" => "规划求解",
|
||||
"What-If\nAnalysis" => "假设分析",
|
||||
"Data Table…" => "数据表…",
|
||||
"Scenario Manager…" => "方案管理器…",
|
||||
|
|
@ -173,6 +180,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Threaded Comments" => "线程式批注",
|
||||
"Show\nComments" => "显示\n批注",
|
||||
"Protect\nWorkbook" => "保护\n工作簿",
|
||||
"Allow Edit\nRanges" => "允许编辑\n区域",
|
||||
"Allow Edit Ranges" => "允许编辑区域",
|
||||
"Unprotect\nSheet" => "撤销\n工作表保护",
|
||||
"Protect Sheet…" => "保护工作表…",
|
||||
"Comments" => "批注",
|
||||
|
|
@ -192,6 +201,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Unfreeze Panes" => "取消冻结窗格",
|
||||
"Dark Mode" => "深色模式",
|
||||
"Interface language" => "界面语言",
|
||||
"Interface size" => "界面大小",
|
||||
"Auto" => "自动",
|
||||
"Drag to freeze columns" => "拖动以冻结列",
|
||||
"Drag to freeze rows" => "拖动以冻结行",
|
||||
"Command\nPalette" => "命令\n面板",
|
||||
"Agent\nControl" => "智能体\n控制",
|
||||
"Action\nJournal" => "操作\n日志",
|
||||
|
|
@ -200,6 +213,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Add Chart\nElement" => "添加图表\n元素",
|
||||
"Change\nChart Type" => "更改\n图表类型",
|
||||
"Switch\nRow/Column" => "切换\n行/列",
|
||||
"Select\nData" => "选择\n数据",
|
||||
"Data Labels: None" => "数据标签:无",
|
||||
"Data Labels: Show" => "数据标签:显示",
|
||||
"Legend: None" => "图例:无",
|
||||
|
|
@ -268,6 +282,61 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Dark" => "深色",
|
||||
"Sheet Options|View" => "屏幕",
|
||||
"Print" => "打印",
|
||||
// File › Backstage view.
|
||||
"File" => "文件",
|
||||
"Back" => "返回",
|
||||
"New" => "新建",
|
||||
"Open" => "打开",
|
||||
"Info" => "信息",
|
||||
"Save" => "保存",
|
||||
"Save As" => "另存为",
|
||||
"Export" => "导出",
|
||||
"Close" => "关闭",
|
||||
"Options" => "选项",
|
||||
"Blank workbook" => "空白工作簿",
|
||||
"Recent" => "最近",
|
||||
"No recent workbooks yet." => "还没有最近使用的工作簿。",
|
||||
"More workbooks" => "更多工作簿",
|
||||
"Browse…" => "浏览…",
|
||||
"Not saved yet" => "尚未保存",
|
||||
"Location" => "位置",
|
||||
"Sheets" => "工作表",
|
||||
"Protect Workbook" => "保护工作簿",
|
||||
"Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "工作表结构已锁定:无法添加、移动、重命名或删除工作表。",
|
||||
"Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "锁定工作表结构,使工作表无法被添加、移动、重命名或删除。",
|
||||
"Properties" => "属性",
|
||||
"Title" => "标题",
|
||||
"Subject" => "主题",
|
||||
"Author" => "作者",
|
||||
"Company" => "公司",
|
||||
"Keywords" => "关键词",
|
||||
"Description" => "说明",
|
||||
"No workbook is open." => "没有打开的工作簿。",
|
||||
"Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。",
|
||||
"Download (.xlsx)" => "下载 (.xlsx)",
|
||||
"PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。",
|
||||
"Print Active Sheets" => "打印活动工作表",
|
||||
"Print Entire Workbook" => "打印整个工作簿",
|
||||
"Fit Sheet on One Page" => "将工作表调整为一页",
|
||||
"Page Setup…" => "页面设置…",
|
||||
"Page {page} of {pages}" => "第 {page} 页,共 {pages} 页",
|
||||
"Nothing to print." => "没有可打印的内容。",
|
||||
"Previous page" => "上一页",
|
||||
"Next page" => "下一页",
|
||||
"Create PDF" => "创建 PDF",
|
||||
"Save the sheet as a PDF document that keeps its page layout." => "将工作表另存为保留页面布局的 PDF 文档。",
|
||||
"Change File Type" => "更改文件类型",
|
||||
"CSV (Comma delimited)" => "CSV (逗号分隔)",
|
||||
"Save the active sheet as comma-separated text." => "将活动工作表另存为逗号分隔的文本。",
|
||||
"Web Page (.html)" => "网页 (.html)",
|
||||
"Save the workbook as a web page." => "将工作簿另存为网页。",
|
||||
"Options|General" => "常规",
|
||||
"Options|Save" => "保存",
|
||||
"Show formula bar" => "显示编辑栏",
|
||||
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
|
||||
"Workbook calculation" => "工作簿计算",
|
||||
"Enable iterative calculation" => "启用迭代计算",
|
||||
"Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ pub enum Icon {
|
|||
TextBox,
|
||||
Symbol,
|
||||
Sparkline,
|
||||
Checkbox,
|
||||
Function,
|
||||
Name,
|
||||
Trace,
|
||||
|
|
@ -101,6 +102,7 @@ pub enum Icon {
|
|||
Theme,
|
||||
Margins,
|
||||
Orient,
|
||||
SheetRtl,
|
||||
Paper,
|
||||
PrintArea,
|
||||
Script,
|
||||
|
|
@ -108,9 +110,17 @@ pub enum Icon {
|
|||
Share,
|
||||
Search,
|
||||
Settings,
|
||||
/// Light/dark appearance: a circle, its right half filled.
|
||||
Appearance,
|
||||
Sheet,
|
||||
Book,
|
||||
Folder,
|
||||
/// File › Backstage navigation (line art, drawn in the navigation colour).
|
||||
Back,
|
||||
NewFile,
|
||||
OpenFolder,
|
||||
Info,
|
||||
Print,
|
||||
}
|
||||
|
||||
struct G<'a> {
|
||||
|
|
@ -415,6 +425,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
|||
g.line(&[(12.0, 4.0), (4.0, 12.0)], c, 1.3);
|
||||
}
|
||||
Icon::Check => g.line(&[(3.0, 8.5), (6.5, 12.0), (13.0, 4.5)], c, 1.3),
|
||||
Icon::Checkbox => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
|
||||
g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4);
|
||||
}
|
||||
Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, false, true),
|
||||
Icon::Plus => {
|
||||
g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3);
|
||||
|
|
@ -574,6 +588,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
|||
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
|
||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE));
|
||||
}
|
||||
Icon::SheetRtl => {
|
||||
// A sheet with an arrow running right to left.
|
||||
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
|
||||
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
|
||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4);
|
||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE);
|
||||
}
|
||||
Icon::PrintArea => {
|
||||
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
|
||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4);
|
||||
|
|
@ -596,6 +618,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
|||
g.circle(8.0, 8.0, 5.0, None, Some(c));
|
||||
g.circle(8.0, 8.0, 2.0, Some(c), None);
|
||||
}
|
||||
Icon::Appearance => {
|
||||
g.circle(8.0, 8.0, 5.5, None, Some(c));
|
||||
let half: Vec<(f32, f32)> = (0..=12)
|
||||
.map(|i| {
|
||||
let a = std::f32::consts::PI * (i as f32 / 12.0 - 0.5);
|
||||
(8.0 + 5.5 * a.cos(), 8.0 + 5.5 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
g.poly(&half, c);
|
||||
}
|
||||
Icon::Book => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None);
|
||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None);
|
||||
|
|
@ -605,6 +637,29 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
|||
Icon::Folder => {
|
||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW);
|
||||
}
|
||||
Icon::Back => {
|
||||
g.circle(8.0, 8.0, 6.5, None, Some(c));
|
||||
g.line(&[(11.5, 8.0), (4.8, 8.0)], c, 1.2);
|
||||
g.line(&[(7.5, 5.3), (4.8, 8.0), (7.5, 10.7)], c, 1.2);
|
||||
}
|
||||
Icon::NewFile => {
|
||||
g.line(&[(9.5, 1.5), (3.5, 1.5), (3.5, 14.5), (12.5, 14.5), (12.5, 4.5), (9.5, 1.5), (9.5, 4.5), (12.5, 4.5)], c, 1.1);
|
||||
}
|
||||
Icon::OpenFolder => {
|
||||
g.line(&[(13.0, 6.5), (13.0, 4.5), (7.5, 4.5), (6.0, 3.0), (1.5, 3.0), (1.5, 13.0)], c, 1.1);
|
||||
g.line(&[(1.5, 13.0), (4.0, 7.0), (15.0, 7.0), (12.5, 13.0), (1.5, 13.0)], c, 1.1);
|
||||
}
|
||||
Icon::Info => {
|
||||
g.circle(8.0, 8.0, 6.5, None, Some(c));
|
||||
g.line(&[(8.0, 7.0), (8.0, 11.8)], c, 1.3);
|
||||
g.circle(8.0, 4.8, 0.8, Some(c), None);
|
||||
}
|
||||
Icon::Print => {
|
||||
g.line(&[(4.5, 5.5), (4.5, 1.5), (11.5, 1.5), (11.5, 5.5)], c, 1.1);
|
||||
g.line(&[(4.0, 11.5), (1.5, 11.5), (1.5, 5.5), (14.5, 5.5), (14.5, 11.5), (12.0, 11.5)], c, 1.1);
|
||||
g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1);
|
||||
g.circle(12.3, 7.6, 0.6, Some(c), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
//! Excel keyboard shortcuts beyond the plain Ctrl/Cmd letters in [`crate::ribbon::shortcut`]:
|
||||
//! number-format and border combinations, F-keys, Alt/Option combinations and the Excel for Mac
|
||||
//! variants. Every entry maps to an existing command or dialog. `docs/ui-parity.md` holds the
|
||||
//! audit against Microsoft's published Windows and Mac shortcut lists.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
use gridcraft_engine::model::Sheet;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::grid::Geo;
|
||||
|
||||
/// What a shortcut does.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shortcut {
|
||||
/// Run a command with these parameters.
|
||||
Run(&'static str, Json),
|
||||
/// Open a dialog.
|
||||
Dialog(&'static str, Json),
|
||||
/// Start editing with the current date or time (Ctrl+; and Ctrl+Shift+;).
|
||||
Date,
|
||||
Time,
|
||||
/// Ctrl+' / Ctrl+Shift+": the formula / value of the cell above, ready to edit.
|
||||
FormulaAbove,
|
||||
ValueAbove,
|
||||
/// Extend the selection to A1, to the last used cell, or to the start of the row.
|
||||
ExtendToStart,
|
||||
ExtendToEnd,
|
||||
ExtendToRowStart,
|
||||
/// Alt+Page Down / Alt+Page Up: one screen right / left.
|
||||
ScreenRight,
|
||||
ScreenLeft,
|
||||
/// Excel for Mac Control+U: edit the active cell (like F2).
|
||||
Edit,
|
||||
/// Shift+F10: the cell context menu.
|
||||
ContextMenu,
|
||||
/// Alt+Down: the active cell's drop-down list.
|
||||
DropDown,
|
||||
/// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out.
|
||||
Zoom(i32),
|
||||
}
|
||||
|
||||
fn run(id: &'static str) -> Shortcut {
|
||||
Shortcut::Run(id, json!({}))
|
||||
}
|
||||
|
||||
fn number_format(code: &str) -> Shortcut {
|
||||
Shortcut::Run("home.numberFormat", json!({"code": code}))
|
||||
}
|
||||
|
||||
fn borders(preset: &str) -> Shortcut {
|
||||
Shortcut::Run("home.borders", json!({"preset": preset}))
|
||||
}
|
||||
|
||||
/// The shortcut for a key press in the grid, if any. `physical` is the key's position on a US
|
||||
/// layout (as Excel matches Ctrl+Shift+4 rather than the `$` character); `mac` selects the Excel
|
||||
/// for Mac variants where they differ.
|
||||
pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Option<Shortcut> {
|
||||
use Key::*;
|
||||
let k = physical.unwrap_or(key);
|
||||
// Control itself (Excel for Mac also uses Control for these), Ctrl on Windows / Cmd on Mac,
|
||||
// and Alt/Option alone.
|
||||
let ctrl = m.ctrl && !m.alt;
|
||||
let cmd = m.command && !m.alt;
|
||||
let alt = m.alt && !m.ctrl && !m.command;
|
||||
let ctrl_alt = m.alt && (m.ctrl || m.command);
|
||||
let plain = !m.ctrl && !m.command && !m.alt;
|
||||
let s = m.shift;
|
||||
Some(match k {
|
||||
// Ctrl+Shift+~ ! @ # $ % ^ & _ : number formats and borders (Windows and Mac).
|
||||
Backtick if ctrl && s => Shortcut::Run("home.numberFormat", json!({"format": "General"})),
|
||||
Num1 if ctrl && s => number_format("#,##0.00"),
|
||||
Num2 if ctrl && s => number_format("h:mm AM/PM"),
|
||||
Num3 if ctrl && s => number_format("d-mmm-yy"),
|
||||
Num4 if ctrl && s => number_format("$#,##0.00_);($#,##0.00)"),
|
||||
Num5 if ctrl && s => number_format("0%"),
|
||||
Num6 if ctrl && s => number_format("0.00E+00"),
|
||||
Num7 if ctrl && s => borders("outside"),
|
||||
Minus if ctrl && s => borders("none"),
|
||||
Num8 if ctrl && s => run("selection.currentRegion"),
|
||||
Semicolon if ctrl && s => Shortcut::Time,
|
||||
Quote if ctrl && s => Shortcut::ValueAbove,
|
||||
Quote if ctrl => Shortcut::FormulaAbove,
|
||||
// Windows Ctrl+; is in `ribbon::shortcut`; Excel for Mac uses Control+; and Command+;.
|
||||
Semicolon if mac && ctrl => Shortcut::Date,
|
||||
Semicolon if mac && cmd && !s => Shortcut::Time,
|
||||
// Ctrl+2..5 (Windows).
|
||||
Num2 if ctrl && !mac => run("home.bold"),
|
||||
Num3 if ctrl && !mac => run("home.italic"),
|
||||
Num4 if ctrl && !mac => run("home.underline"),
|
||||
Num5 if ctrl && !mac => run("home.strikethrough"),
|
||||
// Letters that differ between Windows and Mac.
|
||||
L if (cmd || ctrl) && s => run("data.filter"),
|
||||
L if cmd && mac => run("home.alignLeft"),
|
||||
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
|
||||
U if ctrl && mac && !s => Shortcut::Edit,
|
||||
L if cmd => run("insert.table"),
|
||||
E if cmd && mac && !s => run("home.alignCenter"),
|
||||
E if ctrl && mac && !s => run("edit.flashFill"),
|
||||
F if cmd && s && !mac => Shortcut::Dialog("formatCells", json!({"tab": "Font"})),
|
||||
G if cmd && s && !mac => Shortcut::Dialog("statistics", json!({})),
|
||||
H if cmd && s && mac => Shortcut::Dialog("find", json!({"replace": true})),
|
||||
R if cmd && s && mac => Shortcut::Dialog("sort", json!({})),
|
||||
K if cmd && s && mac => run("data.group"),
|
||||
J if cmd && s && mac => run("data.ungroup"),
|
||||
P if cmd && !s => run("file.print"),
|
||||
Period if cmd && s => run("home.increaseFontSize"),
|
||||
Comma if cmd && s => run("home.decreaseFontSize"),
|
||||
Space if cmd && s => run("edit.selectAll"),
|
||||
Home if cmd && s => Shortcut::ExtendToStart,
|
||||
End if cmd && s => Shortcut::ExtendToEnd,
|
||||
Home if plain && s => Shortcut::ExtendToRowStart,
|
||||
// Ctrl+Alt / Cmd+Option combinations.
|
||||
V if ctrl_alt || (mac && m.ctrl && m.command) => Shortcut::Dialog("pasteSpecial", json!({})),
|
||||
Equals | Plus if ctrl_alt => Shortcut::Zoom(10),
|
||||
Minus if ctrl_alt && !mac => Shortcut::Zoom(-10),
|
||||
F9 if ctrl_alt => run("formulas.calculateNow"),
|
||||
R if ctrl_alt && mac => run("view.collapseRibbon"),
|
||||
Num0 if ctrl_alt && mac => borders("outside"),
|
||||
Minus if ctrl_alt && mac => borders("none"),
|
||||
ArrowLeft if ctrl_alt && mac => borders("left"),
|
||||
ArrowRight if ctrl_alt && mac => borders("right"),
|
||||
ArrowUp if ctrl_alt && mac => borders("top"),
|
||||
ArrowDown if ctrl_alt && mac => borders("bottom"),
|
||||
// Alt / Option alone.
|
||||
Equals if alt && !s && !mac => run("formulas.autoSum"),
|
||||
ArrowDown if alt && !s => Shortcut::DropDown,
|
||||
PageDown if alt => Shortcut::ScreenRight,
|
||||
PageUp if alt => Shortcut::ScreenLeft,
|
||||
ArrowRight if alt && s && !mac => run("data.group"),
|
||||
ArrowLeft if alt && s && !mac => run("data.ungroup"),
|
||||
ArrowRight if alt && !s && mac => run("sheet.next"),
|
||||
ArrowLeft if alt && !s && mac => run("sheet.previous"),
|
||||
F1 if alt && s => run("home.insertSheet"),
|
||||
F1 if alt => run("insert.chart"),
|
||||
F10 if alt && s => Shortcut::Dialog("errorChecking", json!({})),
|
||||
// Function keys.
|
||||
F1 if cmd && !s => run("view.collapseRibbon"),
|
||||
F2 if (cmd || ctrl) && s => Shortcut::Dialog("comment", json!({"threaded": true})),
|
||||
F2 if plain && s => Shortcut::Dialog("comment", json!({"threaded": false})),
|
||||
F3 if cmd && s => run("formulas.createFromSelection"),
|
||||
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
|
||||
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
|
||||
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
|
||||
F4 if cmd && !s => run("file.close"),
|
||||
F5 if plain && s => Shortcut::Dialog("find", json!({})),
|
||||
F5 if plain => Shortcut::Dialog("goTo", json!({})),
|
||||
F7 if plain && !s => Shortcut::Dialog("spelling", json!({})),
|
||||
F9 if plain && s => run("formulas.calculateSheet"),
|
||||
F10 if plain && s => Shortcut::ContextMenu,
|
||||
F11 if plain && s => run("home.insertSheet"),
|
||||
F12 if plain && !s => run("file.saveAs"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handles a key press in the grid. Returns whether it was a shortcut.
|
||||
pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
// AltGr arrives as Ctrl+Alt on Windows: a key that also typed a character (AltGr+= is `~`
|
||||
// on a German layout) is text, not a shortcut.
|
||||
if !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) {
|
||||
return false;
|
||||
}
|
||||
let Some(shortcut) = lookup(key, physical, m, mac) else { return false };
|
||||
let (active, anchor) = app.session.active().map(|d| (d.selection.active, d.selection.anchor)).unwrap_or_default();
|
||||
match shortcut {
|
||||
Shortcut::Run(id, params) => {
|
||||
app.run_or_alert(id, params);
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
Shortcut::Dialog(name, params) => app.open_dialog(name, params),
|
||||
Shortcut::Date | Shortcut::Time => {
|
||||
let now = gridcraft_engine::calc::now_serial();
|
||||
let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") };
|
||||
let text = app
|
||||
.session
|
||||
.active()
|
||||
.map(|d| gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text)
|
||||
.unwrap_or_default();
|
||||
edit_with(app, text);
|
||||
}
|
||||
Shortcut::FormulaAbove | Shortcut::ValueAbove => {
|
||||
let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true };
|
||||
let text = if shortcut == Shortcut::FormulaAbove {
|
||||
sh.cell(above).map(|c| c.input_text()).unwrap_or_default()
|
||||
} else {
|
||||
app.session
|
||||
.active()
|
||||
.and_then(|d| sh.value_ref(above).map(|v| gridcraft_engine::display::format(v, "General", &d.wb).text))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
edit_with(app, text);
|
||||
}
|
||||
Shortcut::ExtendToStart => extend(app, anchor, active, CellRef::new(0, 0)),
|
||||
Shortcut::ExtendToEnd => {
|
||||
let end = sh.used_range().map(|u| u.end).unwrap_or_default();
|
||||
extend(app, anchor, active, end);
|
||||
}
|
||||
Shortcut::ExtendToRowStart => extend(app, anchor, active, CellRef::new(active.row, 0)),
|
||||
Shortcut::ScreenRight | Shortcut::ScreenLeft => {
|
||||
let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0);
|
||||
let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64;
|
||||
let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols };
|
||||
let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
Shortcut::Edit => app.begin_edit(None, false),
|
||||
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
|
||||
Shortcut::DropDown => {
|
||||
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
|
||||
app.grid.list_picker = Some(active);
|
||||
}
|
||||
}
|
||||
Shortcut::Zoom(step) => {
|
||||
let percent = (i32::from(sh.zoom).saturating_add(step)).clamp(10, 400);
|
||||
app.run_or_alert("view.zoom", json!({"percent": percent}));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Starts editing the active cell with `text` and the caret at its end.
|
||||
fn edit_with(app: &mut SheetApp, text: String) {
|
||||
app.begin_edit(Some(text), false);
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
ed.caret = ed.text.chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects `anchor`..`to`, keeping the anchor and the active cell.
|
||||
fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) {
|
||||
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(anchor, to).a1(), "active": active.a1()}));
|
||||
if let Some(d) = app.session.active_mut() {
|
||||
d.selection.anchor = anchor;
|
||||
}
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod backstage;
|
||||
mod border_preview;
|
||||
mod cell_pictures;
|
||||
pub mod chartview;
|
||||
|
|
@ -18,6 +19,7 @@ mod formula_locale;
|
|||
pub mod grid;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod keymap;
|
||||
pub mod keytips;
|
||||
pub mod panes;
|
||||
pub mod pivot_pane;
|
||||
|
|
@ -45,7 +47,6 @@ pub struct UiState {
|
|||
pub system_theme: bool,
|
||||
pub ribbon_tab: String,
|
||||
pub ribbon_collapsed: bool,
|
||||
pub formula_bar: bool,
|
||||
pub formula_bar_expanded: bool,
|
||||
pub status_bar: bool,
|
||||
pub recent: Vec<String>,
|
||||
|
|
@ -55,6 +56,25 @@ pub struct UiState {
|
|||
/// rather than failing the whole `UiState`.
|
||||
#[serde(deserialize_with = "i18n::lenient")]
|
||||
pub language: i18n::Language,
|
||||
/// Interface size in percent on top of the desktop's scale (100 = Auto), applied as egui's
|
||||
/// zoom factor so the ribbon, menus, dialogs and grid grow together. Kept within
|
||||
/// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100.
|
||||
#[serde(deserialize_with = "lenient_interface_scale")]
|
||||
pub interface_scale: u16,
|
||||
}
|
||||
|
||||
pub const INTERFACE_SCALE_MIN: u16 = 75;
|
||||
pub const INTERFACE_SCALE_MAX: u16 = 200;
|
||||
|
||||
/// A requested interface size, rounded and clamped to the supported range; `None` when it isn't
|
||||
/// a finite number.
|
||||
pub fn clamp_interface_scale(percent: f64) -> Option<u16> {
|
||||
percent.is_finite().then(|| percent.round().clamp(INTERFACE_SCALE_MIN as f64, INTERFACE_SCALE_MAX as f64) as u16)
|
||||
}
|
||||
|
||||
fn lenient_interface_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u16, D::Error> {
|
||||
let v = Json::deserialize(d)?;
|
||||
Ok(v.as_f64().and_then(clamp_interface_scale).unwrap_or(100))
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -64,11 +84,11 @@ impl Default for UiState {
|
|||
system_theme: false,
|
||||
ribbon_tab: "Home".into(),
|
||||
ribbon_collapsed: false,
|
||||
formula_bar: true,
|
||||
formula_bar_expanded: false,
|
||||
status_bar: true,
|
||||
recent: vec![],
|
||||
language: i18n::Language::En,
|
||||
interface_scale: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +104,11 @@ impl UiState {
|
|||
}
|
||||
}
|
||||
|
||||
/// The zoom factor for the interface size preference.
|
||||
pub fn zoom_factor(&self) -> f32 {
|
||||
clamp_interface_scale(self.interface_scale as f64).unwrap_or(100) as f32 / 100.0
|
||||
}
|
||||
|
||||
pub fn theme_mode(&self) -> &'static str {
|
||||
if self.system_theme {
|
||||
"system"
|
||||
|
|
@ -115,6 +140,10 @@ pub struct Services {
|
|||
/// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
|
||||
/// write (including any asynchronous fallback); an immediate error uses egui's text path.
|
||||
pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
|
||||
/// Read the HTML flavour of the system clipboard when the user pastes text, so cells the
|
||||
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
|
||||
/// the host pastes the plain text alone.
|
||||
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Web: deliver a file to the user (name, bytes).
|
||||
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
|
||||
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
|
||||
|
|
@ -141,6 +170,8 @@ pub struct SheetApp {
|
|||
pub editor: Option<editor::EditState>,
|
||||
pub text_box_editor: Option<text_box::EditState>,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
/// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs).
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pub message: Option<(String, String)>,
|
||||
pub toast: Option<(String, f64)>,
|
||||
pub services: Services,
|
||||
|
|
@ -173,6 +204,7 @@ impl SheetApp {
|
|||
editor: None,
|
||||
text_box_editor: None,
|
||||
dialog: None,
|
||||
backstage: None,
|
||||
message: None,
|
||||
toast: None,
|
||||
services,
|
||||
|
|
@ -250,7 +282,10 @@ impl SheetApp {
|
|||
/// their existing internal clipboard behavior and do not write to the host clipboard.
|
||||
pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) {
|
||||
// Only ask the engine for HTML when the host can publish it.
|
||||
let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return };
|
||||
let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) {
|
||||
Ok(result) => result,
|
||||
Err(e) => return self.alert(e),
|
||||
};
|
||||
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
|
||||
if let Some(html) = result.get("html").and_then(Json::as_str)
|
||||
&& let Some(copy_html) = &mut self.services.copy_html
|
||||
|
|
@ -263,6 +298,17 @@ impl SheetApp {
|
|||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
|
||||
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
|
||||
/// host can read it.
|
||||
pub fn paste_external(&mut self, text: String) {
|
||||
let html = self.services.paste_html.as_mut().and_then(|read| read());
|
||||
let params = match html {
|
||||
Some(html) => json!({"text": text, "html": html}),
|
||||
None => json!({"text": text}),
|
||||
};
|
||||
self.run_or_alert("edit.paste", params);
|
||||
}
|
||||
|
||||
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
|
||||
fn deliver_pdf(&self, result: &Json) {
|
||||
if result.get("path").is_some() {
|
||||
|
|
@ -279,11 +325,16 @@ impl SheetApp {
|
|||
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
|
||||
pub fn run_or_alert(&mut self, id: &str, params: Json) {
|
||||
if let Err(e) = self.run(id, params) {
|
||||
if e.contains("is not available right now") {
|
||||
self.toast = Some((e, now_ms()));
|
||||
} else {
|
||||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||||
}
|
||||
self.alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a command error: a toast for an unavailable command, else a message box.
|
||||
fn alert(&mut self, e: String) {
|
||||
if e.contains("is not available right now") {
|
||||
self.toast = Some((e, now_ms()));
|
||||
} else {
|
||||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,10 +345,6 @@ impl SheetApp {
|
|||
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"view.formulaBar" => {
|
||||
self.ui.formula_bar = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar);
|
||||
Ok(json!({"on": self.ui.formula_bar}))
|
||||
}
|
||||
"view.collapseRibbon" => {
|
||||
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
||||
Ok(Json::Null)
|
||||
|
|
@ -319,7 +366,21 @@ impl SheetApp {
|
|||
}
|
||||
Ok(json!({"mode": self.ui.theme_mode()}))
|
||||
}
|
||||
"view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())),
|
||||
"view.interfaceScale" => {
|
||||
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
|
||||
let percent = match p.get("percent") {
|
||||
Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100),
|
||||
Some(v) => v.as_f64().and_then(clamp_interface_scale),
|
||||
None => None,
|
||||
};
|
||||
match percent {
|
||||
Some(v) => {
|
||||
self.ui.interface_scale = v;
|
||||
Ok(json!({"percent": v}))
|
||||
}
|
||||
None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")),
|
||||
}
|
||||
}
|
||||
"app.language.set" => {
|
||||
// `{"language": "ja"}`; `code` is accepted as an alias.
|
||||
let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or("");
|
||||
|
|
@ -328,7 +389,7 @@ impl SheetApp {
|
|||
self.ui.language = l;
|
||||
Ok(json!({"language": l}))
|
||||
}
|
||||
None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\" or \"pt\")")),
|
||||
None => Err(format!("unknown language {code:?} (use \"en\", \"es\", \"zh\", \"ja\", \"ko\", \"ru\", \"pt\" or \"cs\")")),
|
||||
}
|
||||
}
|
||||
"app.language.english" => {
|
||||
|
|
@ -339,6 +400,22 @@ impl SheetApp {
|
|||
self.ui.language = i18n::Language::Ja;
|
||||
Ok(json!({"language": i18n::Language::Ja}))
|
||||
}
|
||||
"ui.backstage" => {
|
||||
// `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet.
|
||||
match p.get("page").and_then(Json::as_str).map(str::trim) {
|
||||
Some("close" | "none" | "") => self.backstage = None,
|
||||
Some(id) => match backstage::Page::parse(id) {
|
||||
Some(page) => self.open_backstage(page),
|
||||
None => {
|
||||
return Some(Err(format!(
|
||||
"unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)"
|
||||
)));
|
||||
}
|
||||
},
|
||||
None => self.open_backstage(backstage::Page::Home),
|
||||
}
|
||||
Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}))
|
||||
}
|
||||
"ui.dialog" => {
|
||||
let name = p.get("name").and_then(Json::as_str).unwrap_or("");
|
||||
self.open_dialog(name, p.clone());
|
||||
|
|
@ -407,10 +484,28 @@ impl SheetApp {
|
|||
dl(&name, &b);
|
||||
}
|
||||
}
|
||||
// File › Options (`file.options`) and the File tab itself open the Backstage view.
|
||||
"options" => self.open_backstage(backstage::Page::Options),
|
||||
"backstage" => {
|
||||
let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home);
|
||||
self.open_backstage(page);
|
||||
}
|
||||
other => self.dialog = dialogs::Dialog::open(self, other, params),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the File tab's Backstage view on `page`, committing a cell still being edited first
|
||||
/// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet.
|
||||
pub fn open_backstage(&mut self, page: backstage::Page) {
|
||||
if !self.commit_edit(0, 0, false, false) {
|
||||
return;
|
||||
}
|
||||
match &mut self.backstage {
|
||||
Some(b) => b.page = page,
|
||||
None => self.backstage = Some(backstage::Backstage::new(page)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match self.run("file.open", json!({"path": path})) {
|
||||
Ok(_) => {
|
||||
|
|
@ -567,6 +662,16 @@ impl SheetApp {
|
|||
ctx.request_repaint();
|
||||
}
|
||||
self.effective_dark = ctx.theme() == egui::Theme::Dark;
|
||||
// Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom
|
||||
// keys stay off (they would also swallow GridCraft's shortcuts).
|
||||
if ctx.options(|o| o.zoom_with_keyboard) {
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
}
|
||||
let zoom = self.ui.zoom_factor();
|
||||
if (ctx.zoom_factor() - zoom).abs() > 0.001 {
|
||||
ctx.set_zoom_factor(zoom);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::poll(self, ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
let arrived: Vec<(String, Vec<u8>)> = self
|
||||
|
|
@ -577,8 +682,10 @@ impl SheetApp {
|
|||
.unwrap_or_default();
|
||||
for (name, bytes) in arrived {
|
||||
let b64 = gridcraft_engine::io::base64_encode(&bytes);
|
||||
if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) {
|
||||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||||
match self.run("file.open", json!({"name": name, "base64": b64})) {
|
||||
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
|
||||
Ok(_) => self.backstage = None,
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
|
|
@ -605,23 +712,30 @@ impl SheetApp {
|
|||
// The language the widgets translate with this frame (see `i18n::current`).
|
||||
i18n::set_current(&ctx, self.ui.language);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
self.ribbon_keys(&ctx);
|
||||
if self.backstage.is_none() {
|
||||
self.ribbon_keys(&ctx);
|
||||
}
|
||||
ribbon::title_bar(self, ui);
|
||||
ribbon::show(self, ui);
|
||||
if self.ui.formula_bar {
|
||||
formula_bar::show(self, ui);
|
||||
if self.backstage.is_some() {
|
||||
// File tab: the Backstage view replaces everything below the title bar.
|
||||
backstage::show(self, ui);
|
||||
} else {
|
||||
ribbon::show(self, ui);
|
||||
if self.session.prefs.formula_bar {
|
||||
formula_bar::show(self, ui);
|
||||
}
|
||||
if self.ui.status_bar {
|
||||
tabs::status_bar(self, ui);
|
||||
}
|
||||
tabs::sheet_tabs(self, ui);
|
||||
pivot_pane::show(self, ui);
|
||||
panes::show(self, ui);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| {
|
||||
let g0 = now_ms();
|
||||
grid::show(self, ui);
|
||||
self.perf.grid_ms = now_ms() - g0;
|
||||
});
|
||||
}
|
||||
if self.ui.status_bar {
|
||||
tabs::status_bar(self, ui);
|
||||
}
|
||||
tabs::sheet_tabs(self, ui);
|
||||
pivot_pane::show(self, ui);
|
||||
panes::show(self, ui);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| {
|
||||
let g0 = now_ms();
|
||||
grid::show(self, ui);
|
||||
self.perf.grid_ms = now_ms() - g0;
|
||||
});
|
||||
dialogs::show(self, &ctx);
|
||||
widgets::message_box(self, &ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if ui.button("Refresh").clicked() {
|
||||
app.run_or_alert("pivot.refresh", json!({"pivot": name}));
|
||||
}
|
||||
if ui.button("Calculated Field…").clicked() {
|
||||
app.run_or_alert("pivot.calculatedField", json!({"pivot": name}));
|
||||
}
|
||||
egui::ComboBox::from_id_salt("pv_layout").selected_text("Report Layout").show_ui(ui, |ui| {
|
||||
for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] {
|
||||
if ui.button(l).clicked() {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,33 @@ use crate::widgets::{big_button, color_palette, icon_button, small_button, split
|
|||
|
||||
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
|
||||
|
||||
/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the
|
||||
/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take
|
||||
/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit
|
||||
/// vertically centred in it, with the controls beside them (#170).
|
||||
const MAC: bool = cfg!(target_os = "macos");
|
||||
pub const MAC_TITLE_BAR: f32 = 28.0;
|
||||
/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the
|
||||
/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the
|
||||
/// gap after them from shrinking).
|
||||
const MAC_LIGHT: f32 = 12.0;
|
||||
const MAC_LIGHT_PITCH: f32 = 20.0;
|
||||
const MAC_LIGHTS_LEFT: f32 = 10.0;
|
||||
/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights
|
||||
/// keep from the top of the window.
|
||||
pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
|
||||
|
||||
/// Leave room for the macOS traffic lights when the content extends into the title bar.
|
||||
pub const TITLE_LEFT_PAD: f32 = if cfg!(target_os = "macos") { 76.0 } else { 8.0 };
|
||||
pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 };
|
||||
const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 };
|
||||
/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar.
|
||||
const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 };
|
||||
const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below");
|
||||
|
||||
pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
|
||||
let ch = TITLE_CONTROL_H;
|
||||
egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
// Dragging the empty title area moves the window.
|
||||
let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag());
|
||||
|
|
@ -41,19 +62,21 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
app.grid.toggle_autosave();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, 26.0)).clicked() {
|
||||
if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)).clicked() {
|
||||
app.open_dialog("start", json!({}));
|
||||
}
|
||||
if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, 26.0)).clicked() {
|
||||
if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked()
|
||||
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
|
||||
&& app.commit_edit(0, 0, false, false)
|
||||
{
|
||||
app.run_or_alert("file.save", json!({}));
|
||||
}
|
||||
let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty());
|
||||
let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty());
|
||||
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, 26.0)).clicked() && can_undo
|
||||
{
|
||||
if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo (⌘Z)", vec2(26.0, ch)).clicked() && can_undo {
|
||||
app.run_or_alert("edit.undo", json!({}));
|
||||
}
|
||||
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, 26.0));
|
||||
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch));
|
||||
egui::Popup::menu(&undo_list).show(|ui| {
|
||||
let labels: Vec<String> =
|
||||
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default();
|
||||
|
|
@ -66,11 +89,10 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, 26.0)).clicked() && can_redo
|
||||
{
|
||||
if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo (⌘Y)", vec2(26.0, ch)).clicked() && can_redo {
|
||||
app.run_or_alert("edit.redo", json!({}));
|
||||
}
|
||||
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, 26.0));
|
||||
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch));
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
for (label, id) in [
|
||||
("New Workbook", "file.new"),
|
||||
|
|
@ -97,7 +119,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(8.0);
|
||||
if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, 26.0)).clicked() {
|
||||
if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)).clicked() {
|
||||
app.open_dialog("commandSearch", json!({}));
|
||||
}
|
||||
let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true);
|
||||
|
|
@ -110,9 +132,19 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, 26.0)).clicked() {
|
||||
if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)).clicked() {
|
||||
app.open_dialog("comments", json!({}));
|
||||
}
|
||||
// Quick light/dark switch (#194); right-click offers System as well. The same
|
||||
// choice lives in File › Display theme and the View tab.
|
||||
let lang = app.ui.language;
|
||||
let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0));
|
||||
let dark = ui.ctx().theme() == egui::Theme::Dark;
|
||||
theme.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, dark, lang.tr("Dark Mode")));
|
||||
if theme.clicked() {
|
||||
act(app, "view.darkMode", json!({}));
|
||||
}
|
||||
theme.context_menu(|ui| theme_choices(app, ui));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -207,30 +239,27 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
|
||||
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
|
||||
fn file_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent);
|
||||
let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false));
|
||||
egui::Popup::menu(&file).show(|ui| {
|
||||
for (label, id, needs_document) in [
|
||||
("New Workbook", "file.new", false),
|
||||
("Open…", "file.open", false),
|
||||
("Save", "file.save", true),
|
||||
("Save As…", "file.saveAs", true),
|
||||
("Close", "file.close", true),
|
||||
] {
|
||||
if id == "file.save" || id == "file.close" {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() {
|
||||
// File operations must include the cell the user is still editing.
|
||||
if app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert(id, json!({}));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
let label = egui::RichText::new(app.ui.language.tr("File")).font(theme::ui_font(13.5)).color(t.accent);
|
||||
let file = ui.add(egui::Button::new(label).frame(false).min_size(vec2(46.0, 30.0)));
|
||||
if file.clicked() {
|
||||
app.open_backstage(crate::backstage::Page::Home);
|
||||
}
|
||||
}
|
||||
|
||||
/// System / Light / Dark, as in the View tab's Display theme box (`view.theme`).
|
||||
fn theme_choices(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
let mode = app.ui.theme_mode();
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.radio(mode == value, lang.tr(label)).clicked() {
|
||||
act(app, "view.theme", json!({"mode": value}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
|
||||
|
|
@ -323,7 +352,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
if paste.clicked() {
|
||||
if let Some(text) = app.grid.system_clipboard.take() {
|
||||
act(app, "edit.paste", json!({"text": text}));
|
||||
app.paste_external(text);
|
||||
} else {
|
||||
act(app, "edit.paste", json!({}));
|
||||
}
|
||||
|
|
@ -332,10 +361,13 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
// Keep a keyboard-opened menu anchored to the button when a pointer click
|
||||
// cancels keytips, so its items remain in place and can still be clicked.
|
||||
let anchor_id = paste.id.with("keytip_anchor");
|
||||
// Query response state before taking the egui context write lock.
|
||||
// Calling secondary_clicked() inside data_mut can deadlock.
|
||||
let paste_secondary_clicked = paste.secondary_clicked();
|
||||
let anchored = ui.ctx().data_mut(|data| {
|
||||
let anchored = if paste_keys {
|
||||
true
|
||||
} else if paste.secondary_clicked() {
|
||||
} else if paste_secondary_clicked {
|
||||
false
|
||||
} else {
|
||||
data.get_temp::<bool>(anchor_id).unwrap_or(false)
|
||||
|
|
@ -1143,7 +1175,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
|||
});
|
||||
sep(ui);
|
||||
if big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false).clicked() {
|
||||
act(app, "insert.recommendedCharts", json!({}));
|
||||
app.open_dialog("recommendedCharts", json!({}));
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -1228,6 +1260,11 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
sep(ui);
|
||||
// Cell Controls.
|
||||
if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() {
|
||||
act(app, "insert.checkbox", json!({}));
|
||||
}
|
||||
sep(ui);
|
||||
if big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false).clicked() {
|
||||
app.open_dialog("insertLink", json!({}));
|
||||
}
|
||||
|
|
@ -1390,6 +1427,15 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
|
|||
act(app, "pageLayout.printHeadings", json!({"on": p}));
|
||||
}
|
||||
});
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
|
||||
let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false);
|
||||
if rtl {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
}
|
||||
if r.clicked() {
|
||||
act(app, "view.rightToLeft", json!({"on": !rtl}));
|
||||
}
|
||||
}
|
||||
|
||||
fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|
|
@ -1580,6 +1626,14 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
|||
app.open_dialog("subtotal", json!({}));
|
||||
}
|
||||
});
|
||||
sep(ui);
|
||||
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
|
||||
app.open_dialog("analysisToolPak", json!({}));
|
||||
}
|
||||
sep(ui);
|
||||
if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() {
|
||||
app.open_dialog("solver", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn review(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|
|
@ -1618,6 +1672,9 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() {
|
||||
act(app, "review.protectWorkbook", json!({}));
|
||||
}
|
||||
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
|
||||
app.open_dialog("allowEditRanges", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn view(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|
|
@ -1636,9 +1693,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true));
|
||||
let lang = app.ui.language;
|
||||
ui.vertical(|ui| {
|
||||
let mut fb = app.ui.formula_bar;
|
||||
let mut fb = app.session.prefs.formula_bar;
|
||||
if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() {
|
||||
app.ui.formula_bar = fb;
|
||||
act(app, "view.formulaBar", json!({"on": fb}));
|
||||
}
|
||||
let mut g = s.0;
|
||||
if ui.checkbox(&mut g, lang.tr("Gridlines")).changed() {
|
||||
|
|
@ -1654,7 +1711,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
app.open_dialog("zoom", json!({}));
|
||||
}
|
||||
if big_button(ui, Icon::Search, "100%", "Zoom to 100%", false).clicked() {
|
||||
act(app, "view.zoom", json!({"percent": 100}));
|
||||
act(app, "view.zoom100", json!({}));
|
||||
}
|
||||
if big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false).clicked() {
|
||||
let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0)));
|
||||
|
|
@ -1691,6 +1748,22 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
});
|
||||
});
|
||||
sep(ui);
|
||||
// Interface size (issue #230): scales the whole window on top of the desktop's scale.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
let current = app.ui.interface_scale;
|
||||
let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") };
|
||||
egui::ComboBox::from_id_salt("ui_interface_size")
|
||||
.width(120.0)
|
||||
.selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5)))
|
||||
.show_ui(ui, |ui| {
|
||||
for v in [100, 75, 90, 110, 125, 150, 175, 200] {
|
||||
if ui.selectable_label(v == current, text(v)).clicked() {
|
||||
act(app, "view.interfaceScale", json!({"percent": v}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Interface language: settings live in the View tab, like Excel's Options. The label follows
|
||||
// the current language so it stays readable; the choices are always shown in their own script.
|
||||
ui.vertical(|ui| {
|
||||
|
|
@ -1733,9 +1806,10 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) {
|
|||
};
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Table Name:").small());
|
||||
let mut name = tname.clone();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(120.0));
|
||||
if r.lost_focus() && name != tname {
|
||||
let field = egui::Id::new(("gridcraft.table_name", &tname));
|
||||
if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0))
|
||||
&& name != tname
|
||||
{
|
||||
act(app, "table.rename", json!({"table": tname, "name": name}));
|
||||
}
|
||||
});
|
||||
|
|
@ -1799,6 +1873,9 @@ fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false).clicked() {
|
||||
act(app, "chart.switchRowColumn", json!({"chart": id}));
|
||||
}
|
||||
if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() {
|
||||
app.open_dialog("selectData", json!({"chart": id}));
|
||||
}
|
||||
let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true);
|
||||
egui::Popup::menu(&ct).show(|ui| {
|
||||
for (label, k, s) in [
|
||||
|
|
@ -1910,3 +1987,16 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
|||
fn unused(_: &Tokens, _: Icon) {
|
||||
let _ = icons::GREEN;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mac_title_bar_centres_and_clears_the_traffic_lights() {
|
||||
// The lights are centred in the bar, and content keeps the same gap after them.
|
||||
let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
|
||||
let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT;
|
||||
assert_eq!(MAC_CONTENT_LEFT - lights_end, margin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
|||
None
|
||||
}
|
||||
});
|
||||
let rect = crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells);
|
||||
let rect = geo.mirror_rect(crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells));
|
||||
if !rect.is_positive() {
|
||||
ed.rect = None;
|
||||
app.text_box_editor = Some(ed);
|
||||
|
|
|
|||
|
|
@ -166,10 +166,28 @@ pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
|
|||
)
|
||||
}
|
||||
|
||||
/// Whether epaint can open face `index` of this font file and it maps characters to glyphs.
|
||||
/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font
|
||||
/// file we did not ship is checked first with the same parser and the same call epaint makes
|
||||
/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a
|
||||
/// collection index past the end is rejected instead of crashing the app (#226). A face whose
|
||||
/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused
|
||||
/// too and the next candidate gets its turn.
|
||||
pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map())
|
||||
}
|
||||
|
||||
/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is
|
||||
/// skipped with a warning and the next candidate is tried.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
for (p, index) in paths {
|
||||
if let Ok(bytes) = std::fs::read(p) {
|
||||
if !font_parses(&bytes, *index) {
|
||||
log::warn!("skipping font {p} (face {index}): not a font file egui can read");
|
||||
continue;
|
||||
}
|
||||
let mut fd = FontData::from_owned(bytes);
|
||||
fd.index = *index;
|
||||
return Some(fd);
|
||||
|
|
@ -178,6 +196,21 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
|
||||
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
|
||||
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
|
||||
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
|
||||
for key in &bad {
|
||||
log::warn!("dropping font {key:?}: egui can't parse it");
|
||||
fonts.font_data.remove(key);
|
||||
}
|
||||
if !bad.is_empty() {
|
||||
for chain in fonts.families.values_mut() {
|
||||
chain.retain(|k| !bad.contains(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
None
|
||||
|
|
@ -340,6 +373,7 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
|
|||
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
|
||||
fonts.families.insert(FontFamily::Proportional, ui);
|
||||
}
|
||||
drop_unparseable_fonts(&mut fonts);
|
||||
fonts
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +621,7 @@ mod tests {
|
|||
assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
|
||||
assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]);
|
||||
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
|
||||
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr] {
|
||||
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs] {
|
||||
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
|
||||
}
|
||||
}
|
||||
|
|
@ -613,13 +647,15 @@ mod tests {
|
|||
// Real render check, per script: the proportional family (what cells and the UI use) must
|
||||
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
|
||||
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 6] = [
|
||||
let cases: [(&str, Vec<(String, u32)>, &str); 7] = [
|
||||
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
|
||||
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
|
||||
("sys-cjk-hangul", cjk_hangul(), "한글"),
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
("ui", candidates(UI), "Русский"),
|
||||
// Czech interface labels: every Czech accented letter, in both cases.
|
||||
("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
|
||||
];
|
||||
for language in [Language::En, Language::Ja] {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
@ -650,4 +686,51 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_font_files_are_rejected_not_fatal() {
|
||||
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
|
||||
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
||||
assert!(font_parses(&real, 0), "egui's own font must parse");
|
||||
assert!(!font_parses(b"", 0));
|
||||
assert!(!font_parses(&[0xAB; 64], 0));
|
||||
assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused");
|
||||
assert!(!font_parses(&real, 3), "a single-face file has no face 3");
|
||||
|
||||
// A bad face that slipped into the definitions is dropped (key and family entries), and the
|
||||
// context builds its fonts without panicking.
|
||||
let mut fonts = font_definitions();
|
||||
fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0])));
|
||||
for chain in fonts.families.values_mut() {
|
||||
chain.push("sys-hebrew".into());
|
||||
}
|
||||
drop_unparseable_fonts(&mut fonts);
|
||||
assert!(!fonts.font_data.contains_key("sys-hebrew"));
|
||||
assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew")));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(fonts);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.label("Hebrew עברית");
|
||||
});
|
||||
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn try_load_skips_a_broken_candidate() {
|
||||
// A candidate that exists but is not a readable font must not win over a good one behind it.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let bad = dir.join("broken.ttf");
|
||||
let good = dir.join("good.ttf");
|
||||
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
|
||||
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
|
||||
let _ = std::fs::write(&good, &real);
|
||||
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
|
||||
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
|
||||
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
|
||||
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -83,3 +83,36 @@ fn programmatic_copy_does_not_write_to_the_system_clipboard() {
|
|||
assert_eq!(*calls.borrow(), 0);
|
||||
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_reads_the_clipboard_html_for_the_numbers_behind_the_text() {
|
||||
// #257: currency text from another spreadsheet stays a number when the host offers the HTML.
|
||||
let mut h = harness(Services {
|
||||
paste_html: Some(Box::new(|| Some(r#"<table><tr><td x:num="10000">Rp10,000.00</td></tr></table>"#.to_string()))),
|
||||
..Default::default()
|
||||
});
|
||||
h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap();
|
||||
h.input_mut().events.push(Event::Paste("Rp10,000.00".into()));
|
||||
h.run_steps(2);
|
||||
let value = h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(4, 3))));
|
||||
assert_eq!(value, Some(gridcraft_engine::core::Value::number(10000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
|
||||
// Issue #348: lined-up areas copy as one block; others get Excel's message instead of nothing.
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "B1:B2,A1:A2"})).unwrap();
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n")));
|
||||
assert!(h.state().message.is_none());
|
||||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2));
|
||||
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A1,B2"})).unwrap();
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
|
||||
assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections."));
|
||||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//! The shared ribbon exposes basic file operations without requiring a native menu.
|
||||
//! The File tab opens the Backstage view, which exposes the file operations without a native menu.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -21,15 +21,26 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
|||
h
|
||||
}
|
||||
|
||||
/// Opens File (the Backstage view) and clicks a navigation entry or page button.
|
||||
fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label(label).click();
|
||||
if h.state().backstage.is_none() {
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
click(h, label);
|
||||
}
|
||||
|
||||
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> {
|
||||
h.state().backstage.as_ref().map(|b| b.page.id())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() {
|
||||
fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
|
||||
let opened = Arc::new(Mutex::new(0));
|
||||
let capture = opened.clone();
|
||||
let mut h = harness(Services {
|
||||
|
|
@ -42,39 +53,85 @@ fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() {
|
|||
h.state_mut().ui.ribbon_tab = "Data".into();
|
||||
h.state_mut().ui.ribbon_collapsed = true;
|
||||
h.run_steps(2);
|
||||
choose(&mut h, "New Workbook");
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home");
|
||||
choose(&mut h, "New");
|
||||
assert_eq!(page(&h), Some("new"));
|
||||
click(&mut h, "Blank workbook");
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
choose(&mut h, "Open…");
|
||||
assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker");
|
||||
assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet");
|
||||
choose(&mut h, "Open");
|
||||
click(&mut h, "Browse…");
|
||||
assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker");
|
||||
assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().backstage.is_none(), "Esc returns to the sheet");
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Data");
|
||||
assert!(h.state().ui.ribbon_collapsed);
|
||||
assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
|
||||
let downloads = Arc::new(Mutex::new(Vec::<Vec<u8>>::new()));
|
||||
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||||
let capture = downloads.clone();
|
||||
let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() });
|
||||
let mut h = harness(Services {
|
||||
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))),
|
||||
..Default::default()
|
||||
});
|
||||
h.input_mut().events.push(egui::Event::Text("123".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().editor.is_some());
|
||||
choose(&mut h, "Save");
|
||||
choose(&mut h, "Save As…");
|
||||
assert!(h.state().editor.is_none(), "opening File commits the cell draft");
|
||||
assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As");
|
||||
click(&mut h, "Download (.xlsx)");
|
||||
assert!(h.state().backstage.is_none());
|
||||
choose(&mut h, "Save As");
|
||||
click(&mut h, "Download (.xlsx)");
|
||||
choose(&mut h, "Export");
|
||||
click(&mut h, "CSV (Comma delimited)");
|
||||
let saved = downloads.lock().unwrap();
|
||||
assert_eq!(saved.len(), 2);
|
||||
for bytes in saved.iter() {
|
||||
assert_eq!(saved.len(), 3);
|
||||
for (_, bytes) in saved.iter().take(2) {
|
||||
let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
|
||||
}
|
||||
assert!(saved[2].0.ends_with(".csv"));
|
||||
assert!(String::from_utf8_lossy(&saved[2].1).contains("123"));
|
||||
drop(saved);
|
||||
assert!(h.state().editor.is_none());
|
||||
choose(&mut h, "Close");
|
||||
assert!(h.state().backstage.is_none());
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
let mut h = harness(Services::default());
|
||||
let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["mode"], "automatic");
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some("options"));
|
||||
click(&mut h, "Info");
|
||||
assert_eq!(page(&h), Some("info"));
|
||||
for id in ["print", "export", "saveAs", "new", "open", "home"] {
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(id));
|
||||
}
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err());
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
|
||||
assert!(h.state().backstage.is_none());
|
||||
// The back arrow returns to the sheet too.
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
click(&mut h, "Back");
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_pdf_is_delivered_as_a_download() {
|
||||
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||||
|
|
|
|||
|
|
@ -183,5 +183,21 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
|
|||
assert!(!enter(&mut app, "=SUMA(1;2)"));
|
||||
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::PtBr);
|
||||
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientation");
|
||||
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_interface_retains_its_canonical_formula_fallback() {
|
||||
// The Czech table translates interface labels only: formulas keep canonical English names,
|
||||
// `,` separators and `.` decimals until a Czech formula locale exists.
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Cs);
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
assert!(!enter(&mut app, "=SUMA(1;2)"));
|
||||
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
//! Freezing panes by dragging the freeze handles on the header edges (#195).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::grid::{Geo, freeze_handles};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn geo(h: &Harness) -> Geo {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
|
||||
let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
h.input_mut().events.extend([Event::PointerMoved(from), press(from, true)]);
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
let t = i as f32 / 6.0;
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * t));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(press(to, false));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn freeze(h: &Harness) -> Option<(u32, u32)> {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().freeze
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_handles_freezes_moves_and_unfreezes() {
|
||||
let mut h = harness();
|
||||
assert_eq!(freeze(&h), None);
|
||||
// Column handle from the corner to just past column C's left edge: two columns frozen.
|
||||
let g = geo(&h);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
let (cols, _) = freeze_handles(&g);
|
||||
let target = pos2(g.cell_rect(&sh, CellRef::new(0, 2)).left() + 3.0, g.cells.top() + 40.0);
|
||||
drag(&mut h, cols.center(), target);
|
||||
assert_eq!(freeze(&h), Some((0, 2)));
|
||||
|
||||
// Row handle down to row 4's top: three rows frozen, columns kept.
|
||||
let g = geo(&h);
|
||||
let (_, rows) = freeze_handles(&g);
|
||||
let target = pos2(g.cells.left() + 40.0, g.cell_rect(&sh, CellRef::new(3, 4)).top() + 2.0);
|
||||
drag(&mut h, rows.center(), target);
|
||||
assert_eq!(freeze(&h), Some((3, 2)));
|
||||
|
||||
// Both handles back to the corner unfreeze.
|
||||
let g = geo(&h);
|
||||
let (cols, _) = freeze_handles(&g);
|
||||
drag(&mut h, cols.center(), pos2(g.cells.left() - 5.0, cols.center().y));
|
||||
assert_eq!(freeze(&h), Some((3, 0)));
|
||||
let g = geo(&h);
|
||||
let (_, rows) = freeze_handles(&g);
|
||||
drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0));
|
||||
assert_eq!(freeze(&h), None);
|
||||
}
|
||||
|
|
@ -114,3 +114,41 @@ fn dragging_a_header_boundary_still_resizes_normally() {
|
|||
assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.autofit")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line() {
|
||||
// Issue #341: Ctrl-selected rows/columns resize together; unselected lines keep their size.
|
||||
for (rows, selection, dragged, changed) in [
|
||||
(false, "A:A,C:C", 2, [true, false, true, false]),
|
||||
(false, "A:A,C:C", 0, [true, false, true, false]),
|
||||
(false, "A:A,C:D", 3, [true, false, true, true]),
|
||||
(false, "A:A,C:C", 1, [false, true, false, false]),
|
||||
(false, "A:A,C3", 0, [true, false, false, false]),
|
||||
(true, "1:1,3:3", 2, [true, false, true, false]),
|
||||
(true, "1:1,3:3", 0, [true, false, true, false]),
|
||||
(true, "1:1,3:4", 3, [true, false, true, true]),
|
||||
(true, "1:1,3:3", 1, [false, true, false, false]),
|
||||
(true, "1:1,C3", 0, [true, false, false, false]),
|
||||
] {
|
||||
let mut h = harness(selection);
|
||||
let before = sizes(&h, rows);
|
||||
let (start, zoom) = edge(&h, rows, dragged);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
let after = sizes(&h, rows);
|
||||
let expected = (before[dragged as usize] + 30.0 / zoom).round();
|
||||
for (index, changed) in changed.iter().enumerate() {
|
||||
let want = if *changed { expected } else { before[index] };
|
||||
assert_eq!(after[index], want, "selection {selection}, dragged {dragged}, line {index}");
|
||||
}
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), 1, "selection {selection}: one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(sizes(&h, rows), before, "selection {selection}: undo restores every line");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! UI interaction tests: real egui input through the whole app (no GPU needed).
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
|
|
@ -85,6 +86,21 @@ fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) {
|
|||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_chart(h: &mut egui_kittest::Harness<'static, SheetApp>, id: u32) {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let anchor = sheet.charts.iter().find(|chart| chart.id == id).unwrap().anchor;
|
||||
let pos = egui::pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z)
|
||||
+ egui::vec2(anchor.width, anchor.height) * geo.z / 2.0;
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_navigation_does_not_replay_the_last_pointer_click() {
|
||||
let mut h = harness(blank());
|
||||
|
|
@ -125,6 +141,26 @@ fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() {
|
|||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() {
|
||||
// #247: typing A1 in the Name Box and pressing Enter selected A2; the grid replayed the Enter.
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("gridcraft.name_box")));
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text("A1".into()));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
|
||||
assert!(h.state().name_box.is_none());
|
||||
// The grid has the focus back: a plain Enter there still moves down.
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_enters_values_and_moves_down() {
|
||||
let mut h = harness(blank());
|
||||
|
|
@ -170,6 +206,24 @@ fn shortcuts_format_and_extend() {
|
|||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_key_removes_the_selected_chart() {
|
||||
let mut session = blank();
|
||||
session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
let id = session.execute("insert.chart", json!({"range": "A1:B2", "at": "D2"})).unwrap()["chart"].as_u64().unwrap() as u32;
|
||||
let mut h = harness(session);
|
||||
|
||||
click_cell(&mut h, "C10");
|
||||
click_chart(&mut h, id);
|
||||
assert_eq!(h.state().selected_chart, Some(id));
|
||||
|
||||
key(&mut h, Key::Delete, Modifiers::NONE);
|
||||
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
assert_eq!(h.state().selected_chart, None);
|
||||
assert_eq!(value(&h, "A1"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_every_ribbon_tab_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
|
|
@ -199,9 +253,14 @@ fn language_switch_translates_the_ribbon_and_persists() {
|
|||
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
|
||||
assert!(ids.contains(&id), "engine.commands lists {id}");
|
||||
}
|
||||
for (language, expected) in
|
||||
[(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные"), (Language::PtBr, "Dados")]
|
||||
{
|
||||
for (language, expected) in [
|
||||
(Language::Zh, "数据"),
|
||||
(Language::Ja, "データ"),
|
||||
(Language::Ko, "데이터"),
|
||||
(Language::Ru, "Данные"),
|
||||
(Language::PtBr, "Dados"),
|
||||
(Language::De, "Daten"),
|
||||
] {
|
||||
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
|
||||
h.run_steps(2); // every locale renders the ribbon without panicking
|
||||
assert_eq!(h.state().ui.language, language);
|
||||
|
|
@ -209,10 +268,106 @@ fn language_switch_translates_the_ribbon_and_persists() {
|
|||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, language);
|
||||
}
|
||||
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
|
||||
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.language, Language::Cs);
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() {
|
||||
// #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it
|
||||
// had just opened).
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().open_dialog("nameManager", json!({}));
|
||||
h.run_steps(4);
|
||||
h.get_by_label("New…").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("New Name"));
|
||||
if let Some(d) = h.state_mut().dialog.as_mut() {
|
||||
d.values.insert("name".into(), json!("Total"));
|
||||
d.values.insert("refersTo".into(), json!("=Sheet1!$A$1"));
|
||||
}
|
||||
h.run_steps(1);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
let names = |h: &egui_kittest::Harness<'static, SheetApp>| {
|
||||
h.state().session.active().unwrap().wb.names.iter().map(|n| n.name.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(names(&h), ["Total"]);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"), "OK returns to the Name Manager");
|
||||
|
||||
h.get_by_label("Edit…").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Name"));
|
||||
if let Some(d) = h.state_mut().dialog.as_mut() {
|
||||
d.values.insert("name".into(), json!("GrandTotal"));
|
||||
}
|
||||
h.run_steps(1);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), ["GrandTotal"], "Edit renames instead of adding a second name");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_charts_dialog_inserts_the_chosen_chart() {
|
||||
// #215: Recommended Charts opens a dialog of chart types for the data instead of inserting a
|
||||
// column chart straight away; OK inserts the one picked.
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:B4"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("recommendedCharts", json!({}));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("recommendedCharts"));
|
||||
h.get_by_label("Pie").click();
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::Pie]);
|
||||
assert!(h.state().selected_chart.is_some(), "the new chart is selected");
|
||||
|
||||
// Without numbers there is nothing to recommend: a message, no dialog.
|
||||
h.state_mut().session.run("selection.set", json!({"range": "A1:A4"})).unwrap();
|
||||
h.state_mut().open_dialog("recommendedCharts", json!({}));
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_data_dialog_sets_the_category_axis_labels() {
|
||||
// #287: Chart Design › Select Data edits the Horizontal (Category) Axis Labels and removes the
|
||||
// series the X values were plotted as.
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
|
||||
let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().selected_chart = Some(id as u32);
|
||||
h.state_mut().open_dialog("selectData", json!({"chart": id}));
|
||||
h.run_steps(2);
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
assert_eq!(d.title, "Select Data Source");
|
||||
assert_eq!(d.values["names"], json!(["Year", "Sales"]));
|
||||
d.values.insert("categories".into(), json!("=A2:A4"));
|
||||
d.values.insert("remove".into(), json!([0]));
|
||||
h.run_steps(1);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let chart = h.state().session.active().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
assert_eq!(chart.series.len(), 1);
|
||||
assert_eq!(chart.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
|
||||
assert_eq!(chart.series[0].values, "Sheet1!$B$2:$B$4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_open_and_close() {
|
||||
let mut h = harness(blank());
|
||||
|
|
@ -227,7 +382,10 @@ fn dialogs_open_and_close() {
|
|||
"commandSearch",
|
||||
"spelling",
|
||||
"goTo",
|
||||
"pivotCalculatedField",
|
||||
"about",
|
||||
"analysisToolPak",
|
||||
"solver",
|
||||
] {
|
||||
h.state_mut().open_dialog(name, json!({}));
|
||||
h.run_steps(2);
|
||||
|
|
@ -237,6 +395,70 @@ fn dialogs_open_and_close() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() {
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("analysisToolPak", json!({}));
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some("Descriptive Statistics"));
|
||||
let fill = |h: &mut egui_kittest::Harness<'static, SheetApp>| {
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
d.values.insert("range".into(), json!("A1:A5"));
|
||||
d.values.insert("outputTo".into(), json!("range"));
|
||||
d.values.insert("output".into(), json!("C1"));
|
||||
};
|
||||
fill(&mut h);
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog");
|
||||
assert_eq!(value(&h, "C3"), Value::from("Mean"));
|
||||
assert_eq!(value(&h, "D3"), Value::Number(3.0));
|
||||
// A second report into the same cells asks before replacing them (opened with a tool, the
|
||||
// dialog starts at that tool's form).
|
||||
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
|
||||
fill(&mut h);
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
let d = h.state().dialog.as_ref().expect("the dialog stays open to ask");
|
||||
assert_eq!(d.values.get("askOverwrite"), Some(&json!(true)));
|
||||
assert!(h.state().message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
|
||||
let mut s = blank();
|
||||
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
|
||||
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
let r = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0));
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("solver", json!({}));
|
||||
h.run_steps(2);
|
||||
let d = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(d.values.get("objective"), Some(&json!("B1")));
|
||||
assert_eq!(d.values.get("method"), Some(&json!("simplex")));
|
||||
assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3));
|
||||
h.state_mut().dialog = None;
|
||||
// Solver Results › Restore Original Values undoes the solution.
|
||||
h.state_mut().open_dialog("solverResults", r);
|
||||
h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true));
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(value(&h, "A1"), Value::Number(0.0));
|
||||
assert_eq!(value(&h, "B1"), Value::Number(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn about_renders_every_tab() {
|
||||
let mut h = harness(blank());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
//! Excel keyboard shortcuts (crate::keymap) dispatched through real egui key events.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_engine::model::BorderStyle;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn book(selected: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1.5, 2], [3, 4], [5, 6]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A1*2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": selected})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A key press as a US keyboard reports it (the physical key is the unshifted one).
|
||||
fn key(h: &mut Harness, k: Key, m: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn style(h: &Harness, a1: &str) -> gridcraft_engine::model::Style {
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::parse(a1).unwrap())).clone()
|
||||
}
|
||||
|
||||
/// Ctrl on Windows and Linux sets both `ctrl` and `command`.
|
||||
const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };
|
||||
const CTRL_SHIFT: Modifiers = Modifiers { shift: true, ..CTRL };
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_symbols_apply_number_formats_and_borders() {
|
||||
let mut h = harness(book("A1:B2"));
|
||||
key(&mut h, Key::Num4, CTRL_SHIFT);
|
||||
assert_eq!(style(&h, "B2").num_fmt.as_str(), "$#,##0.00_);($#,##0.00)");
|
||||
key(&mut h, Key::Num5, CTRL_SHIFT);
|
||||
assert_eq!(style(&h, "A1").num_fmt.as_str(), "0%");
|
||||
key(&mut h, Key::Backtick, CTRL_SHIFT);
|
||||
assert_eq!(style(&h, "A1").num_fmt.as_str(), "General");
|
||||
key(&mut h, Key::Num7, CTRL_SHIFT);
|
||||
assert!(style(&h, "A1").borders.top.style != BorderStyle::None && style(&h, "B2").borders.right.style != BorderStyle::None);
|
||||
key(&mut h, Key::Minus, CTRL_SHIFT);
|
||||
assert!(style(&h, "A1").borders.top.style == BorderStyle::None);
|
||||
// Ctrl+Shift+End extends from the anchor to the last used cell.
|
||||
key(&mut h, Key::End, CTRL_SHIFT);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_formula_from_above_and_function_keys() {
|
||||
let mut h = harness(book("C2"));
|
||||
key(&mut h, Key::Quote, CTRL);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.text.clone()).as_deref(), Some("=A1*2"));
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().editor.is_none());
|
||||
let sheets = h.state().session.active().unwrap().wb.sheets.len();
|
||||
key(&mut h, Key::F11, Modifiers::SHIFT);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.sheets.len(), sheets + 1);
|
||||
// Back on the data: Alt+F1 charts the current region, Ctrl+Alt+= zooms in.
|
||||
h.state_mut().session.run("sheet.previous", json!({})).unwrap();
|
||||
h.state_mut().session.run("selection.set", json!({"range": "A1:B3"})).unwrap();
|
||||
key(&mut h, Key::F1, Modifiers::ALT);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].charts.len(), 1);
|
||||
key(&mut h, Key::Equals, Modifiers { alt: true, ..CTRL });
|
||||
assert_eq!(h.state().session.active().unwrap().wb.sheets[0].zoom, 110);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_and_mac_variants() {
|
||||
let cmd = CTRL;
|
||||
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
let ctrl = Modifiers::CTRL;
|
||||
let run = |id: &'static str| Some(Shortcut::Run(id, json!({})));
|
||||
assert_eq!(lookup(Key::L, Some(Key::L), cmd, false), run("insert.table"));
|
||||
assert_eq!(lookup(Key::L, Some(Key::L), mac_cmd, true), run("home.alignLeft"));
|
||||
assert_eq!(lookup(Key::E, Some(Key::E), mac_cmd, true), run("home.alignCenter"));
|
||||
assert_eq!(lookup(Key::E, Some(Key::E), ctrl, true), run("edit.flashFill"));
|
||||
assert_eq!(lookup(Key::Equals, Some(Key::Equals), Modifiers::ALT, false), run("formulas.autoSum"));
|
||||
// The shifted character doesn't matter: Ctrl+Shift+1 is the Number format wherever `!` is.
|
||||
assert_eq!(
|
||||
lookup(Key::Exclamationmark, Some(Key::Num1), CTRL_SHIFT, false),
|
||||
Some(Shortcut::Run("home.numberFormat", json!({"code": "#,##0.00"})))
|
||||
);
|
||||
// Plain keys stay with the grid (typing, navigation).
|
||||
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
|
||||
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
//! Protect Sheet / Unprotect Sheet mask the password as it's typed (issue #347).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
const SECRET: &str = "s3cret";
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) {
|
||||
h.get_by_role(Role::PasswordInput).click();
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text(SECRET.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn secret_on_screen(h: &egui_kittest::Harness<'static, SheetApp>) -> bool {
|
||||
h.query_all_by(|n| n.value().is_some_and(|v| v.contains(SECRET)) || n.label().is_some_and(|l| l.contains(SECRET))).next().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_and_unprotect_sheet_mask_the_password() {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_dialog("protectSheet", json!({}));
|
||||
h.run_steps(3);
|
||||
type_password(&mut h);
|
||||
assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(SECRET)));
|
||||
assert!(!secret_on_screen(&h), "the typed password must not be readable");
|
||||
assert!(h.get_by_role(Role::PasswordInput).value().is_some_and(|v| !v.is_empty() && !v.contains(SECRET)));
|
||||
|
||||
// Show password reveals it on purpose; unticking masks it again.
|
||||
h.get_by_label("Show password").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_role(Role::PasswordInput).is_none());
|
||||
assert!(secret_on_screen(&h));
|
||||
h.get_by_label("Show password").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_role(Role::PasswordInput).is_some());
|
||||
assert!(!secret_on_screen(&h));
|
||||
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.as_ref().is_some_and(|p| p.password_hash.is_some()));
|
||||
|
||||
// Unprotect asks for the password with a masked field too, and it starts hidden again.
|
||||
h.state_mut().open_dialog("unprotectSheet", json!({}));
|
||||
h.run_steps(3);
|
||||
type_password(&mut h);
|
||||
assert!(!secret_on_screen(&h), "the typed password must not be readable");
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none());
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//! Sheet Right-to-Left: the grid is mirrored on screen and pointer/keyboard input follows it.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["first", 2], [3, "fourth"]]})).unwrap();
|
||||
session.execute("view.rightToLeft", json!({"on": true})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn geo(h: &Harness) -> Geo {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
|
||||
}
|
||||
|
||||
/// Where `a1` is drawn on screen.
|
||||
fn point(h: &Harness, a1: &str) -> Pos2 {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
geo(h).screen_cell_rect(sheet, CellRef::parse(a1).unwrap()).center()
|
||||
}
|
||||
|
||||
fn frame(h: &mut Harness, event: Event) {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn current(h: &Harness) -> RangeRef {
|
||||
h.state().session.active().unwrap().selection.current()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_a_is_drawn_on_the_right_and_clicks_hit_the_mirrored_cells() {
|
||||
let mut h = harness();
|
||||
let g = geo(&h);
|
||||
assert!(g.rtl);
|
||||
let (a1, b1) = (point(&h, "A1"), point(&h, "B1"));
|
||||
assert!(a1.x > b1.x, "A1 at {a1:?} should be right of B1 at {b1:?}");
|
||||
// Row headers sit on the right edge, so A1 ends where they begin.
|
||||
let sheet_a1 = {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
g.screen_cell_rect(sheet, CellRef::parse("A1").unwrap())
|
||||
};
|
||||
assert!((sheet_a1.right() - (g.rect.right() - g.header_w)).abs() < 0.5);
|
||||
|
||||
// Drag from C3 to F7 at their on-screen positions.
|
||||
let (from, to) = (point(&h, "C3"), point(&h, "F7"));
|
||||
frame(&mut h, Event::PointerMoved(from));
|
||||
frame(&mut h, button(from, true));
|
||||
frame(&mut h, Event::PointerMoved(to));
|
||||
frame(&mut h, button(to, false));
|
||||
h.run_steps(1);
|
||||
assert_eq!(current(&h), RangeRef::parse("C3:F7").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_arrows_follow_the_screen() {
|
||||
let mut h = harness();
|
||||
h.key_press(Key::ArrowLeft);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("C2").unwrap());
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("A2").unwrap());
|
||||
}
|
||||
|
|
@ -162,3 +162,33 @@ fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() {
|
|||
system_theme(&mut h, Some(Theme::Dark));
|
||||
assert_appearance(&h, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_switch_and_file_menu_choose_the_theme() {
|
||||
// #194: one click in the title bar switches light/dark; File › Display theme offers all three.
|
||||
let mut h = harness(UiState::default());
|
||||
system_theme(&mut h, Some(Theme::Light));
|
||||
assert_appearance(&h, false);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(preferences(&h)["dark"], true);
|
||||
assert_eq!(preferences(&h)["systemTheme"], false);
|
||||
assert_appearance(&h, true);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click();
|
||||
h.run_steps(2);
|
||||
assert_appearance(&h, false);
|
||||
|
||||
// File opens Backstage; its Options page holds the Office Theme choice (#331).
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click();
|
||||
h.run_steps(3);
|
||||
// The first combo box on the page is Display theme.
|
||||
h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("theme combo").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("System").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(preferences(&h)["systemTheme"], true);
|
||||
system_theme(&mut h, Some(Theme::Dark));
|
||||
assert_appearance(&h, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Fo
|
|||
let Some(expr) = formula.expr_arc() else { return false };
|
||||
let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() };
|
||||
let shape = scan.expr(&expr, sheet, false, 0);
|
||||
shape.array || shape.affected
|
||||
// An explicit `@` is written as `_xlfn.SINGLE`, which only dynamic-array formulas carry.
|
||||
let mut at = false;
|
||||
expr.walk(&mut |e| at |= matches!(e, Expr::Unary(UnOp::At, _)));
|
||||
shape.array || shape.affected || at
|
||||
}
|
||||
|
||||
struct Scan<'a> {
|
||||
|
|
@ -215,6 +218,9 @@ fn scalar_arg(name: &str, i: usize) -> bool {
|
|||
"FILTER" | "SORTBY" => i > 1,
|
||||
"SORT" | "UNIQUE" | "TAKE" | "DROP" | "CHOOSECOLS" | "CHOOSEROWS" | "TOROW" | "TOCOL" | "WRAPROWS" | "WRAPCOLS" | "EXPAND" => i != 0,
|
||||
"LINEST" | "LOGEST" | "TREND" | "GROWTH" => i > 1,
|
||||
"FORECAST" | "FORECAST.LINEAR" => i == 0,
|
||||
"FORECAST.ETS" | "FORECAST.ETS.CONFINT" => i != 1 && i != 2,
|
||||
"FORECAST.ETS.SEASONALITY" | "FORECAST.ETS.STAT" => i > 1,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
|
|||
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +466,10 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
|
|||
if (1..=48).contains(&ch.style) {
|
||||
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
|
||||
}
|
||||
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
|
||||
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
|
||||
}
|
||||
s.push_str("<c:chart>");
|
||||
match &ch.title {
|
||||
Some(t) if !t.is_empty() => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue