mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 14:17:12 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f896c1249 | ||
|
|
90b3c41eec | ||
|
|
c6392e6380 | ||
|
|
502c588690 | ||
|
|
88d0c772fb | ||
|
|
f23281db8c | ||
|
|
49fbae245a | ||
|
|
de0c715662 | ||
|
|
b07249de75 | ||
|
|
c15a51c959 | ||
|
|
dd185ff12f | ||
|
|
c3cfc92186 | ||
|
|
9fea6235f7 | ||
|
|
84a414a705 | ||
|
|
1e5edbd43c | ||
|
|
5c456df4b7 | ||
|
|
92fb7cd1b3 | ||
|
|
5545e72944 | ||
|
|
960c79a328 | ||
|
|
645782f5e3 | ||
|
|
87d64f20e1 | ||
|
|
d031c8f37e | ||
|
|
cda29c5b73 | ||
|
|
c21123e375 | ||
|
|
75ede09917 | ||
|
|
14402fc5a3 | ||
|
|
e9fb005eed | ||
|
|
5578c09e55 | ||
|
|
00df1908a7 | ||
|
|
c4e4aeb293 | ||
|
|
20aa531d8c | ||
|
|
11ef0e6b04 | ||
|
|
dfd3675324 | ||
|
|
f75ded37e7 | ||
|
|
59c8e36d36 | ||
|
|
5b15827ede | ||
|
|
3e40014eb0 | ||
|
|
82f42713a8 | ||
|
|
60aa2dfe07 | ||
|
|
ada33d1ad5 | ||
|
|
e72e16a0ec | ||
|
|
580b14eeb9 | ||
|
|
837bb00f2b | ||
|
|
4f10e48128 | ||
|
|
f2ed982fdd | ||
|
|
eb4174723d | ||
|
|
bd1be73f2d | ||
|
|
13f8f19248 | ||
|
|
76f9e2625f | ||
|
|
d51d4e51b9 | ||
|
|
6e935a3c8c | ||
|
|
2cc7dde6c3 | ||
|
|
1d33ea81be | ||
|
|
0f21244e9e | ||
|
|
dcda5a52ce | ||
|
|
47210cde01 | ||
|
|
fe162f8f23 | ||
|
|
c69446aff0 | ||
|
|
2faf8ffc78 | ||
|
|
9400dbea2b | ||
|
|
b19ffabdaf | ||
|
|
82d3c5422f | ||
|
|
19094e2d62 | ||
|
|
e20a7b41af | ||
|
|
cb794080b9 | ||
|
|
7f2146476e | ||
|
|
ca4011dc5e | ||
|
|
b68ae0fe53 | ||
|
|
09b1ac8d16 | ||
|
|
f54c0ca2a6 | ||
|
|
e8ec40da8b | ||
|
|
d7c77e94cd | ||
|
|
9dc3aaf9fa | ||
|
|
fa9c6798ab | ||
|
|
b5fe936d83 | ||
|
|
c450b545a6 | ||
|
|
d2cc499c1d | ||
|
|
811cb116fd | ||
|
|
36723ec310 | ||
|
|
f041904bf8 | ||
|
|
277f01265b | ||
|
|
c5bf34d5d7 | ||
|
|
d61a21c922 | ||
|
|
784c091ae9 | ||
|
|
efcfd08822 | ||
|
|
cee21c64ec | ||
|
|
ce1e2d1cee | ||
|
|
c67931cff6 | ||
|
|
20e39fa3cd | ||
|
|
c4fd49afc2 | ||
|
|
9365c5a9c6 | ||
|
|
c975b5dc75 | ||
|
|
26b3db78db | ||
|
|
42854f2fb9 | ||
|
|
4955382a07 | ||
|
|
86328ec843 | ||
|
|
435a750bba | ||
|
|
ff77dea87f | ||
|
|
699b95288a | ||
|
|
d3b8929fde | ||
|
|
deecb18f5d | ||
|
|
ce2cade393 | ||
|
|
11472b340b | ||
|
|
2f9fce88b0 | ||
|
|
7712eb1b0e | ||
|
|
c5a8cf28fe | ||
|
|
c607fe1627 | ||
|
|
cecbd6cfb3 | ||
|
|
ea9d4c6b32 | ||
|
|
11a921becb | ||
|
|
15e0f0b6be | ||
|
|
1ded850ee6 | ||
|
|
bd0ccd41b5 | ||
|
|
2ad184c2d6 | ||
|
|
886fb0098b | ||
|
|
2cb5405b20 | ||
|
|
34ed83d13f | ||
|
|
0feb404c68 | ||
|
|
0796c50816 | ||
|
|
674d422dc0 | ||
|
|
e165db1ffd | ||
|
|
f8b98775b8 | ||
|
|
38369a7566 | ||
|
|
7f6739e665 | ||
|
|
f736286f24 | ||
|
|
0aaaf9aeab | ||
|
|
7f9d77a12a | ||
|
|
882a19568e | ||
|
|
453882c6fe | ||
|
|
62d84cbbf5 | ||
|
|
54df7e1fc5 | ||
|
|
c30a720998 | ||
|
|
986b6c6ea6 | ||
|
|
1cdee3ee02 | ||
|
|
a678c5d25f | ||
|
|
e92a67bbba | ||
|
|
1b20cb873d | ||
|
|
3509bcfd87 | ||
|
|
ded67f51d2 | ||
|
|
61b264ac21 | ||
|
|
f16fed557e | ||
|
|
e5d34fd63a | ||
|
|
8011191824 | ||
|
|
985257b166 | ||
|
|
8846dadc52 | ||
|
|
c1b1eb6757 | ||
|
|
6f52f1fd3d | ||
|
|
14b59a71f5 | ||
|
|
1c6cb3bce9 | ||
|
|
97f7dca886 | ||
|
|
be8e379ca0 | ||
|
|
303eabaad1 | ||
|
|
880163d135 | ||
|
|
e51c1d98fb | ||
|
|
dcc5434bc8 | ||
|
|
fc2f00bd50 | ||
|
|
48910d71d2 | ||
|
|
5ad3c6eed3 | ||
|
|
60b60619b2 | ||
|
|
e5af1fcbff | ||
|
|
62b17379e3 | ||
|
|
1b0b2009aa | ||
|
|
a059c8e196 | ||
|
|
7fece30d49 | ||
|
|
684567cc9a | ||
|
|
439df2ae3a | ||
|
|
e893621d76 | ||
|
|
c650c2828f | ||
|
|
06cb377c54 | ||
|
|
bf1381253f | ||
|
|
fddd08bec3 | ||
|
|
925bf22e24 | ||
|
|
9c29ef3fff | ||
|
|
d4d38fd224 | ||
|
|
b28b4b729f | ||
|
|
8029433b1c | ||
|
|
68fac6d861 | ||
|
|
e294345599 | ||
|
|
2a3d1ffd7f | ||
|
|
b53a190ea1 | ||
|
|
198e8ecb05 | ||
|
|
b2386e1c23 | ||
|
|
12bfdb0798 | ||
|
|
c51e51fe7b | ||
|
|
da0bf32d8a | ||
|
|
daf4591da0 | ||
|
|
922847a1ed | ||
|
|
91d44508b1 | ||
|
|
698a2a8009 | ||
|
|
13664fe66b | ||
|
|
2b1da77e46 | ||
|
|
311b1a593f | ||
|
|
cd083cfc50 | ||
|
|
628732ef58 | ||
|
|
f424d7d56b | ||
|
|
4d46b9170b | ||
|
|
165e6c8a3b | ||
|
|
96b3ab3244 | ||
|
|
d7174f8297 | ||
|
|
b8b3ba0b0e | ||
|
|
631a49ec30 | ||
|
|
8fe3276cb8 | ||
|
|
69709bd458 | ||
|
|
ecaead4c12 | ||
|
|
2c2d92ef6b | ||
|
|
903bbb096d | ||
|
|
ad6e77f6ef | ||
|
|
1d2df6126b | ||
|
|
38c11f6fb9 | ||
|
|
b36f919410 | ||
|
|
56c32750a2 | ||
|
|
51b531be3e | ||
|
|
6beb80ed36 | ||
|
|
51f3279135 | ||
|
|
28113c6c75 | ||
|
|
4dd49f7b95 | ||
|
|
e8e010b446 | ||
|
|
7e24aa398a | ||
|
|
be33eba7d1 | ||
|
|
891514892a | ||
|
|
881bab8b6a | ||
|
|
2276eabba8 | ||
|
|
cbc2430f4f | ||
|
|
26f325ec15 | ||
|
|
1c8d0b8fe5 |
@@ -0,0 +1,176 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Flatpak repository
|
||||
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
|
||||
# here, and the validation step below refuses a manual tag with a pre-release suffix.
|
||||
if: github.event_name != 'release' || !github.event.release.prerelease
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
|
||||
pages: read
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
APP_ID: ai.storyteller.gridcraft
|
||||
steps:
|
||||
- name: Configure Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
|
||||
- name: Download and validate release bundles
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="$RELEASE_TAG"
|
||||
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
|
||||
exit 1
|
||||
fi
|
||||
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
|
||||
case "${tag%%+*}" in
|
||||
*-*)
|
||||
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
|
||||
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
|
||||
if [[ "$published_tag" != "$tag" ]]; then
|
||||
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
mkdir -p bundles
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern SHA256SUMS.txt --dir bundles
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="gridcraft-$version-linux-$arch.flatpak"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset" --dir bundles
|
||||
test -s "bundles/$asset"
|
||||
(
|
||||
cd bundles
|
||||
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
|
||||
SHA256SUMS.txt | sha256sum --check -
|
||||
)
|
||||
done
|
||||
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
validation_repo="$(mktemp -d)"
|
||||
ostree --repo="$validation_repo" init --mode=archive-z2
|
||||
flatpak build-import-bundle "$validation_repo" "$asset"
|
||||
expected_ref="app/$APP_ID/$arch/stable"
|
||||
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
|
||||
echo "::error::$(basename "$asset") does not contain $expected_ref"
|
||||
exit 1
|
||||
}
|
||||
rm -rf "$validation_repo"
|
||||
done
|
||||
|
||||
- name: Build repository and site
|
||||
shell: bash
|
||||
env:
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
site="$RUNNER_TEMP/site"
|
||||
repo_dir="$site/flatpak/repo"
|
||||
mkdir -p "$repo_dir"
|
||||
ostree --repo="$repo_dir" init --mode=archive-z2
|
||||
|
||||
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
|
||||
if curl --fail --silent --show-error --location \
|
||||
--output /dev/null "$previous_url/summary"; then
|
||||
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
|
||||
ostree --repo="$repo_dir" pull --mirror previous
|
||||
ostree --repo="$repo_dir" remote delete previous
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
for arch in x86_64 aarch64; do
|
||||
flatpak build-import-bundle "$repo_dir" \
|
||||
"bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
done
|
||||
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
|
||||
# (re-mirrored and re-deployed on every release) does not grow without bound.
|
||||
flatpak build-update-repo \
|
||||
--prune --prune-depth=3 \
|
||||
--title="GridCraft" \
|
||||
--comment='Official GridCraft releases' \
|
||||
--description='Official Flatpak repository for GridCraft' \
|
||||
--homepage="https://github.com/$GITHUB_REPOSITORY" \
|
||||
--default-branch=stable \
|
||||
"$repo_dir"
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
|
||||
done
|
||||
test -s "$repo_dir/summary"
|
||||
|
||||
base_url="${PAGES_BASE_URL%/}"
|
||||
repo_url="$base_url/flatpak/repo/"
|
||||
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
|
||||
[Flatpak Repo]
|
||||
Title=GridCraft
|
||||
Comment=Official GridCraft releases
|
||||
Description=Official Flatpak repository for GridCraft
|
||||
Url=$repo_url
|
||||
Homepage=https://github.com/$GITHUB_REPOSITORY
|
||||
META
|
||||
cat >"$site/flatpak/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
|
||||
flatpak install gridcraft $APP_ID</pre></main></body></html>
|
||||
HTML
|
||||
cat >"$site/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
|
||||
HTML
|
||||
touch "$site/.nojekyll"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/site
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
name: Deploy Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
@@ -51,16 +51,15 @@ jobs:
|
||||
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
|
||||
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Build in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -88,7 +87,7 @@ jobs:
|
||||
cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
|
||||
fi
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: inputs.package
|
||||
with:
|
||||
name: release-freebsd
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Quick release-pipeline checks without a Rust build: workflow and packaging-script syntax,
|
||||
# Linux desktop metadata, and the Windows desktop-shortcut installer lifecycle.
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -64,3 +64,33 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -73,18 +73,17 @@ jobs:
|
||||
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
@@ -104,7 +103,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -132,18 +131,17 @@ jobs:
|
||||
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -167,7 +165,7 @@ jobs:
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -191,10 +189,9 @@ jobs:
|
||||
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -208,8 +205,8 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -233,7 +230,7 @@ jobs:
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -256,8 +253,8 @@ jobs:
|
||||
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -271,7 +268,7 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -287,18 +284,17 @@ jobs:
|
||||
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
@@ -314,12 +310,81 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
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).
|
||||
@@ -334,7 +399,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, web, freebsd]
|
||||
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'
|
||||
@@ -348,7 +413,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: gridcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: gridcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -43,14 +43,14 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
|
||||
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
| `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg |
|
||||
| `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render |
|
||||
| `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon |
|
||||
|
||||
@@ -43,11 +43,11 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window): `cargo run --release -p gridcraft-cli -- snapshot --sample sales out.png`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. macOS has no `timeout`; use `perl -e 'alarm 60; exec @ARGV' cmd`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` regenerates `docs/parity.md` from `crates/engine/src/catalog.rs`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
|
||||
Generated
+125
-19
@@ -215,6 +215,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1121,6 +1122,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -1132,6 +1139,23 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1289,7 +1313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1392,10 +1416,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"gridcraft-engine",
|
||||
"gridcraft-ui-egui",
|
||||
"image",
|
||||
@@ -1408,7 +1434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1420,7 +1446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1431,7 +1457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1441,7 +1467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1450,7 +1476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1466,11 +1492,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1479,7 +1506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1487,7 +1514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1496,7 +1523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1507,18 +1534,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
@@ -1528,12 +1555,14 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1550,7 +1579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1593,6 +1622,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1601,7 +1639,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1610,7 +1648,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2038,6 +2076,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2426,6 +2473,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -2474,6 +2531,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3187,6 +3255,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3355,6 +3432,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -4222,6 +4310,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -4287,7 +4393,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -43,6 +43,8 @@ eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
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"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
||||
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
||||
PivotTables and print settings round-trip. CSV and TSV too.
|
||||
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
|
||||
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
|
||||
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
|
||||
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
||||
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
||||
structured table references, Excel's full number-format language, sort and AutoFilter,
|
||||
@@ -105,6 +108,9 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
|
||||
```
|
||||
|
||||
Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md).
|
||||
Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax.
|
||||
|
||||
### Web
|
||||
|
||||
```sh
|
||||
@@ -115,6 +121,42 @@ cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sa
|
||||
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
browser's file picker (dropping files works too) and Save downloads the workbook.
|
||||
|
||||
### Ribbon access keys
|
||||
|
||||
Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
|
||||
then type a sequence below one key at a time. Keytips appear on the supported Home
|
||||
controls and menu items. On macOS, use F10 (Fn+F10 if required by your keyboard); Option
|
||||
remains available for entering accented characters.
|
||||
|
||||
| Sequence after Alt/F10 | Action |
|
||||
|---|---|
|
||||
| `H A L` / `H A C` / `H A R` | Align left / center / right |
|
||||
| `H V S` or legacy `E S` | Open Paste Special |
|
||||
| `H O I` or legacy `O C A` | AutoFit selected columns |
|
||||
|
||||
In Paste Special, **T/V/F/C** select formats/values/formulas/comments, and **E** toggles
|
||||
Transpose. **Enter** applies the selected options; **Escape** cancels the dialog.
|
||||
These letters also work with Alt held on Windows/Linux/BSD.
|
||||
|
||||
Escape backs out one keytip level; Alt/F10 again exits keytips. Clicking, scrolling,
|
||||
or switching windows cancels navigation. Access keys do not start while editing text
|
||||
or using another dialog. This is initial support for these Home and legacy paths;
|
||||
other ribbon commands and full mouse-free navigation remain to be implemented.
|
||||
Browsers or desktop environments may reserve Alt/F10 for their own menus.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its log to standard error and to `gridcraft.log` in the `logs` folder
|
||||
next to its settings: `~/.config/gridcraft/logs` on Linux and BSD (`$XDG_CONFIG_HOME/gridcraft/logs`
|
||||
when set), `~/Library/Application Support/GridCraft/logs` on macOS and
|
||||
`%APPDATA%\GridCraft\logs` on Windows. Each start moves the previous log to `gridcraft.1.log` and
|
||||
that one to `gridcraft.2.log`, so the log of a run that crashed survives the next start; attach
|
||||
them to a bug report. A log file stops growing at 16 MiB. By default GridCraft's own crates log
|
||||
at `info` and everything else at `warn`; `RUST_LOG` replaces that with env_logger-style
|
||||
directives, e.g. `RUST_LOG=debug` or `RUST_LOG=warn,gridcraft_engine=debug` (a trailing `*`
|
||||
matches a prefix: `gridcraft*=debug`). Runs with `GRIDCRAFT_NO_PREFS` set (no saved settings)
|
||||
log to standard error only.
|
||||
|
||||
## For agents: MCP, CLI and the control channel
|
||||
|
||||
```sh
|
||||
@@ -141,7 +183,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||
|---|---|
|
||||
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
|
||||
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
|
||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) |
|
||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
|
||||
| L3 | `chart` (toolkit-free chart layout and rendering) |
|
||||
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
|
||||
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
|
||||
@@ -153,12 +195,13 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||
|
||||
## Roadmap
|
||||
|
||||
GridCraft is pre-alpha and moving fast. It covers 89% of Excel's ribbon and menu commands
|
||||
([`docs/parity.md`](docs/parity.md)) and about 93% of its worksheet functions; weighted by depth,
|
||||
we estimate about 65% of what Excel power users rely on. We're roughly 80% of the way to a first
|
||||
alpha. Next up: signed release builds, XLSX fidelity on real-world files, performance on very large
|
||||
sheets, then slicers, Solver, chart trendlines and a true Page Layout view. The plan and estimates
|
||||
are in [ROADMAP.md](ROADMAP.md).
|
||||
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/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,
|
||||
numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -172,7 +215,23 @@ are in [ROADMAP.md](ROADMAP.md).
|
||||
| arm64 (Snapdragon and other ARM PCs) | `gridcraft-<ver>-windows-arm64.msi` | `gridcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `gridcraft-<ver>-windows-x86.msi` | `gridcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
Installers and executables are code-signed. The MSI installer offers an optional desktop shortcut
|
||||
(unchecked on a new installation).
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\gridcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+137
-124
@@ -1,136 +1,149 @@
|
||||
# GridCraft roadmap
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity — and to be better: faster, open (XLSX is the
|
||||
native format), fully scriptable by agents (MCP + control channel + CLI), and available on the
|
||||
web.
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
## Status (2026-10-07)
|
||||
> **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)
|
||||
|
||||
### Where we are
|
||||
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.
|
||||
This page is the summary. The detail is in:
|
||||
|
||||
| Measure | Value | Notes |
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative assessment, method and weights
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list
|
||||
- [`docs/roadmap.md`](docs/roadmap.md): milestones, current focus, what's next
|
||||
- [`docs/parity-checklist.md`](docs/parity-checklist.md): generated by `cargo xtask parity` (ribbon commands)
|
||||
- Area checklists: [functions](docs/function-parity.md) · [charts](docs/chart-parity.md) ·
|
||||
[file formats](docs/file-format-parity.md) · [UI](docs/ui-parity.md) ·
|
||||
[hardware](docs/hardware-parity.md) · [localization](docs/localization-parity.md)
|
||||
- [`docs/architecture.md`](docs/architecture.md): how it is built
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu command catalog | **89%** (258 / 290) | `cargo xtask parity` → [`docs/parity.md`](docs/parity.md). Home, Draw, Page Layout, Formulas, Automate, Chart Design and PivotTable Design are at 100%. |
|
||||
| Worksheet functions | **~93%** of Excel's ~545 | Missing: ODDF*/ODDL*, BAHTTEXT, IMAGE, TRANSLATE, COPILOT/PY, STOCKHISTORY, RTD |
|
||||
| Feature depth (weighted) | **~65%** | The catalog counts a command once it exists; many are shallower than Excel's (see "What's left") |
|
||||
| Tests | 332 passing | engine, formula, functions, calc, XLSX (incl. PivotTable round-trip), PDF, MCP, CLI, kittest UI |
|
||||
| **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%) |
|
||||
| **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) |
|
||||
| Remaining to **beta** | **~160–240 Opus 5.5 agent-hours** | estimated; itemized in [roadmap.md](docs/roadmap.md#beta-gates) |
|
||||
| Remaining to **full parity** | **~700–1,100 Opus 5.5 agent-hours** | estimated; sum of the dimension rows below |
|
||||
|
||||
The catalog number is generous: it says "this button does something real", not "it does
|
||||
everything Excel's does". The weighted depth figure is our honest estimate of how much of what an
|
||||
Excel power user relies on works as well as in Excel.
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
|
||||
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
|
||||
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
|
||||
|
||||
### How close to an alpha?
|
||||
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
|
||||
calibration](docs/target-app-parity.md#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
|
||||
work; single features measured from PRs #66, #100, #101). Parallelism: the full and mainstream
|
||||
work splits ~65–70% across 4–6 agents by area; the essentials work is mostly small fixes plus one
|
||||
long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with three agents).
|
||||
|
||||
**About 80% of the way to an alpha. Estimate: ~35 wall-clock hours of Claude Opus 5.5 agent
|
||||
work** (2–3 days with 2–3 parallel agents), plus a few minutes of human time to set up release
|
||||
secrets.
|
||||
Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort
|
||||
and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real
|
||||
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text
|
||||
isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and
|
||||
nobody has yet confirmed that Excel opens our output without a repair prompt across a real
|
||||
corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)).
|
||||
|
||||
Alpha means: someone can download a signed build, open their real workbooks, edit and save them
|
||||
without losing data, and not hit a crash in a normal session. The gates:
|
||||
The 2026-10-07 numbers (catalog 89%, "feature depth ~65%", "~215 h to full parity") were
|
||||
re-measured. Catalog and function counts held or rose; the depth and hours moved because of new
|
||||
evidence: 82 issues filed by users and contributors in the repo's first five days, and scope the
|
||||
old estimate left out (localization, legacy `.xls`, Power Query and the data model,
|
||||
collaboration, mobile, AI).
|
||||
|
||||
| Alpha gate | State | Est. hours |
|
||||
## By dimension
|
||||
|
||||
| Dimension | Ready for real work | Hours to parity | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (13 areas, table below) | ~59% (breadth ~75%) | 300–485 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| UI/UX fidelity | ~55% | 50–80 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| AI features | ~15% (agent control strong; no in-app assistant) | 30–50 | [target-app-parity.md](docs/target-app-parity.md#ecosystem-and-ai) |
|
||||
| **Total** | **~50%** | **~700–1,100** | |
|
||||
|
||||
## Features
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
| PivotTables | ~65% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Charts and sparklines | ~50% | ~30% | 40–60 | [chart-parity.md](docs/chart-parity.md) |
|
||||
| Page layout, print, PDF | ~80% | ~55% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| What-if and analysis (Goal Seek, Solver, ToolPak, Forecast) | ~50% | ~40% | 22–35 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
|
||||
## 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
|
||||
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).
|
||||
|
||||
| Language | Code | Status | UI strings |
|
||||
|---|---|---|---|
|
||||
| English | en | full (source language) | 100% |
|
||||
| Simplified Chinese | zh-Hans | menus only | ~12% |
|
||||
| Spanish | es | none | 0% |
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus only | ~12% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | menus only | ~12% |
|
||||
| German | de | none (PR #163 open) | 0% |
|
||||
| Korean | ko | menus only | ~12% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Other shipped languages: 1 (Russian, menus only, ~12%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **Land the open PR queue** (50 open PRs on 2026-10-10, many fixing listed gaps: recalc and spill
|
||||
correctness, chart kind round-trip, CJK font fallback, macOS Finder open, German and Chinese
|
||||
UI). Review and merge: 8–15 h.
|
||||
2. **XLSX fidelity on a real-world corpus**, with Excel opening our output without repair: 25–35 h.
|
||||
3. **Large workbooks and recalc speed**: 100 MB XLSX, 1M rows, multi-threaded recalc: 40–60 h.
|
||||
4. **Charts depth**: kinds and options round-trip, axes, trendlines, error bars: 25–35 h.
|
||||
5. **Text in every script** (CJK, Thai, Arabic, Devanagari) in cells and PDF: 12–20 h.
|
||||
6. **Legacy `.xls` import and password-protected workbooks**: 20–30 h.
|
||||
7. **Localization**: extend the catalog past the ribbon, then the twelve languages: 100–165 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
|
||||
| 2026-10-06 | M0–M13 first pass: engine, 478 functions, XLSX/CSV, egui UI, charts, PivotTables with XLSX round-trip, PDF and printing, what-if, spelling, Draw, task panes, MCP, CLI, web build, release packaging |
|
||||
| 2026-10-05 | Repository created |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| Signed release builds on every platform | Workflows and packaging ready; **blocked on the `release` environment and secrets, which need a human** (see [`docs/releasing.md`](docs/releasing.md)). Then a test build per platform | 4 |
|
||||
| XLSX fidelity on real files | Round-trip tests pass for our own files. Needs a corpus run on real-world workbooks, and confirming Excel opens our output (including PivotTables) with no repair prompt | 10 |
|
||||
| Performance | Virtualised grid and O(log n) geometry are in. Still need: 100k-cell edit recalc under 100 ms (currently ~0.4 s), faster write-back, 1M-row open | 8 |
|
||||
| Never-crash soak | Every-command fuzz test exists. Needs a longer randomised UI soak and an autosave/recovery check | 4 |
|
||||
| Papercuts | Dark-mode row/column headers, a true Page Layout (paper) view, focus edge cases, error messages | 6 |
|
||||
| Docs and first-run | README, CLI/MCP docs exist; needs a "getting started" page and release notes | 3 |
|
||||
| **Total** | | **≈ 35** |
|
||||
|
||||
### How far to 100% parity?
|
||||
|
||||
**Estimate: ~180 wall-clock hours of Claude Opus 5.5 agent work beyond the alpha** (about 1–1.5
|
||||
weeks running continuously with 3–4 parallel agents), so **≈ 215 hours in total from today**.
|
||||
Breakdown under "What's left" below.
|
||||
|
||||
Scope decisions that affect the count: VBA is out of scope (GridCraft has its own recorded and
|
||||
editable scripts instead), and features that need Microsoft cloud services (Copilot, live Stocks/
|
||||
Geography data types, co-authoring through OneDrive, Smart Lookup, Translate) will be replaced
|
||||
by open equivalents or left out, not cloned.
|
||||
|
||||
### What's left
|
||||
|
||||
| Area | What's missing | Est. hours |
|
||||
|---|---|---|
|
||||
| PivotTables | Slicers and timelines, calculated fields/items, PivotCharts, value/label filters, number grouping | 24 |
|
||||
| Charts | Trendlines, error bars, full axis options (log scale, units, crossing), element-level Format pane, chart sheets, Recommended Charts dialog, maps | 26 |
|
||||
| Data | Solver, Analysis ToolPak, Forecast Sheet, Get & Transform beyond CSV/JSON/HTML (Power Query editor lite), relationships / data model | 34 |
|
||||
| Page layout & print | True Page Layout view, header/footer editor on the page, print preview polish | 12 |
|
||||
| Insert & Review | Equations, WordArt, SmartArt-lite, signature line, comment navigation, Show Changes, Allow Edit Ranges, notes ↔ comments conversion | 20 |
|
||||
| XLSX depth | Remaining OOXML parts (form controls, slicer caches, external links, OLE stubs), round-trip of everything we don't model | 16 |
|
||||
| Functions | The remaining ~35 functions, exact Excel edge-case behaviour across the long tail | 10 |
|
||||
| Performance | Parallel recalc, 1M-row workbooks everywhere (sort, filter, fill), memory | 14 |
|
||||
| Fidelity | Side-by-side pixel tuning against Excel, keyboard shortcut completeness, accessibility, localisation | 24 |
|
||||
| **Total** | | **≈ 180** |
|
||||
|
||||
**Working today**
|
||||
- Engine: A1/R1C1 formula parser with reference adjustment, dependency-graph recalculation with
|
||||
dynamic arrays (spill + `#SPILL!`), cycle detection, LET/LAMBDA/MAP/REDUCE/SCAN/BYROW/BYCOL/
|
||||
MAKEARRAY, structured table references, defined names, INDIRECT/OFFSET/INDEX references,
|
||||
478 worksheet functions (math, statistics and distributions, text and regex, lookup incl.
|
||||
XLOOKUP/FILTER/SORT/UNIQUE/VSTACK, dates, financial incl. bonds, engineering incl. complex
|
||||
numbers and Bessel, database, information).
|
||||
- Number formats: Excel's full format-code language (sections, conditions, colours, dates and
|
||||
elapsed time, fractions, scientific, accounting fill), General narrowing to column width.
|
||||
- Files: XLSX read/write (styles, themes, merges, CF, validation, tables, comments, hyperlinks,
|
||||
charts, pictures, sparklines, print settings, defined names, shared/array/dynamic formulas),
|
||||
CSV/TSV with encoding and delimiter detection, HTML export, JSON.
|
||||
- Editing: cell entry with type detection and auto-formats, in-cell and formula-bar editing,
|
||||
point mode (click/drag/arrow references), reference colouring, F4 anchor cycling, function
|
||||
autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose,
|
||||
operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists,
|
||||
text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special,
|
||||
undo/redo with history, format painter.
|
||||
- Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number
|
||||
formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates,
|
||||
duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic
|
||||
row heights, autofit, hide/unhide, freeze panes.
|
||||
- Data: sort (multi-level, by colour, custom lists), AutoFilter (values, custom, top 10,
|
||||
average, colour), tables with totals rows, remove duplicates, text to columns, data validation
|
||||
with error alerts, grouping/outline, subtotals.
|
||||
- PivotTables: Insert/Recommended PivotTables, field list pane, compact/outline/tabular layouts,
|
||||
date grouping, sort, filters, show-values-as, refresh, and XLSX round-trip.
|
||||
- Page layout & print: Page Break Preview, print settings, PDF export and printing.
|
||||
- What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus.
|
||||
- Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded
|
||||
replies, resolve), Watch Window, Selection, Format Chart.
|
||||
- Charts: column, bar, line, area, pie, doughnut, scatter, bubble, radar, histogram, waterfall,
|
||||
funnel, treemap, sunburst, box & whisker, stock and combo charts; sparklines.
|
||||
- UI (egui): Excel-style title bar with Quick Access Toolbar and AutoSave, the full ribbon with
|
||||
galleries (cell styles, table styles, colour palettes, conditional formatting menus),
|
||||
contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth
|
||||
scrolling, zoom, frozen panes, text overflow), sheet tabs (rename, reorder, colour, hide),
|
||||
status bar with Sum/Count/Average and zoom, dialogs (Format Cells, Formula Builder, Find and
|
||||
Replace, Sort, Name Manager, Data Validation, Paste Special…), command palette, dark mode.
|
||||
- Agents: every action is an engine command; JSON control channel in the desktop app (pointer,
|
||||
keyboard, dialogs, screenshots); MCP server (headless or bridged); CLI (`info`, `convert`,
|
||||
`eval`, `cat`, `run`, `commands`, `functions`, `mcp`, `send`).
|
||||
- Platforms: macOS, Windows, Linux, FreeBSD; web (WASM via trunk). Release workflows for
|
||||
signed/notarized macOS universal DMG, signed Windows x64/x86 MSI + zip, Linux AppImage/deb/rpm/
|
||||
tar.gz + Flatpak manifest, FreeBSD tarball and a web zip.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status |
|
||||
|---|---|---|
|
||||
| M0 | Foundation: core, number formats, formula language, functions, model, calc, XLSX/CSV, engine | ✅ done |
|
||||
| M1 | Excel look: chrome, ribbon, formula bar, grid, tabs, status bar, control channel | ✅ done (polish continues) |
|
||||
| M2 | Formatting completeness | ✅ done |
|
||||
| M3 | Editing power (AutoComplete, spelling, drag-move/copy) | ✅ done |
|
||||
| M4 | Data (advanced filter, consolidate, what-if) | 🟡 most; Solver, ToolPak and Power Query remain |
|
||||
| M5 | Formulas tab (trace arrows, watch window, evaluate, error checking) | ✅ done |
|
||||
| M6 | Conditional formatting manager parity | ✅ done |
|
||||
| M7 | Charts parity | 🟡 partial; Format pane in, trendlines/axes/error bars remain |
|
||||
| M8 | Insert & Review | 🟡 partial; ink, icons, comments pane in |
|
||||
| M9 | Page layout & print | 🟡 most; true Page Layout view remains |
|
||||
| M10 | PivotTables, Solver, Analysis ToolPak | 🟡 PivotTables in; slicers, Solver, ToolPak remain |
|
||||
| M11 | Automation (record actions, scripts) | ✅ done |
|
||||
| M12 | Performance, accessibility, i18n | 🟡 partial |
|
||||
| M13 | Release & polish | 🟡 tooling ready; needs release secrets |
|
||||
|
||||
## How to measure
|
||||
|
||||
- `cargo xtask parity` — catalog coverage (feature names from Excel's ribbon and menus).
|
||||
- `cargo test --workspace` — engine, formula, function, XLSX and UI behaviour.
|
||||
- `cargo run --release -p gridcraft-ui-egui --example snapshot -- --sample sales out.png` — look at the UI offscreen.
|
||||
| 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 |
|
||||
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
|
||||
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
|
||||
|
||||
@@ -145,8 +145,15 @@ impl Args {
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = s.execute(id, params).map_err(|e| e.to_string());
|
||||
s.take_ui_requests();
|
||||
let r = s.execute_headless(id, params).map_err(|e| e.to_string());
|
||||
if id == "file.open"
|
||||
&& let Ok(value) = &r
|
||||
&& let Some(warnings) = value.get("warnings").and_then(Value::as_array)
|
||||
{
|
||||
for warning in warnings.iter().filter_map(Value::as_str) {
|
||||
eprintln!("Warning: {warning}");
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_preserves_doubled_quotes() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-t2c-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("make temp directory");
|
||||
let script = dir.join("steps.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
concat!(
|
||||
"{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"\\\"a\\\"\\\"b\\\",c\"}}\n",
|
||||
"{\"command\":\"data.textToColumns\",\"params\":{\"range\":\"A1\",\"delimiters\":[\",\"]}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write commands");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["run", "--script"])
|
||||
.arg(&script)
|
||||
.args(["--print", "A1:B1", "--csv", "--quiet"])
|
||||
.output()
|
||||
.expect("run Gridcraft CLI");
|
||||
std::fs::remove_file(script).expect("remove script");
|
||||
std::fs::remove_dir(dir).expect("remove temp directory");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "\"a\"\"b\",c");
|
||||
}
|
||||
@@ -26,4 +26,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
|
||||
|
||||
@@ -37,8 +37,17 @@ pub fn start() {
|
||||
} else if q.contains("sample") {
|
||||
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
|
||||
}
|
||||
let app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
SheetApp::setup_context(&cc.egui_ctx, false);
|
||||
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
// Follow the browser's language, except a CJK one: the web build has no system
|
||||
// CJK fonts (none are bundled), so that interface would render as tofu.
|
||||
let browser = gridcraft_ui_egui::i18n::Language::system();
|
||||
if !matches!(
|
||||
browser,
|
||||
gridcraft_ui_egui::i18n::Language::Zh | gridcraft_ui_egui::i18n::Language::Ja | gridcraft_ui_egui::i18n::Language::Ko
|
||||
) {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
@@ -92,13 +101,17 @@ impl eframe::App for WebShell {
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let picture_ctx = ctx.clone();
|
||||
Services {
|
||||
copy_html: Some(Box::new(copy_html)),
|
||||
open_async: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) =
|
||||
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -107,6 +120,17 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
pick_picture_async: Some(Box::new(move |inbox| {
|
||||
let ctx = picture_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"]).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
@@ -122,6 +146,39 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start inside the copy gesture; deferring `write` can lose browser user activation.
|
||||
fn copy_html(html: &str, text: &str) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let clipboard = window.navigator().clipboard();
|
||||
if clipboard.is_undefined() || clipboard.is_null() {
|
||||
return Err("browser clipboard is unavailable (a secure context is required)".into());
|
||||
}
|
||||
if !js_sys::Reflect::get(&clipboard, &"write".into()).map_err(js)?.is_function() {
|
||||
return Err("browser does not support rich clipboard writes".into());
|
||||
}
|
||||
let data = js_sys::Object::new();
|
||||
for (mime, value) in [("text/html", html), ("text/plain", text)] {
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime);
|
||||
let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(value));
|
||||
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
js_sys::Reflect::set(&data, &mime.into(), &blob).map_err(js)?;
|
||||
}
|
||||
let item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&data).map_err(js)?;
|
||||
let promise = clipboard.write(&js_sys::Array::of1(&item));
|
||||
let text = text.to_owned();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
log::warn!("Rich clipboard write failed; trying plain text: {e:?}");
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
|
||||
log::error!("Plain-text clipboard fallback also failed: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Triggers a browser download of `bytes`.
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
|
||||
@@ -25,6 +25,15 @@ winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
[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
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle (#69). This audited crate (also used
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! macOS open-documents and quit Apple events (#69).
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a GridCraft book.xlsx` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, so AppKit answered "GridCraft cannot open files in the Excel Workbook
|
||||
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
|
||||
//! workspace forbids. The audited `fmv-macos-events` crate (also used by PdfCraft and PhotoCraft)
|
||||
//! wraps exactly that, an `NSAppleEventManager` handler registered before Finder's launch event
|
||||
//! that leaves winit's delegate alone, behind a safe main-thread API.
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
app.open_path(p);
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
eprintln!("GridCraft control channel listening on 127.0.0.1:{port}");
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_initializes_only_dx12() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a sample that failed to
|
||||
//! build, a graphics adapter warning, a panic a command's guard recovered from) vanished. A launch
|
||||
//! from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings dir>/logs/gridcraft.log`, next to `ui.json` and `prefs.json` (`config_dir` in
|
||||
//! `main.rs`). Each start moves the previous log to `gridcraft.1.log` (and that one to `.2`), so
|
||||
//! the log of a run that crashed survives the next launch. Runs without preferences
|
||||
//! (`GRIDCRAFT_NO_PREFS`) log to standard error only.
|
||||
//!
|
||||
//! Levels: `info` for GridCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,gridcraft_engine=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`gridcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink.
|
||||
//!
|
||||
//! This is the desktop app's logger only; `gridcraft-cli` and `gridcraft-web` set up their own.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "gridcraft.log";
|
||||
/// How many previous logs are kept (`gridcraft.1.log` … `gridcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,gridcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("gridcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
// No file is coming ([`Sink::no_file`]): standard error had the line.
|
||||
} else if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
self.written = 0;
|
||||
self.capped = false;
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
|
||||
/// standard error stays the only sink.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
// A thread that panicked while logging leaves the sink usable: take it back.
|
||||
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
sink.attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
sink.no_file();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file for this run (runs without preferences): log to standard error only.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
|
||||
use log::Log;
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("gridcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"gridcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,gridcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "gridcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "gridcraft", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Error, "gridcraft", "internal error: boom");
|
||||
record(&logger, log::Level::Debug, "gridcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("gridcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}"));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
assert!(logger.sink.lock().expect("sink").pending.is_empty());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
record(&logger, log::Level::Warn, "gridcraft", "after the poison");
|
||||
assert!(read(&path).contains("gridcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,12 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
@@ -16,7 +21,7 @@ use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
@@ -28,6 +33,7 @@ impl eframe::App for App {
|
||||
if let Some(m) = &self.1 {
|
||||
m.poll(&mut self.0, ctx);
|
||||
}
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
@@ -61,15 +67,31 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`GRIDCRAFT_NO_PREFS`, agents' test runs) neither read nor write
|
||||
/// them, and keep no log file.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("GRIDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
Some(config_dir()?.join("logs"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SheetApp) {
|
||||
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
// First run (or a ui.json without a usable language): follow the desktop's language.
|
||||
app.ui.language = gridcraft_ui_egui::i18n::Language::system();
|
||||
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(&b)
|
||||
&& let Ok(ui) = serde_json::from_slice::<gridcraft_ui_egui::UiState>(&b)
|
||||
{
|
||||
let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v));
|
||||
let language = saved.unwrap_or(app.ui.language);
|
||||
app.ui = ui;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
|
||||
&& let Ok(p) = serde_json::from_slice(&b)
|
||||
@@ -79,7 +101,7 @@ fn load_prefs(app: &mut SheetApp) {
|
||||
}
|
||||
|
||||
fn save_prefs(app: &SheetApp) {
|
||||
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = config_dir() else { return };
|
||||
@@ -93,15 +115,31 @@ 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;
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
if clipboard.is_none() {
|
||||
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())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
||||
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
|
||||
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
|
||||
.add_filter("CSV", &["csv"])
|
||||
.pick_file()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
pick_picture: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Pictures (PNG, JPEG)", &["png", "jpg", "jpeg"])
|
||||
.pick_file()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
rfd::FileDialog::new()
|
||||
@@ -133,6 +171,26 @@ fn icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
// Commands catch their panics (`Session::execute`), but the default hook only prints to
|
||||
// standard error; this one sends every panic, caught or not, to the log file too, with a
|
||||
// backtrace when RUST_BACKTRACE is set.
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let trace = std::backtrace::Backtrace::capture();
|
||||
let report = if trace.status() == std::backtrace::BacktraceStatus::Captured {
|
||||
format!("internal error: {info}\n{trace}")
|
||||
} else {
|
||||
format!("internal error: {info}")
|
||||
};
|
||||
// Standard error and the log file; standard error alone when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{report}");
|
||||
} else {
|
||||
// `eprintln!` panics on a broken stderr pipe, and a panic inside the panic hook aborts.
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("gridcraft: {report}\n"));
|
||||
}
|
||||
}));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -157,21 +215,36 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// The log file lives in the settings folder, next to the preferences; opened after the
|
||||
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
|
||||
// first. Runs without preferences (agents' test runs) log to standard error only, so they
|
||||
// don't rotate away the user's own logs.
|
||||
if let Some(logger) = logger {
|
||||
match log_dir().filter(|_| prefs_enabled()) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => log::info!("GridCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
let mut session = Session::new();
|
||||
if let Some(s) = &sample
|
||||
&& let Err(e) = session.execute("file.new", json!({"sample": s}))
|
||||
{
|
||||
eprintln!("sample: {e}");
|
||||
log::warn!("sample {s}: {e}");
|
||||
}
|
||||
for f in &files {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": f})) {
|
||||
eprintln!("{f}: {e}");
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
if session.documents().is_empty() {
|
||||
session.new_workbook();
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.after_engine(); // Show warnings from files opened on the command line.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
@@ -187,21 +260,36 @@ fn main() -> eframe::Result<()> {
|
||||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
};
|
||||
// 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(&cc.egui_ctx, app.ui.dark);
|
||||
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) => eprintln!("control channel on port {port}: {e}"),
|
||||
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),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Entry = (&'static str, &'static str, Option<&'static str>);
|
||||
@@ -78,7 +78,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
("Freeze Top Row", "view.freezeTopRow", None),
|
||||
("Unfreeze Panes", "view.unfreezePanes", None),
|
||||
("-", "", None),
|
||||
("Dark Mode", "view.darkMode", None),
|
||||
("Display Theme", "view.theme", None),
|
||||
("Collapse Ribbon", "view.collapseRibbon", Some("CMD+ALT+R")),
|
||||
],
|
||||
),
|
||||
@@ -181,6 +181,18 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
events: Receiver<MenuEvent>,
|
||||
theme_items: Vec<(&'static str, CheckMenuItem)>,
|
||||
}
|
||||
|
||||
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
|
||||
let mode = match id {
|
||||
"theme:system" => "system",
|
||||
"theme:light" => "light",
|
||||
"theme:dark" => "dark",
|
||||
_ => return false,
|
||||
};
|
||||
app.run_or_alert("view.theme", json!({"mode": mode}));
|
||||
true
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
@@ -200,6 +212,7 @@ impl NativeMenu {
|
||||
&PredefinedMenuItem::quit(None),
|
||||
]);
|
||||
let _ = menu.append(&app_menu);
|
||||
let mut theme_items = Vec::new();
|
||||
for (title, entries) in tree() {
|
||||
let sub = Submenu::new(title, true);
|
||||
for (label, id, acc) in entries {
|
||||
@@ -207,6 +220,16 @@ impl NativeMenu {
|
||||
let _ = sub.append(&PredefinedMenuItem::separator());
|
||||
continue;
|
||||
}
|
||||
if id == "view.theme" {
|
||||
let themes = Submenu::new(label, true);
|
||||
for (mode, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
let item = CheckMenuItem::with_id(format!("theme:{mode}"), label, true, false, None);
|
||||
let _ = themes.append(&item);
|
||||
theme_items.push((mode, item));
|
||||
}
|
||||
let _ = sub.append(&themes);
|
||||
continue;
|
||||
}
|
||||
let accel = acc.and_then(|a| Accelerator::from_str(a).ok());
|
||||
let item = MenuItem::with_id(id, label, true, accel);
|
||||
let _ = sub.append(&item);
|
||||
@@ -220,12 +243,40 @@ impl NativeMenu {
|
||||
let _ = tx.send(e);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
NativeMenu { _menu: menu, events: rx }
|
||||
NativeMenu { _menu: menu, events: rx, theme_items }
|
||||
}
|
||||
|
||||
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
let id = ev.id.as_ref().to_string();
|
||||
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
|
||||
if app.text_box_editor.is_some() {
|
||||
let clipboard = match id.as_str() {
|
||||
"edit.copy" => Some(egui::ViewportCommand::RequestCopy),
|
||||
"edit.cut" => Some(egui::ViewportCommand::RequestCut),
|
||||
"edit.paste" => Some(egui::ViewportCommand::RequestPaste),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(command) = clipboard {
|
||||
ctx.send_viewport_cmd(command);
|
||||
continue;
|
||||
}
|
||||
if id == "edit.undo" || id == "edit.redo" {
|
||||
ctx.input_mut(|i| {
|
||||
i.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: id == "edit.redo", ..Default::default() },
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if select_theme(app, &id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = id.strip_prefix("url:") {
|
||||
if let Some(open) = &app.services.open_url {
|
||||
open(url);
|
||||
@@ -243,14 +294,33 @@ impl NativeMenu {
|
||||
let name = if name == "note" { "comment" } else { name };
|
||||
app.open_dialog(name, params);
|
||||
} else if id == "edit.copy" || id == "edit.cut" {
|
||||
if let Ok(r) = app.run(&id, json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(Value::as_str)
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
app.copy_to_clipboard(ctx, &id);
|
||||
} else {
|
||||
app.run_or_alert(&id, json!({}));
|
||||
}
|
||||
}
|
||||
for (mode, item) in &self.theme_items {
|
||||
let checked = app.ui.theme_mode() == *mode;
|
||||
if item.is_checked() != checked {
|
||||
item.set_checked(checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
for mode in ["dark", "system", "light"] {
|
||||
assert!(select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert_eq!(app.ui.theme_mode(), mode);
|
||||
assert!(app.message.is_none());
|
||||
}
|
||||
assert!(!select_theme(&mut app, "view.formulaBar"));
|
||||
assert_eq!(app.ui.theme_mode(), "light");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,11 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
||||
# The web app (wasm32-unknown-unknown) has no clock: these trap (`RuntimeError: unreachable`)
|
||||
# instead of returning. Read the clock only off wasm (`cfg(not(target_arch = "wasm32"))` or
|
||||
# `cfg!`) and allow the lint at that call.
|
||||
disallowed-methods = [
|
||||
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
|
||||
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
|
||||
]
|
||||
|
||||
+215
-11
@@ -2,23 +2,218 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/gridcraft",
|
||||
"branch": "main",
|
||||
"head": "a60bd2f40edae13197965ded9433dcf276caca45",
|
||||
"head_date": "2026-10-07T18:45:37Z",
|
||||
"commits": 16,
|
||||
"head": "90b3c41eec691797129303603d4073269aebab73",
|
||||
"head_date": "2026-10-10T19:59:42Z",
|
||||
"commits": 148,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 16,
|
||||
"lines_added": 78054,
|
||||
"lines_deleted": 3954,
|
||||
"prs": 5,
|
||||
"commits": 46,
|
||||
"lines_added": 81717,
|
||||
"lines_deleted": 4837,
|
||||
"binary_added": 35,
|
||||
"binary_deleted": 12,
|
||||
"first_commit": "2026-10-06T02:09:42Z",
|
||||
"last_commit": "2026-10-07T18:45:37Z"
|
||||
"last_commit": "2026-10-10T19:59:42Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 646,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T10:06:39Z",
|
||||
"last_commit": "2026-10-08T11:01:16Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 34,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:30Z",
|
||||
"last_commit": "2026-10-08T14:02:29Z"
|
||||
},
|
||||
{
|
||||
"login": "justinjohn0306",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 6,
|
||||
"commits": 7,
|
||||
"lines_added": 1408,
|
||||
"lines_deleted": 116,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:28:53Z",
|
||||
"last_commit": "2026-10-09T18:39:12Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 244,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 244,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:40:12Z",
|
||||
"last_commit": "2026-10-09T20:46:29Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 14,
|
||||
"commits": 14,
|
||||
"lines_added": 586,
|
||||
"lines_deleted": 52,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T08:45:50Z",
|
||||
"last_commit": "2026-10-10T17:06:52Z"
|
||||
},
|
||||
{
|
||||
"login": "harrisonoconnorhover",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 20,
|
||||
"commits": 22,
|
||||
"lines_added": 9266,
|
||||
"lines_deleted": 371,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:28:48Z",
|
||||
"last_commit": "2026-10-10T17:48:20Z"
|
||||
},
|
||||
{
|
||||
"login": "LloydNicholson",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 2,
|
||||
"lines_added": 130,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:13:40Z",
|
||||
"last_commit": "2026-10-09T15:23:15Z"
|
||||
},
|
||||
{
|
||||
"login": "lloyd-nich",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 5,
|
||||
"lines_added": 854,
|
||||
"lines_deleted": 88,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T18:36:08Z",
|
||||
"last_commit": "2026-10-10T12:17:14Z"
|
||||
},
|
||||
{
|
||||
"login": "dylanmtaylor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 281,
|
||||
"lines_deleted": 45,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:14:43Z",
|
||||
"last_commit": "2026-10-10T03:14:29Z"
|
||||
},
|
||||
{
|
||||
"login": "edurbs",
|
||||
"people_entry": true,
|
||||
"github_name": "Eduardo Soares",
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 311,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:18:57Z",
|
||||
"last_commit": "2026-10-09T21:18:57Z"
|
||||
},
|
||||
{
|
||||
"login": "nihilist-narwhal",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 19,
|
||||
"commits": 35,
|
||||
"lines_added": 2261,
|
||||
"lines_deleted": 145,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T22:14:59Z",
|
||||
"last_commit": "2026-10-10T10:40:00Z"
|
||||
},
|
||||
{
|
||||
"login": "neko233-com",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1004,
|
||||
"lines_deleted": 81,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T02:20:59Z",
|
||||
"last_commit": "2026-10-10T02:20:59Z"
|
||||
},
|
||||
{
|
||||
"login": "agammann",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T05:47:38Z",
|
||||
"last_commit": "2026-10-10T05:47:38Z"
|
||||
},
|
||||
{
|
||||
"login": "itrycodinganditsbad",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 7,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T11:25:38Z",
|
||||
"last_commit": "2026-10-10T11:28:07Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
@@ -27,9 +222,18 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 15,
|
||||
"lines_added": 78047,
|
||||
"lines_deleted": 3954
|
||||
"commits": 58,
|
||||
"lines_added": 83111,
|
||||
"lines_deleted": 4992
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 3,
|
||||
"lines_added": 173,
|
||||
"lines_deleted": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.nihilist-narwhal]
|
||||
display_name = "nihilist-narwhal"
|
||||
sync_github_name = false
|
||||
|
||||
[people.edurbs]
|
||||
sync_github_name = true
|
||||
|
||||
+115
-24
@@ -13,6 +13,10 @@ use gridcraft_model::Workbook;
|
||||
|
||||
/// Largest array a reference may expand to.
|
||||
pub const MAX_CELLS: u64 = 16_000_000;
|
||||
/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges
|
||||
/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that
|
||||
/// formulas over them stay fast.
|
||||
const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -54,10 +58,19 @@ pub trait Host {
|
||||
fn random(&mut self) -> f64;
|
||||
/// Called when a volatile or dynamic-reference function is used.
|
||||
fn note_volatile(&mut self) {}
|
||||
/// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill).
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied()
|
||||
}
|
||||
/// Values of a block, row-major. Hosts can override with a sparse fast path.
|
||||
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
|
||||
range.iter().map(|c| self.cell_value(sheet, c)).collect()
|
||||
}
|
||||
/// The sheet's used range (`Sheet::used_range`, which walks every stored row). Hosts that
|
||||
/// keep the workbook unchanged while evaluating can remember it.
|
||||
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
|
||||
self.workbook().sheet(sheet).and_then(|s| s.used_range())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Evaluator<'h> {
|
||||
@@ -129,7 +142,10 @@ impl<'h> Evaluator<'h> {
|
||||
if a.range.is_single() {
|
||||
return self.host.cell_value(a.sheet, a.range.start);
|
||||
}
|
||||
let range = self.trim(a);
|
||||
// A reference keeps its size, so the array has its shape and its blanks count
|
||||
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
|
||||
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
|
||||
let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range };
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
if (h as u64) * (w as u64) > MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
@@ -140,9 +156,11 @@ impl<'h> Evaluator<'h> {
|
||||
|
||||
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
|
||||
/// positions stay right).
|
||||
pub fn trim(&self, a: &Area) -> RangeRef {
|
||||
let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range };
|
||||
let used = sheet.used_range();
|
||||
pub fn trim(&mut self, a: &Area) -> RangeRef {
|
||||
if self.host.workbook().sheet(a.sheet).is_none() {
|
||||
return a.range;
|
||||
}
|
||||
let used = self.host.used_range(a.sheet);
|
||||
let (mut r1, mut c1) = (a.range.end.row, a.range.end.col);
|
||||
match used {
|
||||
Some(u) => {
|
||||
@@ -322,6 +340,19 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn unary(&mut self, op: UnOp, x: &Expr) -> Ev {
|
||||
if op == UnOp::Spill {
|
||||
return match self.eval(x) {
|
||||
Ev::R(areas) => match areas.as_slice() {
|
||||
[a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) {
|
||||
Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]),
|
||||
None => err(CellError::Ref),
|
||||
},
|
||||
_ => err(CellError::Ref),
|
||||
},
|
||||
Ev::V(Value::Error(e)) => err(e),
|
||||
_ => err(CellError::Ref),
|
||||
};
|
||||
}
|
||||
if op == UnOp::At {
|
||||
let ev = self.eval(x);
|
||||
return match ev {
|
||||
@@ -343,7 +374,7 @@ impl<'h> Evaluator<'h> {
|
||||
UnOp::Neg => Value::number(-n),
|
||||
UnOp::Plus => Value::number(n),
|
||||
UnOp::Percent => Value::number(n / 100.0),
|
||||
UnOp::At => Value::number(n),
|
||||
UnOp::At | UnOp::Spill => Value::number(n),
|
||||
},
|
||||
op == UnOp::Plus,
|
||||
))
|
||||
@@ -696,8 +727,20 @@ impl<'h> Evaluator<'h> {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut evaluated = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
let ev = self.eval(a);
|
||||
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
|
||||
let mut criteria_shape = None;
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
let mut ev = self.eval(a);
|
||||
if resizes_sum_range(name, args.len()) {
|
||||
if i == 0 {
|
||||
criteria_shape = ev_shape(&ev);
|
||||
} else if i == 2
|
||||
&& let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev)
|
||||
&& let [area] = areas.as_slice()
|
||||
{
|
||||
ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]);
|
||||
}
|
||||
}
|
||||
let from_ref = matches!(ev, Ev::R(_));
|
||||
let value = match ev {
|
||||
Ev::R(areas) if areas.len() > 1 => {
|
||||
@@ -881,7 +924,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
|
||||
fn trim_lines(&self, a: &Area) -> RangeRef {
|
||||
fn trim_lines(&mut self, a: &Area) -> RangeRef {
|
||||
if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range }
|
||||
}
|
||||
|
||||
@@ -1306,30 +1349,64 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
|
||||
fn resizes_sum_range(name: &str, args: usize) -> bool {
|
||||
args == 3 && matches!(name, "SUMIF" | "AVERAGEIF")
|
||||
}
|
||||
|
||||
/// Height and width of a single-area reference or an array argument.
|
||||
fn ev_shape(ev: &Ev) -> Option<(u32, u32)> {
|
||||
match ev {
|
||||
Ev::R(areas) => match areas.as_slice() {
|
||||
[a] => Some((a.range.height(), a.range.width())),
|
||||
_ => None,
|
||||
},
|
||||
Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))),
|
||||
Ev::V(_) => Some((1, 1)),
|
||||
Ev::L(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns
|
||||
/// as the criteria range (`height` × `width`), cut off at the sheet's edge.
|
||||
pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef {
|
||||
let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1);
|
||||
let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1);
|
||||
RangeRef::new(sum.start, CellRef::new(row, col))
|
||||
}
|
||||
|
||||
/// Collects the static references of an expression with their sheets resolved; `dynamic` is
|
||||
/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change.
|
||||
pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut dynamic = false;
|
||||
e.walk(&mut |x| match x {
|
||||
Expr::Ref(r) => {
|
||||
let range = r.range();
|
||||
match &r.sheet {
|
||||
SheetSel::Current => out.push(Area { sheet, range }),
|
||||
SheetSel::Named(n) => {
|
||||
if let Some(i) = wb.sheet_index(n) {
|
||||
out.push(Area { sheet: i, range });
|
||||
}
|
||||
}
|
||||
SheetSel::Span(a, b) => {
|
||||
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
|
||||
for s in i.min(j)..=i.max(j) {
|
||||
out.push(Area { sheet: s, range });
|
||||
}
|
||||
}
|
||||
let push = |out: &mut Vec<Area>, r: &Reference, range: RangeRef| match &r.sheet {
|
||||
SheetSel::Current => out.push(Area { sheet, range }),
|
||||
SheetSel::Named(n) => {
|
||||
if let Some(i) = wb.sheet_index(n) {
|
||||
out.push(Area { sheet: i, range });
|
||||
}
|
||||
}
|
||||
SheetSel::Span(a, b) => {
|
||||
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
|
||||
for s in i.min(j)..=i.max(j) {
|
||||
out.push(Area { sheet: s, range });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
e.walk(&mut |x| match x {
|
||||
Expr::Ref(r) => push(&mut out, r, r.range()),
|
||||
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
|
||||
// the criteria range's size isn't known statically, recalculate the formula every time.
|
||||
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
|
||||
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
|
||||
let (crit, sum) = (c.range(), s.range());
|
||||
push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width()));
|
||||
}
|
||||
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
|
||||
_ => dynamic = true,
|
||||
},
|
||||
Expr::Call(n, _)
|
||||
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
|
||||
{
|
||||
@@ -1337,6 +1414,20 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
|
||||
}
|
||||
Expr::Name(_) | Expr::Struct(_) => dynamic = true,
|
||||
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true,
|
||||
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between
|
||||
// the references inside it, not only those references.
|
||||
Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => {
|
||||
let mut inner = precedents(wb, sheet, a).0;
|
||||
inner.extend(precedents(wb, sheet, b).0);
|
||||
let mut boxes: Vec<Area> = Vec::new();
|
||||
for area in inner {
|
||||
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
|
||||
Some(x) => x.range = x.range.union(&area.range),
|
||||
None => boxes.push(area),
|
||||
}
|
||||
}
|
||||
out.extend(boxes);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
(out, dynamic)
|
||||
|
||||
+626
-46
@@ -1,13 +1,14 @@
|
||||
//! Dependency graph and recalculation.
|
||||
//!
|
||||
//! The graph maps every formula cell to its static precedents (resolved areas). A reverse index
|
||||
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in
|
||||
//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the
|
||||
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges as row
|
||||
//! intervals per column (wide ranges per sheet), each an interval index so a lookup costs
|
||||
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
|
||||
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
|
||||
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
|
||||
//! calculation is on.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
|
||||
@@ -51,19 +52,147 @@ struct Node {
|
||||
dynamic: bool,
|
||||
}
|
||||
|
||||
/// Ranges wider than this many columns go to the wide list instead of per-column buckets.
|
||||
/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones.
|
||||
const WIDE: u32 = 64;
|
||||
|
||||
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
|
||||
/// `a..=b`?" in O(log n + k).
|
||||
///
|
||||
/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query
|
||||
/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one
|
||||
/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops
|
||||
/// dead entries once either grows, so mutations cost O(log n) amortised.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Intervals<T> {
|
||||
/// Sorted, without duplicates.
|
||||
items: Vec<(u32, u32, T)>,
|
||||
dead: Vec<bool>,
|
||||
dead_count: usize,
|
||||
/// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at
|
||||
/// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included).
|
||||
max_end: Vec<u32>,
|
||||
/// Inserted since the last merge.
|
||||
pending: BTreeSet<(u32, u32, T)>,
|
||||
}
|
||||
|
||||
impl<T> Default for Intervals<T> {
|
||||
fn default() -> Self {
|
||||
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy + Ord> Intervals<T> {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.items.len() == self.dead_count && self.pending.is_empty()
|
||||
}
|
||||
|
||||
fn insert(&mut self, r0: u32, r1: u32, t: T) {
|
||||
let it = (r0, r1, t);
|
||||
if let Ok(i) = self.items.binary_search(&it) {
|
||||
if let Some(d) = self.dead.get_mut(i)
|
||||
&& *d
|
||||
{
|
||||
*d = false;
|
||||
self.dead_count -= 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending.insert(it);
|
||||
}
|
||||
|
||||
fn remove(&mut self, r0: u32, r1: u32, t: T) {
|
||||
let it = (r0, r1, t);
|
||||
if let Ok(i) = self.items.binary_search(&it) {
|
||||
if let Some(d) = self.dead.get_mut(i)
|
||||
&& !*d
|
||||
{
|
||||
*d = true;
|
||||
self.dead_count += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending.remove(&it);
|
||||
}
|
||||
|
||||
/// Merges pending insertions and drops dead entries when there are enough of them.
|
||||
fn settle(&mut self) {
|
||||
if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) {
|
||||
return;
|
||||
}
|
||||
let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len());
|
||||
all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it));
|
||||
all.extend(std::mem::take(&mut self.pending));
|
||||
// Two sorted runs: the stable sort merges them in linear time.
|
||||
all.sort();
|
||||
all.dedup();
|
||||
let size = all.len().next_power_of_two();
|
||||
let mut max_end = vec![0; 2 * size];
|
||||
if let Some(leaves) = max_end.get_mut(size..) {
|
||||
for (slot, it) in leaves.iter_mut().zip(&all) {
|
||||
*slot = it.1;
|
||||
}
|
||||
}
|
||||
for i in (1..size).rev() {
|
||||
let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0));
|
||||
if let Some(slot) = max_end.get_mut(i) {
|
||||
*slot = m;
|
||||
}
|
||||
}
|
||||
self.dead = vec![false; all.len()];
|
||||
self.dead_count = 0;
|
||||
self.items = all;
|
||||
self.max_end = max_end;
|
||||
}
|
||||
|
||||
/// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the
|
||||
/// number of tree nodes visited.
|
||||
fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize {
|
||||
for it in &self.pending {
|
||||
if it.0 <= b && it.1 >= a {
|
||||
f(&it.2);
|
||||
}
|
||||
}
|
||||
// Entries before `p` start at or before row `b`.
|
||||
let p = self.items.partition_point(|it| it.0 <= b);
|
||||
if p == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
|
||||
}
|
||||
|
||||
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
|
||||
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
|
||||
return 1;
|
||||
}
|
||||
if hi - lo <= 1 {
|
||||
if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo))
|
||||
&& it.1 >= a
|
||||
{
|
||||
f(&it.2);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Graph {
|
||||
nodes: HashMap<Key, Node>,
|
||||
/// single cell → dependents
|
||||
cell_deps: HashMap<Key, Vec<Key>>,
|
||||
/// (sheet, col) → [(r0, r1, dependent)]
|
||||
col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>,
|
||||
/// (sheet, range, dependent)
|
||||
wide_deps: Vec<(usize, RangeRef, Key)>,
|
||||
cell_deps: HashMap<Key, HashSet<Key>>,
|
||||
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
|
||||
col_deps: HashMap<(usize, u32), Intervals<Key>>,
|
||||
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
|
||||
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
|
||||
dynamic: HashSet<Key>,
|
||||
/// Range indexes changed since the last [`Graph::settle`].
|
||||
unsettled_cols: HashSet<(usize, u32)>,
|
||||
unsettled_wide: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl Graph {
|
||||
@@ -74,19 +203,44 @@ impl Graph {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
fn insert(&mut self, k: Key, node: Node) {
|
||||
self.remove(k);
|
||||
for a in &node.areas {
|
||||
/// Index entries of a formula's precedents, without duplicates: single cells, column
|
||||
/// intervals and wide ranges.
|
||||
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
|
||||
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for a in areas {
|
||||
if a.range.is_single() {
|
||||
self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k);
|
||||
cells.push((a.sheet, a.range.start));
|
||||
} else if a.range.width() > WIDE {
|
||||
self.wide_deps.push((a.sheet, a.range, k));
|
||||
wide.push((a.sheet, a.range));
|
||||
} else {
|
||||
for c in a.range.start.col..=a.range.end.col {
|
||||
self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k));
|
||||
cols.push((a.sheet, c, a.range.start.row, a.range.end.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.sort_unstable();
|
||||
cells.dedup();
|
||||
cols.sort_unstable();
|
||||
cols.dedup();
|
||||
wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end));
|
||||
wide.dedup();
|
||||
(cells, cols, wide)
|
||||
}
|
||||
|
||||
fn insert(&mut self, k: Key, node: Node) {
|
||||
self.remove(k);
|
||||
let (cells, cols, wide) = Self::entries(&node.areas);
|
||||
for c in cells {
|
||||
self.cell_deps.entry(c).or_default().insert(k);
|
||||
}
|
||||
for (s, c, r0, r1) in cols {
|
||||
self.col_deps.entry((s, c)).or_default().insert(r0, r1, k);
|
||||
self.unsettled_cols.insert((s, c));
|
||||
}
|
||||
for (s, r) in wide {
|
||||
self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k));
|
||||
self.unsettled_wide.insert(s);
|
||||
}
|
||||
if node.dynamic {
|
||||
self.dynamic.insert(k);
|
||||
}
|
||||
@@ -95,54 +249,104 @@ impl Graph {
|
||||
|
||||
fn remove(&mut self, k: Key) {
|
||||
let Some(old) = self.nodes.remove(&k) else { return };
|
||||
for a in &old.areas {
|
||||
if a.range.is_single() {
|
||||
if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) {
|
||||
v.retain(|x| *x != k);
|
||||
}
|
||||
} else if a.range.width() > WIDE {
|
||||
self.wide_deps.retain(|(_, _, d)| *d != k);
|
||||
} else {
|
||||
for c in a.range.start.col..=a.range.end.col {
|
||||
if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) {
|
||||
v.retain(|(_, _, d)| *d != k);
|
||||
}
|
||||
let (cells, cols, wide) = Self::entries(&old.areas);
|
||||
for c in cells {
|
||||
if let Some(v) = self.cell_deps.get_mut(&c) {
|
||||
v.remove(&k);
|
||||
if v.is_empty() {
|
||||
self.cell_deps.remove(&c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (s, c, r0, r1) in cols {
|
||||
if let Some(v) = self.col_deps.get_mut(&(s, c)) {
|
||||
v.remove(r0, r1, k);
|
||||
self.unsettled_cols.insert((s, c));
|
||||
}
|
||||
}
|
||||
for (s, r) in wide {
|
||||
if let Some(v) = self.wide_deps.get_mut(&s) {
|
||||
v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k));
|
||||
self.unsettled_wide.insert(s);
|
||||
}
|
||||
}
|
||||
self.dynamic.remove(&k);
|
||||
}
|
||||
|
||||
/// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it,
|
||||
/// just slower).
|
||||
fn settle(&mut self) {
|
||||
for key in std::mem::take(&mut self.unsettled_cols) {
|
||||
if let Some(v) = self.col_deps.get_mut(&key) {
|
||||
v.settle();
|
||||
if v.is_empty() {
|
||||
self.col_deps.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in std::mem::take(&mut self.unsettled_wide) {
|
||||
if let Some(v) = self.wide_deps.get_mut(&key) {
|
||||
v.settle();
|
||||
if v.is_empty() {
|
||||
self.wide_deps.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulas that read cell `c` of `sheet`.
|
||||
pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec<Key>) {
|
||||
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
|
||||
out.extend_from_slice(v);
|
||||
out.extend(v.iter().copied());
|
||||
}
|
||||
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
|
||||
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k));
|
||||
v.query(c.row, c.row, &mut |k| out.push(*k));
|
||||
}
|
||||
if let Some(v) = self.wide_deps.get(&sheet) {
|
||||
v.query(c.row, c.row, &mut |(c0, c1, k)| {
|
||||
if *c0 <= c.col && c.col <= *c1 {
|
||||
out.push(*k);
|
||||
}
|
||||
});
|
||||
}
|
||||
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k));
|
||||
}
|
||||
|
||||
/// Formulas that read any cell of `range`.
|
||||
/// Formulas that read any cell of `range` (a formula can be listed more than once).
|
||||
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
|
||||
if range.count() <= 4096 {
|
||||
for c in range.iter() {
|
||||
self.dependents(sheet, c, out);
|
||||
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
|
||||
out.extend(v.iter().copied());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for ((s, c), v) in &self.cell_deps {
|
||||
if *s == sheet && range.contains(*c) {
|
||||
out.extend_from_slice(v);
|
||||
} else {
|
||||
for ((s, c), v) in &self.cell_deps {
|
||||
if *s == sheet && range.contains(*c) {
|
||||
out.extend(v.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((s, col), v) in &self.col_deps {
|
||||
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
|
||||
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k));
|
||||
let (r0, r1) = (range.start.row, range.end.row);
|
||||
if (range.width() as usize) <= self.col_deps.len() {
|
||||
for col in range.start.col..=range.end.col {
|
||||
if let Some(v) = self.col_deps.get(&(sheet, col)) {
|
||||
v.query(r0, r1, &mut |k| out.push(*k));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for ((s, col), v) in &self.col_deps {
|
||||
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
|
||||
v.query(r0, r1, &mut |k| out.push(*k));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k));
|
||||
if let Some(v) = self.wide_deps.get(&sheet) {
|
||||
v.query(r0, r1, &mut |(c0, c1, k)| {
|
||||
if *c0 <= range.end.col && range.start.col <= *c1 {
|
||||
out.push(*k);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Precedent areas of a formula cell (for Trace Precedents).
|
||||
@@ -177,6 +381,9 @@ struct PassHost<'a> {
|
||||
in_progress: HashSet<Key>,
|
||||
/// New spills found during the pass: anchor → array.
|
||||
spills: HashMap<Key, std::sync::Arc<Array>>,
|
||||
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
|
||||
/// every row, so once per sheet rather than once per range read.
|
||||
used: HashMap<usize, Option<RangeRef>>,
|
||||
rng: &'a mut u64,
|
||||
now: f64,
|
||||
cycle: bool,
|
||||
@@ -184,6 +391,27 @@ struct PassHost<'a> {
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is
|
||||
/// recalculated in this pass, and so may spill elsewhere now).
|
||||
fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
let Some(sh) = host.wb.sheet(sheet) else { return false };
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
|
||||
let range = RangeRef::new(anchor, end);
|
||||
let own = sh.spill_ranges.get(&anchor);
|
||||
let spilled_over = |c: CellRef| {
|
||||
!own.is_some_and(|o| o.contains(c))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
|
||||
};
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| sh.merges.iter().any(|m| m.intersects(&range))
|
||||
|| sh.tables.iter().any(|t| t.range.contains(anchor))
|
||||
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some()))
|
||||
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
|
||||
}
|
||||
|
||||
impl PassHost<'_> {
|
||||
fn compute(&mut self, k: Key) -> Value {
|
||||
if let Some(v) = self.results.get(&k) {
|
||||
@@ -204,27 +432,96 @@ impl PassHost<'_> {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
ev.value(&expr)
|
||||
};
|
||||
// A legacy array formula fills the range it was entered in.
|
||||
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
|
||||
let v = match legacy {
|
||||
Some(range) => fit_to_range(v, range),
|
||||
None => v,
|
||||
};
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
self.pending.remove(&k);
|
||||
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
|
||||
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
|
||||
// of their own, which stay blank.
|
||||
let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY"));
|
||||
let v = match v {
|
||||
Value::Array(a) if a.rows == 1 && a.cols == 1 => a.data.first().cloned().unwrap_or_default(),
|
||||
Value::Array(a) => {
|
||||
self.spills.insert(k, a.clone());
|
||||
let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) {
|
||||
a
|
||||
} else {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
if a.rows > 1 || a.cols > 1 {
|
||||
self.spills.insert(k, a.clone());
|
||||
}
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v => v,
|
||||
};
|
||||
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
|
||||
/// Excel does: a single row or column repeats across the range, a single value fills it, cells
|
||||
/// past the result show `#N/A`, and a result larger than the range is cut off.
|
||||
fn fit_to_range(v: Value, range: RangeRef) -> Value {
|
||||
if range.count() > crate::eval::MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
let Value::Array(a) = v else {
|
||||
return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) };
|
||||
};
|
||||
if h * w == 1 {
|
||||
return a.data.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut data = Vec::with_capacity(h * w);
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c });
|
||||
data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA)));
|
||||
}
|
||||
}
|
||||
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
|
||||
}
|
||||
|
||||
impl Host for PassHost<'_> {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
self.wb
|
||||
}
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
let k = (sheet, anchor);
|
||||
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
||||
return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied();
|
||||
}
|
||||
// Recalculated in this pass: its new array, unless something blocks it (#SPILL!).
|
||||
self.compute(k);
|
||||
let arr = self.spills.get(&k)?;
|
||||
let end = CellRef::new(
|
||||
anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)),
|
||||
anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)),
|
||||
);
|
||||
let range = RangeRef::new(anchor, end);
|
||||
let sh = self.wb.sheet(sheet)?;
|
||||
let blocked = end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty()))
|
||||
|| sh.merges.iter().any(|m| m.intersects(&range));
|
||||
(!blocked).then_some(range)
|
||||
}
|
||||
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
|
||||
let k = (sheet, c);
|
||||
if self.pending.contains(&k) || self.results.contains_key(&k) {
|
||||
@@ -301,6 +598,10 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
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()))
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
self.now
|
||||
}
|
||||
@@ -340,17 +641,29 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.graph.settle();
|
||||
}
|
||||
|
||||
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
||||
pub fn recalc_all(&mut self, wb: &mut Workbook) {
|
||||
self.rebuild(wb);
|
||||
// Spills are laid out again from the formulas where they are now (a structural edit moves
|
||||
// or deletes the formulas, not the values they spilled).
|
||||
for i in 0..wb.sheets.len() {
|
||||
if wb.sheet(i).is_some_and(|s| !s.spill_ranges.is_empty() || !s.spill.is_empty())
|
||||
&& let Some(sh) = wb.sheet_mut(i)
|
||||
{
|
||||
sh.spill.clear();
|
||||
sh.spill_ranges.clear();
|
||||
}
|
||||
}
|
||||
let all: Vec<Key> = self.graph.nodes.keys().copied().collect();
|
||||
self.run(wb, all, true);
|
||||
}
|
||||
|
||||
/// Updates the graph for cells whose content changed and recalculates what depends on them.
|
||||
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
@@ -360,6 +673,15 @@ impl Calc {
|
||||
}
|
||||
None => {
|
||||
self.graph.remove(k);
|
||||
// A formula replaced by a constant (or cleared) takes its spilled values with it.
|
||||
if let Some(sh) = wb.sheet_mut(k.0)
|
||||
&& let Some(r) = sh.spill_ranges.remove(&k.1)
|
||||
{
|
||||
for c in r.iter() {
|
||||
sh.spill.remove(&c);
|
||||
}
|
||||
cleared.push((k.0, r));
|
||||
}
|
||||
// A formula that doesn't parse evaluates to #NAME?.
|
||||
if let Some(cell) = wb.sheet_mut(k.0).and_then(|s| s.cells.get_mut(k.1))
|
||||
&& cell.formula.is_some()
|
||||
@@ -369,6 +691,7 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.graph.settle();
|
||||
if wb.calc.mode == CalcMode::Manual {
|
||||
// Only the edited formulas themselves are evaluated.
|
||||
let own: Vec<Key> = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect();
|
||||
@@ -376,6 +699,7 @@ impl Calc {
|
||||
return;
|
||||
}
|
||||
let mut seeds: Vec<Key> = changed.to_vec();
|
||||
let mut emptied: Vec<Key> = Vec::new();
|
||||
// Typing into (or clearing) a cell a dynamic array spills over (or would spill over)
|
||||
// re-evaluates the anchor, which may now be blocked or unblocked.
|
||||
for &k in changed {
|
||||
@@ -390,6 +714,20 @@ impl Calc {
|
||||
seeds.push((k.0, c));
|
||||
}
|
||||
}
|
||||
if !sh.cells.has(k.1) {
|
||||
match emptied.iter_mut().find(|x| x.0 == k.0) {
|
||||
Some(x) => x.1 = CellRef::new(x.1.row.max(k.1.row), x.1.col.max(k.1.col)),
|
||||
None => emptied.push(k),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An emptied cell can unblock an anchor any distance above or to the left of it.
|
||||
for (si, end) in emptied {
|
||||
for &(s, c) in self.graph.nodes.keys() {
|
||||
if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {
|
||||
seeds.push((s, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spill areas of changed anchors also change.
|
||||
@@ -398,6 +736,9 @@ impl Calc {
|
||||
seeds.extend(r.iter().take(65536).map(|c| (k.0, c)));
|
||||
}
|
||||
}
|
||||
for (si, r) in &cleared {
|
||||
seeds.extend(r.iter().take(65536).map(|c| (*si, c)));
|
||||
}
|
||||
let t0 = prof_now();
|
||||
let dirty = self.dirty_closure(wb, &seeds);
|
||||
let t1 = prof_now();
|
||||
@@ -443,6 +784,7 @@ impl Calc {
|
||||
|
||||
/// Evaluates the dirty set and writes results (and spills) back.
|
||||
fn run(&mut self, wb: &mut Workbook, mut dirty: Vec<Key>, full: bool) {
|
||||
let mut dynamic_again = !self.graph.dynamic.is_empty();
|
||||
for _round in 0..8 {
|
||||
if dirty.is_empty() {
|
||||
break;
|
||||
@@ -466,6 +808,7 @@ impl Calc {
|
||||
pending: exprs.keys().copied().collect(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now,
|
||||
cycle: false,
|
||||
@@ -486,6 +829,7 @@ impl Calc {
|
||||
self.circular = cycles;
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for (k, v) in results {
|
||||
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1)
|
||||
@@ -514,7 +858,8 @@ impl Calc {
|
||||
let blocked = end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c)))
|
||||
|| sheet.merges.iter().any(|m| m.intersects(&range));
|
||||
|| sheet.merges.iter().any(|m| m.intersects(&range))
|
||||
|| sheet.tables.iter().any(|t| t.range.contains(k.1));
|
||||
if blocked {
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
@@ -532,7 +877,30 @@ impl Calc {
|
||||
if let Some(r) = changed_range {
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, r, &mut deps);
|
||||
spill_changes.extend(deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
}
|
||||
if let Some(o) = old
|
||||
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
|
||||
{
|
||||
freed.push((k.0, o));
|
||||
}
|
||||
}
|
||||
// Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read
|
||||
// a spill area before it was laid out: they are evaluated once more.
|
||||
if dynamic_again && !spills.is_empty() {
|
||||
dynamic_again = false;
|
||||
spill_changes.extend(self.graph.dynamic.iter().copied());
|
||||
}
|
||||
// An area a formula no longer spills over may unblock another formula's array.
|
||||
for (si, o) in freed {
|
||||
for &(s, c) in self.graph.nodes.keys() {
|
||||
if s == si
|
||||
&& c.row <= o.end.row
|
||||
&& c.col <= o.end.col
|
||||
&& wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))
|
||||
{
|
||||
spill_changes.push((s, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
if full {
|
||||
@@ -543,7 +911,6 @@ impl Calc {
|
||||
}
|
||||
spill_changes.sort_by_key(|(s, c)| (*s, c.row, c.col));
|
||||
spill_changes.dedup();
|
||||
spill_changes.retain(|k| !spills.contains_key(k));
|
||||
if spill_changes.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -553,6 +920,7 @@ impl Calc {
|
||||
}
|
||||
|
||||
/// Local date-time as a serial (1900 system). Wasm without a clock returns a fixed date.
|
||||
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
|
||||
pub fn now_serial() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -582,6 +950,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
|
||||
pending: HashSet::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: now_serial(),
|
||||
cycle: false,
|
||||
@@ -592,6 +961,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
|
||||
ev.value(expr)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
|
||||
fn prof_now() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -602,3 +972,213 @@ fn prof_now() -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
|
||||
use super::{Calc, Intervals, Key};
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random cases.
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
fn found(iv: &Intervals<u32>, a: u32, b: u32) -> Vec<u32> {
|
||||
let mut v = Vec::new();
|
||||
iv.query(a, b, &mut |t| v.push(*t));
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
let mut model: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut s = 0x9E37_79B9_7F4A_7C15;
|
||||
for step in 0..20_000u32 {
|
||||
let roll = rng(&mut s) % 10;
|
||||
if roll < 6 || model.is_empty() {
|
||||
let r0 = (rng(&mut s) % 500) as u32;
|
||||
let r1 = r0 + (rng(&mut s) % 40) as u32;
|
||||
if !model.contains(&(r0, r1, step)) {
|
||||
iv.insert(r0, r1, step);
|
||||
model.push((r0, r1, step));
|
||||
}
|
||||
} else {
|
||||
let i = (rng(&mut s) % model.len() as u64) as usize;
|
||||
let (r0, r1, t) = model.swap_remove(i);
|
||||
iv.remove(r0, r1, t);
|
||||
if roll == 9 {
|
||||
// Removed and put back before the next merge.
|
||||
iv.insert(r0, r1, t);
|
||||
model.push((r0, r1, t));
|
||||
}
|
||||
}
|
||||
if rng(&mut s).is_multiple_of(50) {
|
||||
iv.settle();
|
||||
}
|
||||
if step.is_multiple_of(97) {
|
||||
let a = (rng(&mut s) % 560) as u32;
|
||||
let b = a + (rng(&mut s) % 30) as u32;
|
||||
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= b && *r1 >= a).map(|(_, _, t)| *t).collect();
|
||||
want.sort_unstable();
|
||||
assert_eq!(found(&iv, a, b), want, "rows {a}..={b} at step {step}");
|
||||
}
|
||||
}
|
||||
iv.settle();
|
||||
for row in 0..560 {
|
||||
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= row && row <= *r1).map(|(_, _, t)| *t).collect();
|
||||
want.sort_unstable();
|
||||
assert_eq!(found(&iv, row, row), want, "row {row}");
|
||||
}
|
||||
for (r0, r1, t) in std::mem::take(&mut model) {
|
||||
iv.remove(r0, r1, t);
|
||||
}
|
||||
iv.settle();
|
||||
assert!(iv.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_queries_cost_log_n_plus_matches() {
|
||||
// 40k one-row ranges (like =SUM(E1:J1) down a column) and 1k ranges over everything:
|
||||
// a point query visits O(log n) tree nodes per match, not every range in the column.
|
||||
let n = 40_000u32;
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
for r in 0..n {
|
||||
iv.insert(r, r, r);
|
||||
}
|
||||
for t in 0..1_000 {
|
||||
iv.insert(0, n, n + t);
|
||||
}
|
||||
iv.settle();
|
||||
let mut worst = 0;
|
||||
for row in (0..n).step_by(7) {
|
||||
let mut k = 0;
|
||||
let visited = iv.query(row, row, &mut |_| k += 1);
|
||||
assert_eq!(k, 1_001);
|
||||
worst = worst.max(visited);
|
||||
}
|
||||
// 1,001 matches in a tree of depth 16: well under 4 nodes per match plus the path.
|
||||
assert!(worst < 4 * 1_001 + 64, "visited {worst} nodes");
|
||||
let mut k = 0;
|
||||
let visited = iv.query(n + 1, n + 5, &mut |_| k += 1);
|
||||
assert_eq!(k, 0);
|
||||
assert!(visited < 64, "visited {visited} nodes for no match");
|
||||
}
|
||||
|
||||
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
|
||||
fn sum_rows(rows: u32) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for r in 0..rows {
|
||||
for c in 4..10 {
|
||||
sh.cells.set(CellRef::new(r, c), Cell::value(Value::Number(f64::from(r % 97 + c))));
|
||||
}
|
||||
sh.cells.set(CellRef::new(r, 10), Cell::formula(Formula::new(&format!("=SUM(E{0}:J{0})", r + 1))));
|
||||
}
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
/// Opening (full recalc), changing every value of a column, and re-entering every formula.
|
||||
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
|
||||
fn open_and_bulk_edit(rows: u32) -> Duration {
|
||||
let mut wb = sum_rows(rows);
|
||||
let mut calc = Calc::new();
|
||||
let t = Instant::now();
|
||||
calc.recalc_all(&mut wb);
|
||||
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for (_, c) in &values {
|
||||
sh.cells.set(*c, Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
}
|
||||
calc.cells_changed(&mut wb, &values);
|
||||
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
|
||||
calc.cells_changed(&mut wb, &formulas);
|
||||
let elapsed = t.elapsed();
|
||||
let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10)));
|
||||
let r = rows - 1;
|
||||
assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::<u32>()))));
|
||||
elapsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_formulas_recalc_and_bulk_edit_correctly() {
|
||||
// The values behind the timing test below, checked on every run.
|
||||
open_and_bulk_edit(500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing-sensitive; run with --ignored in release"]
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn recalc_with_range_formulas_scales_linearly() {
|
||||
// Each row's SUM reads a range in the same columns. Recalculating on open used to walk
|
||||
// every row per range read (the used range), and finding a cell's dependents scanned
|
||||
// every range in its column: 4x the rows took 16x the time. Linear is 4x; allow 8x.
|
||||
let best = |rows| (0..3).map(|_| open_and_bulk_edit(rows)).min().unwrap_or_default();
|
||||
let small = best(5_000);
|
||||
let large = best(20_000);
|
||||
assert!(large < small * 8 + Duration::from_millis(100), "5k rows {small:?}, 20k rows {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_dependents_after_edits() {
|
||||
// Formulas that read ranges, wide ranges and whole columns, found again after edits.
|
||||
let mut wb = Workbook::new();
|
||||
let mut calc = Calc::new();
|
||||
let set = |wb: &mut Workbook, calc: &mut Calc, at: &str, input: &str| {
|
||||
let c = CellRef::parse(at).unwrap();
|
||||
let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(Value::Number(input.parse().unwrap())) };
|
||||
wb.sheet_mut(0).unwrap().cells.set(c, cell);
|
||||
calc.cells_changed(wb, &[(0, c)]);
|
||||
};
|
||||
let num = |wb: &Workbook, at: &str| {
|
||||
let v = wb.sheet(0).unwrap().value(CellRef::parse(at).unwrap());
|
||||
v.as_f64().unwrap_or_else(|| panic!("{at} = {v:?}"))
|
||||
};
|
||||
set(&mut wb, &mut calc, "A1", "1");
|
||||
set(&mut wb, &mut calc, "B2", "2");
|
||||
set(&mut wb, &mut calc, "CZ3", "3");
|
||||
set(&mut wb, &mut calc, "DA5", "=SUM(A1:B2)");
|
||||
set(&mut wb, &mut calc, "DA6", "=SUM(A1:CZ3)");
|
||||
set(&mut wb, &mut calc, "DA7", "=SUM(B:B)");
|
||||
set(&mut wb, &mut calc, "DA8", "=SUM(2:2)");
|
||||
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (3.0, 6.0, 2.0, 2.0));
|
||||
set(&mut wb, &mut calc, "B2", "20");
|
||||
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (21.0, 24.0, 20.0, 20.0));
|
||||
// Re-entering a formula with another range drops the old one.
|
||||
set(&mut wb, &mut calc, "DA5", "=SUM(A1:A2)");
|
||||
set(&mut wb, &mut calc, "B2", "5");
|
||||
assert_eq!(num(&wb, "DA5"), 1.0);
|
||||
let mut deps = Vec::new();
|
||||
calc.graph.dependents(0, CellRef::parse("B2").unwrap(), &mut deps);
|
||||
deps.sort_unstable();
|
||||
let at = |s: &str| (0, CellRef::parse(s).unwrap());
|
||||
assert_eq!(deps, vec![at("DA6"), at("DA7"), at("DA8")]);
|
||||
deps.clear();
|
||||
calc.graph.dependents_of_range(0, RangeRef::new(CellRef::parse("A1").unwrap(), CellRef::parse("A9").unwrap()), &mut deps);
|
||||
deps.sort_unstable();
|
||||
deps.dedup();
|
||||
assert_eq!(deps, vec![at("DA5"), at("DA6"), at("DA8")]);
|
||||
set(&mut wb, &mut calc, "CZ3", "30");
|
||||
assert_eq!(num(&wb, "DA6"), 36.0);
|
||||
// Ranges on another sheet.
|
||||
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
set(&mut wb, &mut calc, "DA9", "=SUM(Data!A1:A3)+SUM(Data!A:CZ)");
|
||||
assert_eq!(num(&wb, "DA9"), 0.0);
|
||||
wb.sheet_mut(1).unwrap().cells.set(CellRef::parse("A2").unwrap(), Cell::value(Value::Number(4.0)));
|
||||
calc.cells_changed(&mut wb, &[(1, CellRef::parse("A2").unwrap())]);
|
||||
assert_eq!(num(&wb, "DA9"), 8.0);
|
||||
deps.clear();
|
||||
calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps);
|
||||
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,40 @@ impl T {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_range_operator() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "3");
|
||||
t.set("B1", "=SEQUENCE(A1)");
|
||||
t.set("C1", "=SUM(B1#)");
|
||||
t.set("C2", "=ROWS(B1#)");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0));
|
||||
// Growing and shrinking the spill recalculates its users.
|
||||
t.set("A1", "5");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0));
|
||||
t.set("A1", "2");
|
||||
assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0));
|
||||
// A blocked spill, or a cell that doesn't spill, gives #REF!.
|
||||
t.set("B2", "x");
|
||||
assert_eq!(t.get("C1"), Value::Error(CellError::Ref));
|
||||
t.set("B2", "");
|
||||
assert_eq!(t.num("C1"), 3.0);
|
||||
t.set("D1", "=SUM(A1#)");
|
||||
assert_eq!(t.get("D1"), Value::Error(CellError::Ref));
|
||||
// Through a defined name.
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Seq".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$B$1#".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.set("E1", "=SUM(Seq)*10");
|
||||
assert_eq!(t.num("E1"), 30.0);
|
||||
t.set("A1", "4");
|
||||
assert_eq!(t.num("E1"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_dependencies() {
|
||||
let mut t = T::new();
|
||||
@@ -105,6 +139,72 @@ fn operators() {
|
||||
assert_eq!(t.num("A7"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_spill_reads_as_spill_error() {
|
||||
// Calculated in the same pass, a formula over a blocked spill read the array that couldn't
|
||||
// spill.
|
||||
let mut t = T::new();
|
||||
let s = t.wb.sheet_mut(0).unwrap();
|
||||
for a in ["A1", "B1", "C1", "A2", "C2", "A3", "B3", "C3"] {
|
||||
s.cells.set(c(a), Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
s.cells.set(c("B2"), Cell::formula(Formula::new("=SEQUENCE(2,2,9)")));
|
||||
s.cells.set(c("E1"), Cell::formula(Formula::new("=SUM(A1:C3)")));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("B2"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("C3"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_spill_read_through_offset() {
|
||||
// The reader's reference is only known while evaluating, so it isn't in the dependency graph.
|
||||
let mut t = T::new();
|
||||
t.set("E4", "5");
|
||||
t.set("E8", "=SUM(OFFSET(E2,0,0,2,2))");
|
||||
t.set("E3", "=SEQUENCE(2,2,3)");
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("E3"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("E8"), Value::Error(CellError::Spill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_into_the_old_area_of_a_blocked_neighbour() {
|
||||
// C7's array becomes blocked by C8, whose array then takes over C7's old area.
|
||||
let mut t = T::new();
|
||||
t.set("C7", "=SEQUENCE(2,2)");
|
||||
t.set("C8", "=SEQUENCE(2,2)");
|
||||
assert_eq!(t.get("C7"), Value::Error(CellError::Spill));
|
||||
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_do_not_spill_in_tables() {
|
||||
// Excel: spilled array formulas aren't supported in tables.
|
||||
let mut t = T::new();
|
||||
t.wb.sheet_mut(0).unwrap().tables.push(gridcraft_model::Table {
|
||||
id: 1,
|
||||
name: "Table1".into(),
|
||||
range: gridcraft_core::RangeRef::parse("B3:C6").unwrap(),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: vec![],
|
||||
style: String::new(),
|
||||
banded_rows: false,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: false,
|
||||
});
|
||||
t.set("C4", "=SEQUENCE(2)");
|
||||
t.set("E1", "=SEQUENCE(4)");
|
||||
t.set("H1", "=SUM(C4:C5)");
|
||||
assert_eq!(t.get("C4"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("C5"), Value::Empty);
|
||||
assert_eq!(t.get("H1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("E4"), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ifs_and_lazy() {
|
||||
let mut t = T::new();
|
||||
@@ -158,6 +258,40 @@ fn spills() {
|
||||
assert_eq!(t.num("D2"), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_keep_their_size_past_the_used_range() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "5");
|
||||
t.set("A2", "6");
|
||||
t.set("C1", "=ROWS(A1:A10*1)");
|
||||
t.set("C2", "=SUMPRODUCT(--(A1:A10=\"\"))");
|
||||
t.set("C3", "=COUNTBLANK(A1:A10)");
|
||||
t.set("C4", "=COUNTIF(A1:A10,\"\")");
|
||||
t.set("C5", "=COLUMNS(TRANSPOSE(A1:A10))");
|
||||
for (at, want) in [("C1", 10.0), ("C2", 8.0), ("C3", 8.0), ("C4", 8.0), ("C5", 10.0)] {
|
||||
assert_eq!(t.num(at), want, "{at}");
|
||||
}
|
||||
// Whole columns are still read up to the used range.
|
||||
t.set("D1", "=ROWS(FILTER(A:A,A:A<>\"\"))");
|
||||
assert_eq!(t.num("D1"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spilled_blank_cells_show_zero() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "5");
|
||||
t.set("A3", "7");
|
||||
t.set("C1", "=A1:A4");
|
||||
assert_eq!(t.get("C2"), Value::Number(0.0));
|
||||
assert_eq!(t.get("C4"), Value::Number(0.0));
|
||||
t.set("D1", "=COUNT(C1:C4)");
|
||||
assert_eq!(t.num("D1"), 4.0);
|
||||
t.set("E1", "=A2:A3");
|
||||
assert_eq!(t.get("E1"), Value::Number(0.0));
|
||||
t.set("F1", "=SORT(A1:A3)");
|
||||
assert_eq!(t.get("F3"), Value::Number(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn let_lambda() {
|
||||
let mut t = T::new();
|
||||
@@ -185,6 +319,33 @@ fn names_and_sheets() {
|
||||
assert_eq!(t.num("A1"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_formulas_fill_their_range() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("A2", "2");
|
||||
let cse = |t: &mut T, at: &str, formula: &str, range: &str| {
|
||||
let mut f = Formula::new(formula);
|
||||
f.array = gridcraft_core::RangeRef::parse(range);
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(f));
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c(at))]);
|
||||
};
|
||||
// Past the result: #N/A. A single column repeats across the range.
|
||||
cse(&mut t, "C1", "=A1:A2*2", "C1:D3");
|
||||
cse(&mut t, "F1", "=A1:A2*2", "F1");
|
||||
cse(&mut t, "G1", "=5", "G1:G2");
|
||||
assert_eq!(t.num("C2"), 4.0);
|
||||
assert_eq!(t.num("D2"), 4.0);
|
||||
assert_eq!(t.get("C3"), Value::Error(CellError::NA));
|
||||
// A smaller range shows only part of the result and never spills.
|
||||
assert_eq!(t.num("F1"), 2.0);
|
||||
assert_eq!(t.get("F2"), Value::Empty);
|
||||
assert_eq!(t.num("G2"), 5.0);
|
||||
// A range too large to fill is an error, not an allocation of every cell.
|
||||
cse(&mut t, "H1", "=1", "H1:XFD1048576");
|
||||
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_function() {
|
||||
let mut t = T::new();
|
||||
@@ -215,3 +376,94 @@ fn recalc_all_after_load() {
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A3"), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_sum_range_takes_the_criteria_size() {
|
||||
// Excel adds up the range that starts at sum_range's top-left cell with the criteria range's
|
||||
// height and width, whatever size sum_range has.
|
||||
let mut t = T::new();
|
||||
for (r, (k, v)) in [("a", 1), ("b", 2), ("a", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", r + 1), k);
|
||||
t.set(&format!("B{}", r + 1), &v.to_string());
|
||||
}
|
||||
t.set("D1", "=SUMIF(A1:A3,\"a\",B1)");
|
||||
assert_eq!(t.num("D1"), 4.0);
|
||||
t.set("D2", "=AVERAGEIF(A1:A3,\"a\",B1)");
|
||||
assert_eq!(t.num("D2"), 2.0);
|
||||
t.set("D3", "=SUMIF(A1:A3,\"a\",B1:B2)");
|
||||
assert_eq!(t.num("D3"), 4.0);
|
||||
t.set("D4", "=SUMIF(A1:A3,\"a\",B1:F1)");
|
||||
assert_eq!(t.num("D4"), 4.0);
|
||||
t.set("D5", "=SUMIF(A1:A3,\"a\",B1:B99)");
|
||||
assert_eq!(t.num("D5"), 4.0);
|
||||
t.set("D6", "=SUMIF(A:A,\"a\",B1)");
|
||||
assert_eq!(t.num("D6"), 4.0);
|
||||
t.set("D7", "=SUMIF(A1:A3,\"a\",$B$2)");
|
||||
assert_eq!(t.num("D7"), 2.0); // B2:B4: rows 1 and 3 match → B2 + B4 (empty)
|
||||
t.set("D8", "=SUMIF(A1:A3,\"a\")");
|
||||
assert_eq!(t.num("D8"), 0.0);
|
||||
// A horizontal criteria range resizes across.
|
||||
t.set("H1", "1");
|
||||
t.set("I1", "0");
|
||||
t.set("J1", "1");
|
||||
t.set("H2", "10");
|
||||
t.set("I2", "20");
|
||||
t.set("J2", "30");
|
||||
t.set("D9", "=SUMIF(H1:J1,1,H2)");
|
||||
assert_eq!(t.num("D9"), 40.0);
|
||||
// The resized cells are precedents: editing one recalculates.
|
||||
t.set("B3", "5");
|
||||
assert_eq!(t.num("D1"), 6.0);
|
||||
assert_eq!(t.num("D2"), 3.0);
|
||||
assert_eq!(t.num("D3"), 6.0);
|
||||
assert_eq!(t.num("D4"), 6.0);
|
||||
assert_eq!(t.num("D6"), 6.0);
|
||||
// SUMIFS and AVERAGEIFS don't resize: different sizes are #VALUE!.
|
||||
t.set("D11", "=SUMIFS(B1,A1:A3,\"a\")");
|
||||
assert_eq!(t.get("D11"), Value::Error(CellError::Value));
|
||||
t.set("D12", "=AVERAGEIFS(B1:B2,A1:A3,\"a\")");
|
||||
assert_eq!(t.get("D12"), Value::Error(CellError::Value));
|
||||
t.set("D13", "=SUMIFS(B1:B3,A1:A3,\"a\")");
|
||||
assert_eq!(t.num("D13"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_resized_sum_range_stops_at_the_sheet_edge() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "a");
|
||||
t.set("A2", "a");
|
||||
t.set("B1048575", "5");
|
||||
t.set("B1048576", "7");
|
||||
t.set("C1", "=SUMIF(A1:A2,\"a\",B1048576)");
|
||||
assert_eq!(t.num("C1"), 7.0);
|
||||
t.set("C2", "=SUMIF(A1:A2,\"a\",B1048575)");
|
||||
assert_eq!(t.num("C2"), 12.0);
|
||||
t.set("C3", "=SUMIF(A1:B1,\"a\",XFD1)");
|
||||
assert_eq!(t.num("C3"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() {
|
||||
let mut t = T::new();
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Amounts".into(),
|
||||
scope: None,
|
||||
formula: "Data!$B$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
for (r, k) in ["a", "b", "a"].iter().enumerate() {
|
||||
t.set(&format!("A{}", r + 1), k);
|
||||
t.wb.sheet_mut(1).unwrap().set_value(CellRef::new(r as u32, 1), Value::Number((r + 1) as f64));
|
||||
}
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.set("C1", "=SUMIF(A1:A3,\"a\",Data!B1)");
|
||||
assert_eq!(t.num("C1"), 4.0);
|
||||
t.set("C2", "=AVERAGEIF(A1:A3,\"a\",Amounts)");
|
||||
assert_eq!(t.num("C2"), 2.0);
|
||||
t.wb.sheet_mut(1).unwrap().set_value(c("B3"), Value::Number(9.0));
|
||||
t.calc.cells_changed(&mut t.wb, &[(1, c("B3"))]);
|
||||
assert_eq!(t.num("C1"), 10.0);
|
||||
assert_eq!(t.num("C2"), 5.0);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ pub struct SeriesData {
|
||||
pub kind: ChartKind,
|
||||
/// Plotted against the secondary (right) value axis.
|
||||
pub secondary: bool,
|
||||
/// Draw line segments as a smoothed (spline) curve.
|
||||
pub smooth: bool,
|
||||
/// Number format code of the source values ("General" by default).
|
||||
pub number_format: String,
|
||||
}
|
||||
@@ -152,6 +154,7 @@ impl Default for SeriesData {
|
||||
color: [0x15, 0x60, 0x82, 0xFF],
|
||||
kind: ChartKind::ColumnClustered,
|
||||
secondary: false,
|
||||
smooth: false,
|
||||
number_format: "General".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ fn sanitize(d: &ChartData) -> ChartData {
|
||||
color: s.color,
|
||||
kind: s.kind,
|
||||
secondary: s.secondary,
|
||||
smooth: s.smooth,
|
||||
number_format: s.number_format.clone(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -545,8 +546,9 @@ fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Draws a polyline split at gaps (`None`), decimated when long.
|
||||
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
|
||||
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
|
||||
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
|
||||
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
|
||||
let mut run: Vec<[f32; 2]> = Vec::new();
|
||||
for p in pts.iter().chain(std::iter::once(&None)) {
|
||||
match p {
|
||||
@@ -554,6 +556,10 @@ fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
|
||||
None => {
|
||||
if run.len() >= 2 {
|
||||
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
|
||||
let r = match smooth {
|
||||
Some(plot) => smooth_run(r, plot),
|
||||
None => r,
|
||||
};
|
||||
ctx.line(r, color, width);
|
||||
} else {
|
||||
run.clear();
|
||||
@@ -563,6 +569,48 @@ fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
|
||||
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
|
||||
/// duplicated so the curve passes through the first and last points, and the curve is clamped
|
||||
/// to the `plot` box (a spline overshoots its data near sharp turns).
|
||||
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return pts;
|
||||
}
|
||||
let cap = MAX_LINE_PTS * 2;
|
||||
let steps = (cap / n).clamp(1, 100);
|
||||
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
|
||||
let at = |i: isize| -> [f32; 2] {
|
||||
let j = i.clamp(0, n as isize - 1) as usize;
|
||||
pts.get(j).copied().unwrap_or([0.0, 0.0])
|
||||
};
|
||||
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
|
||||
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
|
||||
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
|
||||
for s in 0..steps {
|
||||
let t = s as f32 / steps as f32;
|
||||
out.push(clip(catmull(p0, p1, p2, p3, t)));
|
||||
}
|
||||
}
|
||||
out.push(clip(at(n as isize - 1)));
|
||||
out
|
||||
}
|
||||
|
||||
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
|
||||
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
let mut o = [0.0f32; 2];
|
||||
for k in 0..2 {
|
||||
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
|
||||
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
|
||||
let mut seen = HashSet::new();
|
||||
for p in pts {
|
||||
@@ -836,7 +884,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
}
|
||||
_ => {
|
||||
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
|
||||
draw_runs(ctx, &pts, s.color, 2.25);
|
||||
draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));
|
||||
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
|
||||
if k == ChartKind::LineMarkers && cw >= 3.0 {
|
||||
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
|
||||
@@ -914,7 +962,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
} else {
|
||||
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
|
||||
if lines {
|
||||
draw_runs(ctx, &px, s.color, 2.0);
|
||||
draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot));
|
||||
}
|
||||
if !lines || px.len() <= 500 {
|
||||
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);
|
||||
@@ -1468,3 +1516,29 @@ pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod smooth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_runs_are_smoothed_uniformly_within_the_cap() {
|
||||
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
|
||||
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
|
||||
let out = smooth_run(pts, plot);
|
||||
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
|
||||
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
|
||||
assert_eq!(out.len(), 1499 * 2 + 1);
|
||||
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_overshoot_is_clamped_to_the_plot() {
|
||||
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
|
||||
// A step right at the plot edges: Catmull-Rom overshoots past both.
|
||||
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
|
||||
let out = smooth_run(pts, plot);
|
||||
assert!(out.len() > 5);
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,8 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
|
||||
let kind = effective_kind(chart.kind, s.kind, i);
|
||||
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
|
||||
(Some(f), true) => Some(nums(wb, sheet, f)),
|
||||
// X values with text in them (labels) are ignored: the points are numbered 1..n.
|
||||
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
|
||||
_ => None,
|
||||
};
|
||||
if categories.is_none()
|
||||
@@ -165,7 +166,17 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
series.push(SeriesData { name, values, x, sizes, color, kind, secondary: s.secondary, number_format: ref_format(wb, sheet, &s.values) });
|
||||
series.push(SeriesData {
|
||||
name,
|
||||
values,
|
||||
x,
|
||||
sizes,
|
||||
color,
|
||||
kind,
|
||||
secondary: s.secondary,
|
||||
smooth: s.smooth,
|
||||
number_format: ref_format(wb, sheet, &s.values),
|
||||
});
|
||||
}
|
||||
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
|
||||
|
||||
@@ -60,6 +60,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat
|
||||
color,
|
||||
kind,
|
||||
secondary: false,
|
||||
smooth: false,
|
||||
number_format: "$#,##0".into(),
|
||||
}
|
||||
}
|
||||
@@ -223,6 +224,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
},
|
||||
Series {
|
||||
name: None,
|
||||
@@ -232,6 +234,7 @@ fn resolve_from_workbook() {
|
||||
color: Some(Color::rgb(1, 2, 3)),
|
||||
secondary: true,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
},
|
||||
Series {
|
||||
name: Some("Literal".into()),
|
||||
@@ -241,6 +244,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
},
|
||||
];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
@@ -269,8 +273,26 @@ fn resolve_from_workbook() {
|
||||
// Combo kinds and scatter X values.
|
||||
let mut c = chart(ChartKind::Combo);
|
||||
c.series = vec![
|
||||
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: false, kind: None },
|
||||
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: true, kind: None },
|
||||
Series {
|
||||
name: None,
|
||||
categories: None,
|
||||
values: "Sales!B2:B4".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
},
|
||||
Series {
|
||||
name: None,
|
||||
categories: None,
|
||||
values: "Sales!B2:B4".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: true,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
},
|
||||
];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].kind, ChartKind::ColumnClustered);
|
||||
@@ -284,6 +306,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
|
||||
@@ -299,6 +322,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
}];
|
||||
let d = resolve(&wb, 7, &c);
|
||||
assert_eq!(d.series.len(), 1);
|
||||
@@ -314,6 +338,29 @@ fn palette_beyond_six_series() {
|
||||
assert_ne!(p[6], p[12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_with_text_x_values_numbers_the_points() {
|
||||
let mut wb = Workbook::new();
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
for (i, (label, v)) in [("North", 3.0), ("South", 5.0), ("East", 4.0)].iter().enumerate() {
|
||||
s.set_value(CellRef::new(i as u32, 0), Value::from(*label));
|
||||
s.set_value(CellRef::new(i as u32, 1), Value::Number(*v));
|
||||
s.set_value(CellRef::new(i as u32, 2), Value::Number(i as f64 * 10.0));
|
||||
}
|
||||
for kind in [ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble] {
|
||||
let mut c = chart(kind);
|
||||
c.series = vec![Series { categories: Some("Sheet1!$A$1:$A$3".into()), values: "Sheet1!$B$1:$B$3".into(), ..Default::default() }];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x, None, "{kind:?}");
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
|
||||
// Numeric X values are kept.
|
||||
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
|
||||
assert_eq!(resolve(&wb, 0, &c).series[0].x, Some(vec![Some(0.0), Some(10.0), Some(20.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
[img[i], img[i + 1], img[i + 2], img[i + 3]]
|
||||
@@ -450,8 +497,51 @@ fn thousands_of_points_are_decimated() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
||||
let m = ApproxMeasure;
|
||||
let cats = ["a", "b", "c", "d"].map(String::from).to_vec();
|
||||
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = smooth;
|
||||
let d = ChartData { categories: cats.clone(), series: vec![s] };
|
||||
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Line { pts, .. } => Some(pts.len()),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let straight = line_pts(false, &m);
|
||||
let curved = line_pts(true, &m);
|
||||
assert_eq!(straight, 4, "straight polyline keeps one point per datum");
|
||||
assert!(curved > straight, "smooth line interpolates extra points: {curved} vs {straight}");
|
||||
// Rendering either way must stay finite and inside the canvas.
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
let d = ChartData { categories: cats, series: vec![s] };
|
||||
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_line_splits_at_gaps() {
|
||||
let m = ApproxMeasure;
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
s.values[1] = None; // gap between two data points
|
||||
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
|
||||
// Two separate runs (before/after the gap) → at least two line primitives.
|
||||
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
|
||||
assert!(lines >= 2, "a gap must split the smoothed line into runs, got {lines}");
|
||||
assert_sane(&prims, 600.0, 400.0);
|
||||
}
|
||||
|
||||
/// Small deterministic PRNG (xorshift) for the fuzz test.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
@@ -491,6 +581,7 @@ fn fuzz_random_data_and_sizes() {
|
||||
color: [(r.next() % 256) as u8, 100, 50, 255],
|
||||
kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize],
|
||||
secondary: r.next().is_multiple_of(3),
|
||||
smooth: r.next().is_multiple_of(4),
|
||||
number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
+70
-14
@@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Days in a month of a date system's calendar: like [`days_in_month`], except that February
|
||||
/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29).
|
||||
pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
|
||||
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
|
||||
}
|
||||
|
||||
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
|
||||
/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31.
|
||||
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
|
||||
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
|
||||
/// `None` before the epoch or after 9999-12-31.
|
||||
pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
|
||||
// DATE(): years 0–1899 are added to 1900.
|
||||
let mut y = if (0..1900).contains(&year) { year + 1900 } else { year };
|
||||
let m0 = month - 1;
|
||||
y += m0.div_euclid(12);
|
||||
let y = if (0..1900).contains(&year) { year + 1900 } else { year };
|
||||
let m0 = month.checked_sub(1)?;
|
||||
let y = y.checked_add(m0.div_euclid(12))?;
|
||||
let m = m0.rem_euclid(12) + 1;
|
||||
if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 {
|
||||
return None;
|
||||
}
|
||||
let base = days_from_civil(y as i32, m as u32, 1) + day - 1;
|
||||
let serial = match sys {
|
||||
DateSystem::D1900 => {
|
||||
let s = base - UNIX_1899_12_30;
|
||||
// Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30.
|
||||
if s <= 60 { s - 1 } else { s }
|
||||
}
|
||||
DateSystem::D1904 => base - UNIX_1904_01_01,
|
||||
let first = days_from_civil(y as i32, m as u32, 1);
|
||||
// Serial of the first of the month; the day (and any overflow) counts on from there.
|
||||
let first = match sys {
|
||||
// Up to February 1900 the serial is one lower than the day count from 1899-12-30: the
|
||||
// fictitious 1900-02-29 (serial 60) comes before 1 March.
|
||||
DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2),
|
||||
DateSystem::D1904 => first - UNIX_1904_01_01,
|
||||
};
|
||||
let serial = first.checked_add(day)?.checked_sub(1)?;
|
||||
if !(0..=2_958_465).contains(&serial) {
|
||||
return None;
|
||||
}
|
||||
@@ -97,7 +105,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
|
||||
|
||||
/// Date/time from a serial. `None` for negative or too-large serials.
|
||||
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
|
||||
if !serial.is_finite() || serial < 0.0 || serial >= 2_958_466.0 {
|
||||
if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) {
|
||||
return None;
|
||||
}
|
||||
let mut day = serial.floor() as i64;
|
||||
@@ -168,10 +176,58 @@ mod tests {
|
||||
assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fictitious_1900_02_29() {
|
||||
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
|
||||
// through it.
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0));
|
||||
assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0));
|
||||
assert_eq!(serial_from_ymd(s, 1900, 1, -1), None);
|
||||
assert_eq!(serial_from_ymd(s, 1900, 0, 1), None);
|
||||
// Later years keep the real calendar.
|
||||
assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1));
|
||||
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
|
||||
// Every serial up to 1900-12-31 comes back from its own year, month and day.
|
||||
for serial in 0..=366 {
|
||||
let d = datetime_from_serial(s, serial as f64).unwrap();
|
||||
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
|
||||
}
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
assert_eq!(days_in_month_in(s, 1901, 2), 28);
|
||||
assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28);
|
||||
// The 1904 system has no fictitious day: 1904 is a real leap year.
|
||||
let s = DateSystem::D1904;
|
||||
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0));
|
||||
assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0));
|
||||
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_month_and_day() {
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None);
|
||||
assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None);
|
||||
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None);
|
||||
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None);
|
||||
assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let s = DateSystem::D1900;
|
||||
for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] {
|
||||
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
|
||||
let d = datetime_from_serial(s, serial).unwrap();
|
||||
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ pub mod value;
|
||||
|
||||
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
|
||||
pub use date::DateSystem;
|
||||
pub use value::{Array, CellError, Value, compare, compare_text, number_to_text, sort_compare};
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
|
||||
@@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
|
||||
if !(1..=12).contains(&m) || d < 1 {
|
||||
return None;
|
||||
}
|
||||
let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 };
|
||||
let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64;
|
||||
if d > max {
|
||||
return None;
|
||||
}
|
||||
@@ -275,6 +275,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
|
||||
|
||||
/// The year used for dates typed without one. Fixed per process from the system clock when
|
||||
/// available (wasm without clock falls back to 2026).
|
||||
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
|
||||
pub fn current_year() -> i64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -357,4 +358,19 @@ mod tests {
|
||||
assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75));
|
||||
assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fictitious_1900_02_29() {
|
||||
// Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61.
|
||||
assert_eq!(p("2/29/1900").value, Value::Number(60.0));
|
||||
assert_eq!(p("1900-02-29").value, Value::Number(60.0));
|
||||
assert_eq!(p("29-Feb-1900").value, Value::Number(60.0));
|
||||
assert_eq!(p("2/28/1900").value, Value::Number(59.0));
|
||||
assert_eq!(p("3/1/1900").value, Value::Number(61.0));
|
||||
assert_eq!(p("2/29/1901").value, Value::text("2/29/1901"));
|
||||
assert_eq!(p("2/30/1900").value, Value::text("2/30/1900"));
|
||||
// The 1904 system starts after it.
|
||||
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
|
||||
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,13 +288,13 @@ impl Value {
|
||||
pub fn compare(a: &Value, b: &Value) -> Ordering {
|
||||
match (a, b) {
|
||||
(Value::Empty, Value::Empty) => Ordering::Equal,
|
||||
(Value::Empty, Value::Number(n)) => 0.0f64.partial_cmp(n).unwrap_or(Ordering::Equal),
|
||||
(Value::Number(n), Value::Empty) => n.partial_cmp(&0.0).unwrap_or(Ordering::Equal),
|
||||
(Value::Empty, Value::Number(n)) => compare_numbers(0.0, *n),
|
||||
(Value::Number(n), Value::Empty) => compare_numbers(*n, 0.0),
|
||||
(Value::Empty, Value::Text(t)) => "".cmp(&t.to_lowercase() as &str),
|
||||
(Value::Text(t), Value::Empty) => (t.to_lowercase() as String).as_str().cmp(""),
|
||||
(Value::Empty, Value::Bool(b)) => false.cmp(b),
|
||||
(Value::Bool(b), Value::Empty) => b.cmp(&false),
|
||||
(Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
|
||||
(Value::Number(x), Value::Number(y)) => compare_numbers(*x, *y),
|
||||
(Value::Text(x), Value::Text(y)) => compare_text(x, y),
|
||||
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
|
||||
(Value::Error(x), Value::Error(y)) => x.code().cmp(&y.code()),
|
||||
@@ -302,6 +302,23 @@ pub fn compare(a: &Value, b: &Value) -> Ordering {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares numbers the way Excel does: both are rounded to 15 significant digits first, so binary
|
||||
/// noise such as `0.1+0.2` versus `0.3` counts as equal.
|
||||
pub fn compare_numbers(a: f64, b: f64) -> Ordering {
|
||||
fn round15(x: f64) -> f64 {
|
||||
if !x.is_finite() || x == 0.0 { x } else { format!("{x:.14e}").parse().unwrap_or(x) }
|
||||
}
|
||||
if a == b {
|
||||
return Ordering::Equal;
|
||||
}
|
||||
// Numbers further apart than the 15th digit can't round together, and rounding keeps their
|
||||
// order: skip the text round trip (this is on the sort and lookup hot path).
|
||||
if (a - b).abs() > a.abs().max(b.abs()) * 1e-13 {
|
||||
return a.partial_cmp(&b).unwrap_or(Ordering::Equal);
|
||||
}
|
||||
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
|
||||
/// Case-insensitive text order (Unicode lowercase).
|
||||
pub fn compare_text(a: &str, b: &str) -> Ordering {
|
||||
let mut ai = a.chars().flat_map(char::to_lowercase);
|
||||
@@ -410,6 +427,17 @@ mod tests {
|
||||
assert_eq!(CellError::parse("#div/0!"), Some(CellError::Div0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_compare_at_15_significant_digits() {
|
||||
let num = Value::Number;
|
||||
assert_eq!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Equal);
|
||||
assert_eq!(compare(&num(1.1 * 3.0), &num(3.3)), Ordering::Equal);
|
||||
assert_eq!(compare(&num(1.0), &num(1.0 + 1e-16)), Ordering::Equal);
|
||||
assert_ne!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Greater);
|
||||
assert_eq!(compare(&num(1.0), &num(1.00000000000001)), Ordering::Less);
|
||||
assert_eq!(compare(&num(1.0), &num(2.0)), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays() {
|
||||
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
|
||||
|
||||
@@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Performance scenarios on large workbooks (release build):
|
||||
//! `cargo run --release -p gridcraft-engine --example perf -- [rows]`
|
||||
|
||||
#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Excel's ribbon and menu tree (feature names only), used as the parity metric: a catalog
|
||||
//! entry counts as done when a command with its id is registered. `cargo xtask parity` writes
|
||||
//! `docs/parity.md` from this list.
|
||||
//! `docs/parity-checklist.md` from this list.
|
||||
|
||||
/// (id, tab, group, label)
|
||||
pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
|
||||
+172
-20
@@ -112,7 +112,8 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
}
|
||||
let sel = d.selection.current();
|
||||
if !sel.is_single() {
|
||||
let used = sh.used_range().unwrap_or(sel);
|
||||
// An empty sheet has no data to work on: just the active cell.
|
||||
let Some(used) = sh.used_range() else { return Ok(RangeRef::cell(d.selection.active)) };
|
||||
return Ok(sel.intersection(&used).unwrap_or(sel));
|
||||
}
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
@@ -265,6 +266,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
// Move whole lines (cells with formulas adjusted for relative references).
|
||||
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
|
||||
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
|
||||
let mut pictures = Vec::new();
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
for &k in &lanes {
|
||||
let src = line(src_i, k);
|
||||
@@ -277,21 +279,52 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);
|
||||
c.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
}
|
||||
if let Some(picture) = sh.cell_pictures.get(&src) {
|
||||
pictures.push((dst, picture.clone()));
|
||||
}
|
||||
moved.push((dst, cell));
|
||||
}
|
||||
}
|
||||
// Row heights travel with rows.
|
||||
let heights: Vec<Option<LineInfo>> =
|
||||
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
|
||||
// Floating objects anchored inside the sorted body travel with their lines, unless they
|
||||
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
|
||||
// is the inverse of `order`: `dst_of[src] = dst`.
|
||||
let dst_of: Vec<u32> = {
|
||||
let mut v = vec![0u32; order.len()];
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
if let Some(slot) = v.get_mut(src_i as usize) {
|
||||
*slot = dst_i as u32;
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
|
||||
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
|
||||
// can be re-fitted to the lines it spans after the sort.
|
||||
let objs: Vec<Anchor> =
|
||||
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
|
||||
let descs: Vec<(u32, u32, f32, f32)> = objs
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
|
||||
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
|
||||
let tc = sh.col_at(x2);
|
||||
let tr = sh.row_at(y2);
|
||||
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
|
||||
})
|
||||
.collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
for (c, cell) in &moved {
|
||||
match cell {
|
||||
Some(x) => shm.cells.set(*c, x.clone()),
|
||||
Some(x) => shm.set_cell(*c, x.clone()),
|
||||
None => {
|
||||
shm.cells.remove(*c);
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (i, h) in heights.into_iter().enumerate() {
|
||||
let row = body.start.row + i as u32;
|
||||
match h {
|
||||
@@ -305,6 +338,34 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
|
||||
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
|
||||
// Only objects anchored inside the sorted block move: within its lines on the sort axis
|
||||
// and within its span on the other axis (a picture beside the block stays put).
|
||||
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
|
||||
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
|
||||
let ni = shm.images.len();
|
||||
let nch = shm.charts.len();
|
||||
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 {
|
||||
continue;
|
||||
}
|
||||
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
|
||||
if na != a {
|
||||
if i < ni {
|
||||
if let Some(o) = shm.images.get_mut(i) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if i < ni + nch {
|
||||
if let Some(o) = shm.charts.get_mut(i - ni) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
|
||||
o.anchor = na;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, _) in moved {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
@@ -314,6 +375,35 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
Ok(json!({"sorted": body.a1()}))
|
||||
}
|
||||
|
||||
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
|
||||
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
|
||||
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
|
||||
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
|
||||
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
|
||||
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
|
||||
if a.mode == AnchorMode::Absolute {
|
||||
return a;
|
||||
}
|
||||
let (tc, tr, tdx, tdy) = bottom;
|
||||
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
|
||||
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
|
||||
if axis_start < b0 || axis_start > b1 {
|
||||
return a;
|
||||
}
|
||||
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
|
||||
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
|
||||
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
|
||||
let x1 = sh.col_left(nc) + a.dx as f64;
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let x2 = sh.col_left(nbc) + tdx as f64;
|
||||
let y2 = sh.row_top(nbr) + tdy as f64;
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
|
||||
} else {
|
||||
Anchor { cell: CellRef::new(nr, nc), ..a }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -579,6 +669,9 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
// Rows and columns past the data are blank: only the part with data is compared (whole
|
||||
// columns compared a million blank rows and reported them as removed duplicates).
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
|
||||
let cols: Vec<u32> = p
|
||||
.get("columns")
|
||||
@@ -600,21 +693,30 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
let pictures: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<Vec<Option<Cell>>> =
|
||||
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
shm.cells.remove(CellRef::new(row, c));
|
||||
shm.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
for (i, cells) in rows.into_iter().enumerate() {
|
||||
for (j, cell) in cells.into_iter().enumerate() {
|
||||
if let Some(cell) = cell {
|
||||
shm.cells.set(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
|
||||
shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
@@ -662,13 +764,12 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = super::edit::input_to_cell(&t, old.as_ref(), &mut cx.wb).unwrap_or(None);
|
||||
let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.cells.set(c, x),
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
None => {
|
||||
shm.cells.remove(c);
|
||||
shm.remove_cell(c);
|
||||
}
|
||||
}
|
||||
cx.changed.push((sheet, c));
|
||||
@@ -683,9 +784,15 @@ fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char)
|
||||
let mut cur = String::new();
|
||||
let mut in_q = false;
|
||||
let mut last_delim = false;
|
||||
for c in text.chars() {
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == quote {
|
||||
in_q = !in_q;
|
||||
if in_q && chars.peek() == Some("e) {
|
||||
cur.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = !in_q;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !in_q && delims.contains(&c) {
|
||||
@@ -718,16 +825,42 @@ fn op_param(s: Option<&str>) -> CfOperator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` minus `cut`, as up to four non-overlapping rectangles.
|
||||
fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
let Some(i) = r.intersection(cut) else { return vec![r] };
|
||||
let mut out = Vec::new();
|
||||
if i.start.row > r.start.row {
|
||||
out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col)));
|
||||
}
|
||||
if i.end.row < r.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end));
|
||||
}
|
||||
if i.start.col > r.start.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1)));
|
||||
}
|
||||
if i.end.col < r.end.col {
|
||||
out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
}
|
||||
}
|
||||
validations.retain(|d| !d.ranges.is_empty());
|
||||
}
|
||||
|
||||
fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if bool_param(p, "clear").unwrap_or(false) {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges.retain(|r| !ranges.iter().any(|x| x.contains_range(r)));
|
||||
}
|
||||
sh.validations.retain(|d| !d.ranges.is_empty());
|
||||
remove_validation_cells(&mut sh.validations, &ranges);
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
@@ -767,10 +900,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for v in sh.validations.iter_mut() {
|
||||
v.ranges.retain(|r| !ranges.iter().any(|x| x.contains_range(r)));
|
||||
}
|
||||
sh.validations.retain(|d| !d.ranges.is_empty());
|
||||
remove_validation_cells(&mut sh.validations, &ranges);
|
||||
sh.validations.push(dv.clone());
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -868,7 +998,7 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u));
|
||||
let Some(r) = r else { continue };
|
||||
for c in r.iter().take(100_000) {
|
||||
let text = sh.cell(c).map(|x| x.input_text()).unwrap_or_default();
|
||||
let text = sh.input_text(c);
|
||||
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
|
||||
bad_cells.push(c.a1());
|
||||
}
|
||||
@@ -1006,6 +1136,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi});
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| {
|
||||
s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true)
|
||||
};
|
||||
s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap();
|
||||
s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap();
|
||||
assert!(ok(&mut s, "B2", "999"));
|
||||
assert!(!ok(&mut s, "B1", "999"));
|
||||
assert!(!ok(&mut s, "B3", "999"));
|
||||
s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap();
|
||||
for cell in ["B2", "B3", "B4"] {
|
||||
assert!(ok(&mut s, cell, "150"), "{cell}");
|
||||
assert!(!ok(&mut s, cell, "5"), "{cell}");
|
||||
}
|
||||
assert!(ok(&mut s, "B1", "5"));
|
||||
assert!(!ok(&mut s, "B1", "150"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
|
||||
|
||||
@@ -82,7 +82,15 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Ink,
|
||||
anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 },
|
||||
anchor: Anchor {
|
||||
cell,
|
||||
dx,
|
||||
dy,
|
||||
width: (maxx - minx).max(1.0) as f32,
|
||||
height: (maxy - miny).max(1.0) as f32,
|
||||
// Ink is a freehand mark: pinned where it was drawn, it does not follow cells.
|
||||
mode: gridcraft_model::AnchorMode::Absolute,
|
||||
},
|
||||
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
|
||||
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
|
||||
text,
|
||||
|
||||
+168
-52
@@ -40,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy),
|
||||
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy),
|
||||
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
@@ -119,9 +119,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
|
||||
/// format). Errors when the formula can't be parsed.
|
||||
pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = old.map(|c| c.style).unwrap_or_default();
|
||||
/// format). Inherits the destination's cell, row or column style, including its number
|
||||
/// format, before parsing. Errors when the formula can't be parsed.
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
if input.is_empty() {
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
return Ok(if c.is_blank() { None } else { Some(c) });
|
||||
@@ -148,6 +149,7 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_
|
||||
parsed = gridcraft_formula::parse(&text);
|
||||
}
|
||||
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
|
||||
let expr = wb.name_call_case(expr);
|
||||
let mut cell = Cell::formula(Formula::from_expr(expr));
|
||||
cell.style = style;
|
||||
// Formulas whose result is a date/time get a format from functions like TODAY().
|
||||
@@ -206,23 +208,28 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if array {
|
||||
// Legacy Ctrl+Shift+Enter array formula over the selection.
|
||||
let range = cx.sel.current();
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned();
|
||||
let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default();
|
||||
let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default();
|
||||
if let Some(f) = cell.formula.as_mut() {
|
||||
std::sync::Arc::make_mut(f).array = Some(range);
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.set(range.start, cell);
|
||||
// The array takes the whole range: other cells in it lose their contents.
|
||||
for c in range.iter().skip(1).take(1_000_000) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
|
||||
let style = old.style;
|
||||
sh.set_cell(c, Cell { style, ..Cell::default() });
|
||||
}
|
||||
}
|
||||
sh.set_cell(range.start, cell);
|
||||
cx.touch(sheet, range.start);
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
|
||||
let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.cells.set(at, c),
|
||||
Some(c) => sh.set_cell(at, c),
|
||||
None => {
|
||||
sh.cells.remove(at);
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
cx.touch(sheet, at);
|
||||
@@ -265,21 +272,22 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (ci, v) in cols.iter().enumerate() {
|
||||
let Some(at) = start.offset(ri as i64, ci as i64) else { continue };
|
||||
let input = json_to_input(v);
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned();
|
||||
let cell = match v {
|
||||
Json::Number(n) => Some(Cell {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: old.as_ref().map(|c| c.style).unwrap_or_default(),
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }),
|
||||
_ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?,
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.cells.set(at, c),
|
||||
Some(c) => sh.set_cell(at, c),
|
||||
None => {
|
||||
sh.cells.remove(at);
|
||||
sh.remove_cell(at);
|
||||
}
|
||||
}
|
||||
cx.touch(sheet, at);
|
||||
@@ -297,8 +305,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let input = str_param(p, "input").unwrap_or("").to_string();
|
||||
let origin = s.doc()?.selection.active;
|
||||
edit(s, |cx| {
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned();
|
||||
let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
|
||||
for r in &ranges {
|
||||
if r.count() > 2_000_000 {
|
||||
return Err(bad("range.fill", "range too large"));
|
||||
@@ -312,7 +319,7 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
|
||||
}
|
||||
cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
|
||||
cx.sheet_mut(sheet)?.cells.set(at, cell);
|
||||
cx.sheet_mut(sheet)?.set_cell(at, cell);
|
||||
cx.touch(sheet, at);
|
||||
}
|
||||
}
|
||||
@@ -478,7 +485,8 @@ fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(n) = d.wb.name(reference, sheet) {
|
||||
n.formula.clone()
|
||||
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.range.a1()).unwrap_or_default();
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
|
||||
} else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
@@ -611,9 +619,16 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
range
|
||||
};
|
||||
let text = crate::display::range_text(&d.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() };
|
||||
s.clipboard = Some(clip);
|
||||
Ok(json!({"range": range.a1(), "text": text}))
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
result["html"] = Json::String(html);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -665,13 +680,12 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
|
||||
let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(c, cell),
|
||||
Some(cell) => sh.set_cell(c, cell),
|
||||
None => {
|
||||
sh.cells.remove(c);
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
}
|
||||
cx.touch(sheet, c);
|
||||
@@ -784,9 +798,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone()))
|
||||
};
|
||||
let mut moved_formulas = Vec::new();
|
||||
let mut moved_pictures = std::collections::BTreeMap::new();
|
||||
if cut_move {
|
||||
// Remove the source first (contents and formats), and fix references to it.
|
||||
let taken = cx.sheet_mut(clip.sheet)?.cells.take_range(src);
|
||||
let source = cx.sheet_mut(clip.sheet)?;
|
||||
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
|
||||
let taken = source.take_cells(src);
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
@@ -805,16 +822,34 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
src_sheet.cell(sc).cloned()
|
||||
};
|
||||
let src_val = src_sheet.value(sc);
|
||||
if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
||||
let mut new_picture = old_picture.clone();
|
||||
let mut new = old.clone();
|
||||
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
if cut_move {
|
||||
return Some(std::sync::Arc::new(Formula::from_expr(e)));
|
||||
// A moved formula still reads cells on its original sheet. Qualify
|
||||
// those references before the workbook-wide move adjustment below.
|
||||
let e = if dest_sheet != clip.sheet {
|
||||
e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
moved.array = f.array;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved =
|
||||
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
@@ -828,10 +863,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
@@ -843,6 +880,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
@@ -851,6 +889,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
@@ -866,10 +905,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
}
|
||||
let a = old.value.clone();
|
||||
let b = new.value.clone();
|
||||
// Numbers stored as text take part (multiplying by 1 turns them into
|
||||
// numbers); other text and errors stay as they are.
|
||||
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
|
||||
&& (a.is_number() || a.is_empty())
|
||||
&& (a.is_number() || a.is_empty() || a.is_text())
|
||||
&& (b.is_number() || b.is_empty())
|
||||
{
|
||||
new.value = match op.as_str() {
|
||||
@@ -883,8 +928,26 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Value::number(x / y)
|
||||
}
|
||||
}
|
||||
_ => b,
|
||||
_ => b.clone(),
|
||||
};
|
||||
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
|
||||
new.value = a;
|
||||
}
|
||||
// A formula already there is kept and combined with the pasted number.
|
||||
if let Some(f) = &old.formula
|
||||
&& let Ok(y) = b.to_number()
|
||||
&& (b.is_number() || b.is_empty())
|
||||
&& let Some(sym) = match op.as_str() {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -903,9 +966,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.cells.set(dest, new);
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.cells.set(dest, new),
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
}
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
@@ -929,11 +995,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if cut_move {
|
||||
// References elsewhere to the moved block now point to its new place.
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
if dest_sheet == clip.sheet {
|
||||
let e = gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col };
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
|
||||
}
|
||||
let _ = dest_name;
|
||||
let e = if dest_sheet == clip.sheet {
|
||||
gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
|
||||
cx.structural = true;
|
||||
}
|
||||
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
|
||||
@@ -946,7 +1013,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"pasted": s.doc()?.selection.a1()}))
|
||||
}
|
||||
|
||||
/// Applies a reference adjustment to every formula in the workbook (and names, CF, DV).
|
||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
@@ -998,12 +1065,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
"all" => {
|
||||
sh.cells.remove(c);
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
"contents" => {
|
||||
cell.value = Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.cells.set(c, cell);
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
"formats" => {
|
||||
cell.style = StyleId::DEFAULT;
|
||||
@@ -1034,6 +1101,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let mut r = target_range(s, p)?;
|
||||
// Whole rows or columns fill only where the sheet has content (nothing on an empty sheet).
|
||||
if r.is_full_cols() || r.is_full_rows() {
|
||||
match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) {
|
||||
Some(u) => r = u,
|
||||
None => return Ok(Json::Null),
|
||||
}
|
||||
}
|
||||
if r.count() > 5_000_000 {
|
||||
return Err(bad("edit.fill", "range too large"));
|
||||
}
|
||||
// With one row selected, Fill Down copies from the row above (Excel behaviour).
|
||||
if dr == 1 && r.height() == 1 {
|
||||
r.start.row = r.start.row.saturating_sub(1);
|
||||
@@ -1050,8 +1127,6 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
|
||||
};
|
||||
let used = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range());
|
||||
let r = if r.is_full_cols() || r.is_full_rows() { used.and_then(|u| r.intersection(&u)).unwrap_or(r) } else { r };
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
|
||||
Ok(Json::Null)
|
||||
@@ -1068,8 +1143,8 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > 5_000_000 {
|
||||
return Err(bad("edit.autoFill", "target too large"));
|
||||
if target.count() > 5_000_000 || src.count() > 5_000_000 {
|
||||
return Err(bad("edit.autoFill", "range too large"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, target, mode)?;
|
||||
@@ -1175,7 +1250,11 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| {
|
||||
let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() };
|
||||
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
|
||||
crate::display::cell_text(&d.wb, sh, c)
|
||||
} else {
|
||||
cell.input_text()
|
||||
};
|
||||
matches(&text, &what, case, whole).then_some((c, text))
|
||||
})
|
||||
.collect();
|
||||
@@ -1219,7 +1298,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut n = 0;
|
||||
for si in sheets {
|
||||
let cells: Vec<(CellRef, String)> =
|
||||
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default();
|
||||
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
|
||||
for (c, text) in cells {
|
||||
if !all && c != active {
|
||||
continue;
|
||||
@@ -1234,13 +1313,12 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned();
|
||||
let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(c, cell),
|
||||
Some(cell) => sh.set_cell(c, cell),
|
||||
None => {
|
||||
sh.cells.remove(c);
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
}
|
||||
cx.touch(si, c);
|
||||
@@ -1310,6 +1388,22 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C2:C4"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "C3")["value"], json!(4.0));
|
||||
// Past the result Excel shows #N/A; the old content of the range is replaced.
|
||||
assert_eq!(get(&mut s, "C4")["text"], "#N/A");
|
||||
let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text());
|
||||
assert_eq!(bar.as_deref(), Some("{=B2:B3*2}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcards() {
|
||||
assert!(wildcard("hello", "h*o"));
|
||||
@@ -1327,6 +1421,28 @@ mod tests {
|
||||
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_operations_keep_text_errors_and_formulas() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}),
|
||||
)
|
||||
.unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], "123");
|
||||
s.execute("edit.copy", json!({"range": "A2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:E1"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(123.0));
|
||||
assert_eq!(get(&mut s, "B1")["value"], "Total");
|
||||
assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A");
|
||||
assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1");
|
||||
assert_eq!(get(&mut s, "D1")["value"], json!(8.0));
|
||||
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles, on?: bool, position?: bottom|top|left|right}",
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
@@ -291,6 +291,11 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
}
|
||||
"smooth" => {
|
||||
for s in &mut c.series {
|
||||
s.smooth = on;
|
||||
}
|
||||
}
|
||||
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
|
||||
}
|
||||
Ok(())
|
||||
@@ -468,13 +473,13 @@ fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut v = Vec::new();
|
||||
for c in &sh.shapes {
|
||||
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1()}));
|
||||
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
for c in &sh.images {
|
||||
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1()}));
|
||||
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
for c in &sh.charts {
|
||||
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1()}));
|
||||
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
@@ -840,7 +845,7 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let v = sh.value(c);
|
||||
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
|
||||
sh.cells.set(c, Cell { value, formula: None, style: new });
|
||||
sh.set_cell(c, Cell { value, formula: None, style: new });
|
||||
cx.touch(si, c);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::DocState;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||
@@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return ok();
|
||||
};
|
||||
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
|
||||
let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string();
|
||||
let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true);
|
||||
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
|
||||
let title = if imported {
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||
} else {
|
||||
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||
};
|
||||
// An import has no save target: ordinary Save must ask for a new destination.
|
||||
let path = str_param(p, "path").filter(|_| !imported).map(str::to_string);
|
||||
// Replace an untouched blank Book1 like Excel does.
|
||||
if s.documents().len() == 1
|
||||
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
|
||||
@@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
s.close_document(0);
|
||||
}
|
||||
let i = s.add_document(DocState::new(wb, path, title));
|
||||
if !warnings.is_empty() {
|
||||
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
|
||||
if warnings.len() > 5 {
|
||||
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
|
||||
}
|
||||
s.ui_requests.push(crate::UiRequest::Message(message));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
|
||||
|
||||
@@ -365,6 +365,9 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let base = border_style(str_param(p, "style").unwrap_or("thin"));
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
if r.count() > 2_000_000 {
|
||||
@@ -453,7 +456,29 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
});
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
// Clear the neighbour's facing edge when removing borders, so it really goes.
|
||||
if preset == "none" {
|
||||
// A shared line can be stored on either cell. Selected cells are handled
|
||||
// by this loop; remove only facing edges outside this range.
|
||||
for (boundary, dr, dc) in [(top, -1, 0), (bottom, 1, 0), (left, 0, -1), (right, 0, 1)] {
|
||||
if !boundary {
|
||||
continue;
|
||||
}
|
||||
let Some(neighbor) = c.offset(dr, dc) else { continue };
|
||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(neighbor)).unwrap_or_default();
|
||||
let new = cx.wb.styles.derive(st, |s| {
|
||||
let edge = match (dr, dc) {
|
||||
(-1, _) => &mut s.borders.bottom,
|
||||
(1, _) => &mut s.borders.top,
|
||||
(_, -1) => &mut s.borders.right,
|
||||
_ => &mut s.borders.left,
|
||||
};
|
||||
*edge = BorderLine::default();
|
||||
});
|
||||
if new != st {
|
||||
cx.sheet_mut(sheet)?.set_style(neighbor, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
@@ -560,7 +585,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.cells.set(c, cell);
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?}", has_doc, delete_name),
|
||||
cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name),
|
||||
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
|
||||
cmd!(
|
||||
"formulas.createFromSelection",
|
||||
"Create from Selection",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("Cmd+Shift+F3"),
|
||||
"{range?, top?: true, left?: false, bottom?, right?}",
|
||||
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}",
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
@@ -218,15 +218,26 @@ fn valid_name(n: &str) -> bool {
|
||||
if !(first.is_alphabetic() || first == '_' || first == '\\') {
|
||||
return false;
|
||||
}
|
||||
if n.len() > 255 || n.eq_ignore_ascii_case("R") || n.eq_ignore_ascii_case("C") {
|
||||
return false;
|
||||
}
|
||||
if CellRef::parse(n).is_some() {
|
||||
if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
|
||||
return false;
|
||||
}
|
||||
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
|
||||
}
|
||||
|
||||
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
|
||||
/// accept as a name.
|
||||
fn is_r1c1(n: &str) -> bool {
|
||||
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
|
||||
let mut rest = n;
|
||||
if let Some(r) = rest.strip_prefix(['R', 'r']) {
|
||||
rest = r.get(digits(r)..).unwrap_or("");
|
||||
}
|
||||
if let Some(c) = rest.strip_prefix(['C', 'c']) {
|
||||
rest = c.get(digits(c)..).unwrap_or("");
|
||||
}
|
||||
rest.is_empty() && !n.is_empty()
|
||||
}
|
||||
|
||||
fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(name) = str_param(p, "name").map(str::to_string) else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({})));
|
||||
@@ -264,9 +275,14 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
let scope = match str_param(p, "scope") {
|
||||
None => None,
|
||||
Some("Workbook") => Some(None),
|
||||
Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let before = cx.wb.names.len();
|
||||
cx.wb.names.retain(|n| !n.name.eq_ignore_ascii_case(&name));
|
||||
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc)));
|
||||
if cx.wb.names.len() == before {
|
||||
return Err(bad("formulas.deleteName", "no such name"));
|
||||
}
|
||||
@@ -295,6 +311,9 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = target_range(s, p)?;
|
||||
let top = bool_param(p, "top").unwrap_or(true);
|
||||
let left = bool_param(p, "left").unwrap_or(false);
|
||||
let bottom = bool_param(p, "bottom").unwrap_or(false);
|
||||
let right = bool_param(p, "right").unwrap_or(false);
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let q = gridcraft_formula::quote_sheet(&sh.name);
|
||||
@@ -309,28 +328,38 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let clean = |t: String| -> String {
|
||||
let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect();
|
||||
if n.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&n).is_some() {
|
||||
if !n.is_empty() && !valid_name(&n) {
|
||||
n.insert(0, '_');
|
||||
}
|
||||
n
|
||||
if valid_name(&n) { n } else { String::new() }
|
||||
};
|
||||
// The labels in the chosen edge rows/columns name the cells between them.
|
||||
let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32));
|
||||
let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32));
|
||||
let mut made = Vec::new();
|
||||
if top && r.height() > 1 {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let name = clean(sh.value(CellRef::new(r.start.row, c)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(r.start.row + 1, c), CellRef::new(r.end.row, c)))));
|
||||
if r0 <= r1 && c0 <= c1 {
|
||||
for (on, row) in [(top, r.start.row), (bottom, r.end.row)] {
|
||||
for c in (c0..=c1).filter(|_| on) {
|
||||
let name = clean(sh.value(CellRef::new(row, c)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if left && r.width() > 1 {
|
||||
for row in r.start.row + top as u32..=r.end.row {
|
||||
let name = clean(sh.value(CellRef::new(row, r.start.col)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(row, r.start.col + 1), CellRef::new(row, r.end.col)))));
|
||||
for (on, col) in [(left, r.start.col), (right, r.end.col)] {
|
||||
for row in (r0..=r1).filter(|_| on) {
|
||||
let name = clean(sh.value(CellRef::new(row, col)).display());
|
||||
if !name.is_empty() {
|
||||
made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
|
||||
// reported.
|
||||
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
|
||||
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n));
|
||||
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
|
||||
let n = made.len();
|
||||
edit(s, |cx| {
|
||||
for (name, f) in made {
|
||||
@@ -338,7 +367,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false });
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(json!({"created": n}))
|
||||
Ok(json!({"created": n, "skipped": skipped}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+189
-15
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Picture",
|
||||
["Insert", "Illustrations"],
|
||||
None,
|
||||
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?}",
|
||||
"{path? | base64?: \"...\", mime?, at?: \"B2\", width?, height?, alt?, mode?: moveOnly|moveAndSize|absolute, placement?: cell|overCells}",
|
||||
has_doc,
|
||||
insert_picture
|
||||
),
|
||||
@@ -122,8 +122,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s,
|
||||
&with(p, "kind", json!("textBox"))
|
||||
)),
|
||||
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
|
||||
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
|
||||
cmd!(
|
||||
"object.setAnchorMode",
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
has_doc,
|
||||
set_anchor_mode
|
||||
),
|
||||
cmd!(
|
||||
"insert.link",
|
||||
"Link",
|
||||
@@ -220,6 +230,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
||||
.into_iter()
|
||||
.filter_map(|(c, v)| {
|
||||
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
|
||||
dest.map(|c| (c, v))
|
||||
})
|
||||
.collect();
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
}
|
||||
@@ -228,17 +245,24 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
// Lower-case names taken, and the next suffix for each header, so that thousands of equal
|
||||
// headers don't take cubic time.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut next_suffix = std::collections::HashMap::new();
|
||||
for c in r.start.col..=r.end.col {
|
||||
let mut n = sh.value(CellRef::new(r.start.row, c)).display();
|
||||
if n.is_empty() {
|
||||
n = format!("Column{}", c - r.start.col + 1);
|
||||
}
|
||||
let base = n.clone();
|
||||
let mut k = 2;
|
||||
while names.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
|
||||
let key = base.to_ascii_lowercase();
|
||||
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
|
||||
while taken.contains(&n.to_ascii_lowercase()) {
|
||||
n = format!("{base}{k}");
|
||||
k += 1;
|
||||
}
|
||||
next_suffix.insert(key, k);
|
||||
taken.insert(n.to_ascii_lowercase());
|
||||
// Headers are text.
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str()));
|
||||
names.push(n);
|
||||
@@ -304,7 +328,7 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
t.range.end.row = t.range.end.row.saturating_sub(1);
|
||||
let (c0, c1) = (t.range.start.col, t.range.end.col);
|
||||
for c in c0..=c1 {
|
||||
sh.cells.remove(CellRef::new(row, c));
|
||||
sh.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
write_totals(sh, ti);
|
||||
@@ -333,12 +357,11 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
|
||||
let c = CellRef::new(row, t.range.start.col + i as u32);
|
||||
if let Some(code) = col.totals.subtotal_code() {
|
||||
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
|
||||
sh.cells
|
||||
.set(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
} else if let Some(l) = &col.totals_label {
|
||||
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
|
||||
} else {
|
||||
sh.cells.remove(c);
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +531,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -521,15 +545,19 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > 1_000_000 {
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
@@ -566,6 +594,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.0) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
title,
|
||||
series,
|
||||
@@ -654,14 +683,25 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) };
|
||||
let Some(src) = c.source.clone() else { return Ok(Json::Null) };
|
||||
// Charts read from a file have no source range: take the block their series span.
|
||||
let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) };
|
||||
let (sheet_name, body) = split_sheet(&src);
|
||||
let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si);
|
||||
let Some(r) = RangeRef::parse(body) else { return Ok(Json::Null) };
|
||||
let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) };
|
||||
let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) };
|
||||
let by_rows = !c.by_rows;
|
||||
// Whole-column/row references (common in files) span a million cells: clip the block's
|
||||
// far edges to the cells in use, then refuse anything still too large to chart.
|
||||
if let Some(used) = ssh.used_range() {
|
||||
r.end.row = r.end.row.min(used.end.row).max(r.start.row);
|
||||
r.end.col = r.end.col.min(used.end.col).max(r.start.col);
|
||||
}
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("chart.switchRowColumn", "range too large for a chart"));
|
||||
}
|
||||
let by_rows = !by_rows;
|
||||
let series = series_from_range(ssh, r, by_rows);
|
||||
if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) {
|
||||
c.source.get_or_insert(src);
|
||||
c.by_rows = by_rows;
|
||||
c.series = series;
|
||||
}
|
||||
@@ -669,6 +709,29 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The block a chart's series come from, and whether each series is a row of it: the bounding
|
||||
/// range of the series names, categories and values, when they're all references to one sheet.
|
||||
fn series_block(series: &[Series]) -> Option<(String, bool)> {
|
||||
let mut sheet: Option<String> = None;
|
||||
let mut bounds: Option<RangeRef> = None;
|
||||
let mut rows = 0;
|
||||
for s in series {
|
||||
for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() {
|
||||
let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None };
|
||||
let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None };
|
||||
if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) {
|
||||
return None;
|
||||
}
|
||||
let range = r.range();
|
||||
if i == 0 && range.height() == 1 && range.width() > 1 {
|
||||
rows += 1;
|
||||
}
|
||||
bounds = Some(bounds.map_or(range, |b| b.union(&range)));
|
||||
}
|
||||
}
|
||||
Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len()))
|
||||
}
|
||||
|
||||
fn chart_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
@@ -696,14 +759,39 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let placement = str_param(p, "placement").unwrap_or("overCells");
|
||||
if !matches!(placement, "cell" | "overCells") {
|
||||
return Err(bad("insert.picture", "placement must be cell or overCells"));
|
||||
}
|
||||
let data = if let Some(b) = str_param(p, "base64") {
|
||||
crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))?
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
crate::io::read_file(path)?
|
||||
} else {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({})));
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement})));
|
||||
return ok();
|
||||
};
|
||||
if placement == "cell" {
|
||||
let mime = validate_cell_picture(&data)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let at = match str_param(p, "at") {
|
||||
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
|
||||
None => d.selection.active,
|
||||
};
|
||||
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 {
|
||||
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() };
|
||||
return edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture));
|
||||
cx.touch(sheet, at);
|
||||
Ok(json!({"cell": at.a1(), "placement": "cell"}))
|
||||
});
|
||||
}
|
||||
if data.len() > 64 * 1024 * 1024 {
|
||||
return Err(bad("insert.picture", "image larger than 64 MB"));
|
||||
}
|
||||
@@ -723,6 +811,8 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
|
||||
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
|
||||
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
|
||||
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
|
||||
},
|
||||
data,
|
||||
mime,
|
||||
@@ -735,6 +825,33 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode.
|
||||
fn validate_cell_picture(data: &[u8]) -> Result<&'static str> {
|
||||
if data.len() > 16 * 1024 * 1024 {
|
||||
return Err(bad("insert.picture", "cell pictures must be at most 16 MiB"));
|
||||
}
|
||||
let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?;
|
||||
let mime = match format {
|
||||
image::ImageFormat::Png => "image/png",
|
||||
image::ImageFormat::Jpeg => "image/jpeg",
|
||||
_ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")),
|
||||
};
|
||||
let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format)
|
||||
.into_dimensions()
|
||||
.map_err(|_| bad("insert.picture", "invalid image dimensions"))?;
|
||||
if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 {
|
||||
return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels"));
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?;
|
||||
Ok(mime)
|
||||
}
|
||||
|
||||
fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kind = match str_param(p, "kind").unwrap_or("rectangle") {
|
||||
"roundedRectangle" => ShapeKind::RoundedRectangle,
|
||||
@@ -764,6 +881,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(144.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
fill,
|
||||
line,
|
||||
@@ -776,6 +894,37 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A text box holds at most as many characters as a cell.
|
||||
const SHAPE_TEXT_MAX_CHARS: usize = 32_767;
|
||||
|
||||
fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id =
|
||||
p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?;
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?;
|
||||
if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() {
|
||||
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
return Err(bad("shape.setText", "the shape is not a text box"));
|
||||
}
|
||||
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
if shape.text == text {
|
||||
return ok();
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
shape.text = text.into();
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
@@ -800,7 +949,8 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => None,
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
|
||||
if let Some(at) = cell_param(p, "at") {
|
||||
@@ -822,6 +972,30 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
|
||||
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
|
||||
/// when the sheet is sorted or edited structurally.
|
||||
fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
|
||||
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
|
||||
}
|
||||
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
|
||||
a.mode = mode;
|
||||
Ok(json!({"mode": anchor_mode_name(mode)}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- links & comments
|
||||
|
||||
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -846,7 +1020,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
|
||||
sh.cells.set(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
@@ -1134,7 +1308,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Icon,
|
||||
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size },
|
||||
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
fill: color,
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
|
||||
@@ -125,10 +125,11 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({
|
||||
"cell": c.a1(),
|
||||
"value": value_json(&sh.value(c)),
|
||||
"type": match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" },
|
||||
"type": if sh.cell_pictures.contains_key(&c) { "picture" } else { match sh.value(c) { Value::Empty => "empty", Value::Number(_) => "number", Value::Text(_) => "text", Value::Bool(_) => "boolean", Value::Error(_) => "error", Value::Array(_) => "array" } },
|
||||
"formula": cell.and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text)),
|
||||
"text": crate::display::cell_text(&d.wb, sh, c),
|
||||
"input": cell.map(|x| x.input_text()).unwrap_or_default(),
|
||||
"input": sh.input_text(c),
|
||||
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
|
||||
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
|
||||
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
|
||||
"style": style,
|
||||
|
||||
@@ -146,6 +146,30 @@ pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
|
||||
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
|
||||
p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
|
||||
}
|
||||
|
||||
/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
|
||||
/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
|
||||
pub(crate) fn anchor_mode_param(p: &Json) -> Option<gridcraft_model::AnchorMode> {
|
||||
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
|
||||
match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
|
||||
"moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize),
|
||||
"moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly),
|
||||
"absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
|
||||
pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str {
|
||||
match m {
|
||||
gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize",
|
||||
gridcraft_model::AnchorMode::MoveOnly => "moveOnly",
|
||||
gridcraft_model::AnchorMode::Absolute => "absolute",
|
||||
}
|
||||
}
|
||||
pub(crate) fn ok() -> Result<Json> {
|
||||
Ok(Json::Null)
|
||||
}
|
||||
@@ -209,24 +233,36 @@ pub struct Ctx<'a> {
|
||||
pub sel: &'a mut Selection,
|
||||
/// Rows whose automatic height should be recomputed (sheet, row).
|
||||
pub fit_rows: Vec<(usize, u32)>,
|
||||
/// The command applied sheet protection itself (row/column inserts and deletes the
|
||||
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
|
||||
pub protection_checked: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
let d = s.doc_mut()?;
|
||||
commit(d, f)
|
||||
commit(d, |cx| {
|
||||
let r = f(cx)?;
|
||||
crate::tables::sync_headers(cx);
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
let prof = std::env::var_os("GRIDCRAFT_PROFILE").is_some();
|
||||
let t0 = std::time::Instant::now();
|
||||
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
|
||||
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
|
||||
let mut sel = d.selection.clone();
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new() };
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, .. } = ctx;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb)?;
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
if prof {
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
|
||||
}
|
||||
if structural {
|
||||
@@ -234,7 +270,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
} else if !changed.is_empty() {
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
}
|
||||
if prof {
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + recalc {:?}", t0.elapsed());
|
||||
}
|
||||
fit_rows.sort_unstable();
|
||||
@@ -242,7 +278,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
for (si, row) in fit_rows {
|
||||
auto_row_height(&mut wb, si, row);
|
||||
}
|
||||
if prof {
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + rows {:?}", t0.elapsed());
|
||||
}
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
@@ -250,6 +286,43 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||||
if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
|
||||
/// multi-line text. Rows with a user-set height keep it.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
|
||||
@@ -1621,6 +1621,29 @@ mod tests {
|
||||
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_headers_get_numbered_names() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")];
|
||||
// Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would
|
||||
// hang rather than fail a wall-clock check, which is flaky on a loaded machine.
|
||||
header.extend((0..3000).map(|_| json!("x")));
|
||||
let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect();
|
||||
let end = CellRef::new(1, header.len() as u32 - 1).a1();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap();
|
||||
let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap();
|
||||
assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]);
|
||||
assert_eq!(src.headers[4], "x");
|
||||
assert_eq!(src.headers.last().map(String::as_str), Some("x3000"));
|
||||
// Table column names follow the same rule.
|
||||
s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap();
|
||||
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone();
|
||||
assert_eq!(text(&mut s, "B1"), "A2");
|
||||
let last = CellRef::new(0, 3003).a1();
|
||||
assert_eq!(text(&mut s, &last), "x3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let mut s = sales();
|
||||
|
||||
@@ -847,11 +847,41 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
|
||||
if let Some(picture) = sh.cell_pictures.get(c) {
|
||||
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
||||
} else {
|
||||
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
|
||||
}
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
|
||||
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
|
||||
let (x, y, w, h) = rect;
|
||||
let pad = 2.0 * k;
|
||||
let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0));
|
||||
if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 {
|
||||
return;
|
||||
}
|
||||
let Some((iw, ih)) = crate::io::image_size(data) else { return };
|
||||
if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 {
|
||||
return;
|
||||
}
|
||||
let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return };
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let Ok(image) = reader.decode() else { return };
|
||||
let image =
|
||||
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
|
||||
let fit = (w / iw as f32).min(h / ih as f32);
|
||||
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
|
||||
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
|
||||
}
|
||||
|
||||
/// Is a cell free for text to overflow into?
|
||||
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
||||
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
||||
@@ -923,7 +953,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
||||
other => other,
|
||||
};
|
||||
let lines: Vec<String> = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] };
|
||||
let rot = st.align.rotation;
|
||||
let rotated = rot != 0 && rot != 255;
|
||||
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
|
||||
let lines: Vec<String> = match (wrap, rotated) {
|
||||
(true, false) => wrap_lines(&text, font, size, avail.max(size)),
|
||||
(true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)),
|
||||
(true, true) => vec![text.lines().next().unwrap_or_default().to_string()],
|
||||
(false, _) => vec![text],
|
||||
};
|
||||
let line_h = size * 1.2;
|
||||
let n = lines.len().max(1) as f32;
|
||||
let first_baseline = match st.align.v {
|
||||
@@ -933,7 +971,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
};
|
||||
// Overflow into empty neighbours (single-line text, not merged).
|
||||
let (mut clip_l, mut clip_r) = (x, x + w);
|
||||
if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w {
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
@@ -966,13 +1004,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let clip_r = clip_r.min(limits.1);
|
||||
pg.save();
|
||||
pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h);
|
||||
let rot = st.align.rotation;
|
||||
if rot != 0 && rot != 255 && !wrap {
|
||||
if rotated {
|
||||
// Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell.
|
||||
// Wrapped lines stack rightward for upward text and leftward for downward text, so
|
||||
// downward text starts its first line far enough in for the last to end at the left pad.
|
||||
let deg = (rot as f32).clamp(-90.0, 90.0);
|
||||
let angle = -deg.to_radians();
|
||||
let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) };
|
||||
if let Some(t) = lines.first() {
|
||||
let last = lines.len().saturating_sub(1) as f32;
|
||||
for (i, t) in lines.iter().enumerate() {
|
||||
let i = i as f32;
|
||||
let (ox, oy) = if deg > 0.0 {
|
||||
(x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad)
|
||||
} else {
|
||||
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
|
||||
};
|
||||
pg.text_rotated(ox, oy, size, font, color, t, angle);
|
||||
}
|
||||
pg.restore();
|
||||
@@ -1673,7 +1718,7 @@ mod tests {
|
||||
let id = wb.next_object_id();
|
||||
wb.sheet_mut(0).unwrap().images.push(Image {
|
||||
id,
|
||||
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0 },
|
||||
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
|
||||
data: png,
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
@@ -1762,4 +1807,26 @@ mod tests {
|
||||
let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0);
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_wrapped_text_prints_rotated() {
|
||||
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
|
||||
let mut s = s();
|
||||
let header = "Term 1 : Drama : Continuous Assessment";
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap();
|
||||
s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap();
|
||||
s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
let pdf = String::from_utf8_lossy(&pdf);
|
||||
// The page holds only these two cells, so every text block on it is a piece of a header.
|
||||
let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect();
|
||||
let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count();
|
||||
let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count();
|
||||
// Every piece of both headers is rotated, each wrapped along the row height into a few long lines.
|
||||
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
||||
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+235
-52
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{Sheet, Visibility};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -61,6 +61,46 @@ fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
|
||||
Ok((a, count))
|
||||
}
|
||||
|
||||
/// Bottom-right line of an anchor (the `to` marker of a two-cell anchor) and the offset of that
|
||||
/// corner inside it, in points. Measured against `sh`'s current geometry.
|
||||
fn anchor_corner(sh: &Sheet, a: &Anchor) -> (u32, u32, f32, f32) {
|
||||
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
|
||||
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
|
||||
let tc = sh.col_at(x2);
|
||||
let tr = sh.row_at(y2);
|
||||
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
|
||||
}
|
||||
|
||||
/// Repositions one floating object's anchor for a structural insert/delete, honouring its
|
||||
/// [`AnchorMode`]: absolute objects ("Don't move or size with cells") stay put; "move but don't
|
||||
/// size" follows its cell and keeps its size; "move and size" is re-fitted to the lines it lands
|
||||
/// on. `corner` is the pre-edit bottom-right line/offset (from [`anchor_corner`]); a line deleted
|
||||
/// out from under an edge collapses that edge onto `at`. `sh` is the post-edit grid, used to
|
||||
/// measure the re-fitted size.
|
||||
fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis, at: u32, map_line: &impl Fn(u32) -> Option<u32>) -> Anchor {
|
||||
if a.mode == AnchorMode::Absolute {
|
||||
return a;
|
||||
}
|
||||
let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col };
|
||||
// A deleted anchor line collapses onto the line that takes its place.
|
||||
let nmain = map_line(main).unwrap_or(at);
|
||||
let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) };
|
||||
if a.mode == AnchorMode::MoveOnly {
|
||||
return Anchor { cell: CellRef::new(nr, nc), ..a };
|
||||
}
|
||||
// Move and size: re-fit to the lines the object lands on. A far edge whose line was deleted
|
||||
// collapses onto the near cell, shrinking the object.
|
||||
let (tc, tr, tdx, tdy) = corner;
|
||||
let far = if axis == Axis::Rows { tr } else { tc };
|
||||
let nfar = map_line(far).unwrap_or(nmain);
|
||||
let (nbr, nbc) = if axis == Axis::Rows { (nfar, tc) } else { (tr, nfar) };
|
||||
let x1 = sh.col_left(nc) + a.dx as f64;
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let xn = sh.col_left(nbc) + tdx as f64;
|
||||
let yn = sh.row_top(nbr) + tdy as f64;
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode }
|
||||
}
|
||||
|
||||
/// Shifts everything that has a position on the sheet for an insert/delete.
|
||||
fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) {
|
||||
let map_line = |v: u32| -> Option<u32> {
|
||||
@@ -74,26 +114,24 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
None
|
||||
}
|
||||
};
|
||||
let map_range = |r: RangeRef| -> Option<RangeRef> {
|
||||
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||
sheet: gridcraft_formula::SheetSel::Current,
|
||||
kind: gridcraft_formula::RefKind::Range(
|
||||
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
|
||||
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
|
||||
),
|
||||
});
|
||||
match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) {
|
||||
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
let map_range = |r: RangeRef| map_range(r, &e);
|
||||
let map_cell = |c: CellRef| -> Option<CellRef> {
|
||||
match axis {
|
||||
Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)),
|
||||
Axis::Cols => map_line(c.col).map(|x| CellRef::new(c.row, x)),
|
||||
}
|
||||
};
|
||||
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
|
||||
// re-fit after the shift measures against the grid the object actually lands on.
|
||||
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
|
||||
.charts
|
||||
.iter()
|
||||
.map(|o| &o.anchor)
|
||||
.chain(sh.images.iter().map(|o| &o.anchor))
|
||||
.chain(sh.shapes.iter().map(|o| &o.anchor))
|
||||
.map(|a| anchor_corner(sh, a))
|
||||
.collect();
|
||||
// Line info.
|
||||
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let old = std::mem::take(lines);
|
||||
@@ -149,17 +187,26 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
None => sh.autofilter = None,
|
||||
}
|
||||
}
|
||||
sh.cell_pictures =
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
for ch in sh.charts.iter_mut() {
|
||||
if let Some(c) = map_cell(ch.anchor.cell) {
|
||||
ch.anchor.cell = c;
|
||||
}
|
||||
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
|
||||
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
|
||||
// against the post-shift grid first (the re-fit needs its sizes), then written back.
|
||||
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
|
||||
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
|
||||
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
|
||||
let base = new_charts.len() + new_images.len();
|
||||
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
|
||||
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for im in sh.images.iter_mut() {
|
||||
if let Some(c) = map_cell(im.anchor.cell) {
|
||||
im.anchor.cell = c;
|
||||
}
|
||||
for (o, a) in sh.images.iter_mut().zip(new_images) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
if let Some(c) = map_cell(sp.cell) {
|
||||
@@ -196,8 +243,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells).
|
||||
fn cancel_copy_mode(s: &mut Session) {
|
||||
let uid = s.doc().map(|d| d.uid).ok();
|
||||
if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) {
|
||||
s.clipboard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
@@ -207,8 +264,9 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match axis {
|
||||
@@ -230,21 +288,25 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.cells.take_range(block);
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
@@ -254,7 +316,9 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -265,27 +329,24 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
|
||||
_ => {}
|
||||
}
|
||||
let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let right = shift == "right";
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if right {
|
||||
sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64);
|
||||
} else {
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64);
|
||||
}
|
||||
// Formulas referring to cells that moved: approximate with a whole-sheet move edit.
|
||||
let moved = if right {
|
||||
RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width()))
|
||||
} else {
|
||||
RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col))
|
||||
};
|
||||
shift_cell_features(sh, &e);
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) };
|
||||
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 });
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -296,31 +357,28 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
|
||||
_ => {}
|
||||
}
|
||||
let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let left = shift == "left";
|
||||
edit(s, |cx| {
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.take_range(r);
|
||||
sh.take_cells(r);
|
||||
if left {
|
||||
sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.end.col + 1, -(r.width() as i64));
|
||||
} else {
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64));
|
||||
}
|
||||
shift_cell_features(sh, &e);
|
||||
}
|
||||
// References into the deleted block become #REF!; the rest move.
|
||||
let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col };
|
||||
let _ = deleted;
|
||||
let moved_from = if left {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1))
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col });
|
||||
// References into the deleted cells become #REF!; the rest move.
|
||||
rewrite(&mut cx.wb, &name, &e);
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -359,6 +417,39 @@ fn edit_doc<R>(d: &mut crate::DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -
|
||||
super::commit(d, f)
|
||||
}
|
||||
|
||||
/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and
|
||||
/// deleted lines must not hold locked cells.
|
||||
fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let wb = &s.doc()?.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return Ok(()) };
|
||||
let Some(pr) = &sh.protection else { return Ok(()) };
|
||||
let allowed = match (axis, insert) {
|
||||
(Axis::Rows, true) => pr.insert_rows,
|
||||
(Axis::Rows, false) => pr.delete_rows,
|
||||
(Axis::Cols, true) => pr.insert_columns,
|
||||
(Axis::Cols, false) => pr.delete_columns,
|
||||
};
|
||||
let locked = |st: gridcraft_model::StyleId| wb.styles.get(st).protection.locked;
|
||||
let refuse = || EngineError::Other(PROTECTED.into());
|
||||
if !allowed {
|
||||
return Err(refuse());
|
||||
}
|
||||
if insert {
|
||||
return Ok(());
|
||||
}
|
||||
let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 });
|
||||
let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols };
|
||||
if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) {
|
||||
return Err(refuse());
|
||||
}
|
||||
let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) };
|
||||
if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) {
|
||||
return Err(refuse());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
@@ -369,6 +460,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
|
||||
}
|
||||
let name = d.wb.sheet(i).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let order: Vec<String> = d.wb.sheets.iter().map(|s| s.name.clone()).collect();
|
||||
let d = s.doc_mut()?;
|
||||
edit_doc(d, |cx| {
|
||||
cx.wb.sheets.remove(i);
|
||||
@@ -378,7 +470,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
if let Some(e) = f.expr() {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e, &name);
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
@@ -392,6 +484,13 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
*sc -= 1;
|
||||
}
|
||||
// Names referring to the sheet become #REF!.
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i {
|
||||
cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1));
|
||||
@@ -461,10 +560,31 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
let base = sh.name.clone();
|
||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base);
|
||||
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
|
||||
sh.name = name.chars().take(31).collect();
|
||||
sh.tables.clear(); // table names must be unique
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
.wb
|
||||
.names
|
||||
.iter()
|
||||
.filter(|nm| nm.scope == Some(i))
|
||||
.map(|nm| {
|
||||
let formula = gridcraft_formula::parse(&nm.formula)
|
||||
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
|
||||
.unwrap_or_else(|_| nm.formula.clone());
|
||||
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
|
||||
})
|
||||
.collect();
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
cx.wb.names.extend(copies);
|
||||
cx.wb.sheets.insert(at, Arc::new(sh));
|
||||
cx.wb.active_sheet = at;
|
||||
} else {
|
||||
@@ -472,6 +592,17 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = to.min(n - 1);
|
||||
cx.wb.sheets.insert(at, sh);
|
||||
cx.wb.active_sheet = at;
|
||||
// Sheet-level names follow their sheet.
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut() {
|
||||
*sc = match *sc {
|
||||
x if x == i => at,
|
||||
x if i < x && x <= at => x - 1,
|
||||
x if at <= x && x < i => x + 1,
|
||||
x => x,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -538,3 +669,55 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||
s.doc_mut()?.wb_switch_sheet(i as usize);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
|
||||
fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference {
|
||||
sheet: gridcraft_formula::SheetSel::Current,
|
||||
kind: gridcraft_formula::RefKind::Range(
|
||||
gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
|
||||
gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
|
||||
),
|
||||
});
|
||||
match gridcraft_formula::adjust::adjust(expr, "S", "S", e) {
|
||||
gridcraft_formula::Expr::Ref(r) => Some(r.range()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
|
||||
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
|
||||
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
|
||||
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
|
||||
for dv in sh.validations.iter_mut() {
|
||||
dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
|
||||
}
|
||||
sh.validations.retain(|dv| !dv.ranges.is_empty());
|
||||
for t in sh.tables.iter_mut() {
|
||||
t.range = map_range(t.range, e).unwrap_or_default();
|
||||
}
|
||||
sh.tables.retain(|t| t.range != RangeRef::default());
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
match map_range(af.range, e) {
|
||||
Some(r) => af.range = r,
|
||||
None => sh.autofilter = None,
|
||||
}
|
||||
}
|
||||
sh.cell_pictures =
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
if let Some(c) = map_cell(sp.cell) {
|
||||
sp.cell = c;
|
||||
}
|
||||
}
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ impl Trial {
|
||||
for (si, c, v) in sets {
|
||||
if let Some(sh) = wb.sheet_mut(*si) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.cells.set(*c, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
|
||||
keys.push((*si, *c));
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(csi)?;
|
||||
let style = sh.style_id(chg);
|
||||
sh.cells.set(chg, Cell { value: Value::number(r.x), formula: None, style });
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
|
||||
cx.touch(csi, chg);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -489,13 +489,12 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
let input = super::edit::json_to_input(v);
|
||||
let old = cx.wb.sheet(si).and_then(|sh| sh.cell(*c)).cloned();
|
||||
let cell = super::edit::input_to_cell(&input, old.as_ref(), &mut cx.wb)?;
|
||||
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.cells.set(*c, cell),
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
None => {
|
||||
sh.cells.remove(*c);
|
||||
sh.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
cx.touch(si, *c);
|
||||
@@ -572,7 +571,7 @@ fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.cells.set(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
@@ -695,7 +694,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, v) in out {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.cells.set(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
cx.touch(si, c);
|
||||
}
|
||||
Ok(())
|
||||
@@ -891,10 +890,10 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.cells.remove(*c);
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
for (c, cell) in cells {
|
||||
shm.cells.set(c, cell);
|
||||
shm.set_cell(c, cell);
|
||||
}
|
||||
for c in old {
|
||||
cx.touch(dsi, c);
|
||||
@@ -1108,9 +1107,9 @@ fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(dsi)?;
|
||||
let style = sh.style_id(c);
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.cells.remove(c);
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.cells.set(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,28 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
format_value(v, &parsed(code), wb.date_system)
|
||||
}
|
||||
|
||||
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
||||
/// protected sheet. A spilled cell follows its anchor.
|
||||
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
if !sheet.is_protected() {
|
||||
return false;
|
||||
}
|
||||
let at = match sheet.cell(c) {
|
||||
Some(x) if x.formula.is_some() => c,
|
||||
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
||||
Some((anchor, _)) => *anchor,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
wb.styles.get(sheet.style_id(at)).protection.hidden
|
||||
}
|
||||
|
||||
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
|
||||
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let v = sheet.value(c);
|
||||
if sheet.show_formulas
|
||||
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
|
||||
&& !formula_hidden(wb, sheet, c)
|
||||
{
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
@@ -57,7 +74,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
||||
let mut out = String::new();
|
||||
let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r };
|
||||
let r = clipboard_range(r);
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
@@ -82,6 +99,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The existing plain-text clipboard limit, shared with its HTML representation.
|
||||
pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef {
|
||||
if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }
|
||||
}
|
||||
|
||||
/// Status-bar statistics for a selection: average, count (non-empty), numerical count, min,
|
||||
/// max, sum.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
|
||||
|
||||
+26
-10
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, serial_from_ymd};
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
|
||||
let src_len = if vertical { src.height() } else { src.width() };
|
||||
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
|
||||
let mut pictures = Vec::new();
|
||||
for lane in lanes {
|
||||
let src_cells: Vec<(CellRef, Option<Cell>)> = (0..src_len)
|
||||
.map(|i| {
|
||||
@@ -97,18 +98,23 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cell.style = style;
|
||||
}
|
||||
}
|
||||
if let Some(picture) = sh.cell_pictures.get(&sc) {
|
||||
pictures.push((*t, picture.clone()));
|
||||
}
|
||||
writes.push((*t, if cell.is_blank() { None } else { Some(cell) }));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, cell) in &writes {
|
||||
match cell {
|
||||
Some(x) => sh.cells.set(*c, x.clone()),
|
||||
Some(x) if mode == FillMode::Formats => sh.cells.set(*c, x.clone()),
|
||||
Some(x) => sh.set_cell(*c, x.clone()),
|
||||
None => {
|
||||
sh.cells.remove(*c);
|
||||
sh.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sh.cell_pictures.extend(pictures);
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
@@ -258,15 +264,25 @@ impl Pattern {
|
||||
}
|
||||
Pattern::Months { last, first, months } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let d = datetime_from_serial(gridcraft_core::DateSystem::D1900, base)?;
|
||||
let m = d.month as i64 + months * k * sign;
|
||||
let s = serial_from_ymd(gridcraft_core::DateSystem::D1900, d.year as i64, m, d.day as i64)?;
|
||||
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
||||
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
|
||||
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
||||
let d = datetime_from_serial(sys, serial)?;
|
||||
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
||||
let year = total.div_euclid(12);
|
||||
let month = total.rem_euclid(12) + 1;
|
||||
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
|
||||
}
|
||||
|
||||
fn round15(x: f64) -> f64 {
|
||||
if x == 0.0 || !x.is_finite() {
|
||||
return x;
|
||||
@@ -310,8 +326,8 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
|
||||
"date" => {
|
||||
let d = datetime_from_serial(sys, v);
|
||||
match (unit, d) {
|
||||
("month", Some(d)) => serial_from_ymd(sys, d.year as i64, d.month as i64 + step as i64, d.day as i64).unwrap_or(v),
|
||||
("year", Some(d)) => serial_from_ymd(sys, d.year as i64 + step as i64, d.month as i64, d.day as i64).unwrap_or(v),
|
||||
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||||
("weekday", Some(_)) => {
|
||||
let mut n = v;
|
||||
let mut left = step.abs() as i64;
|
||||
@@ -339,7 +355,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, cell) in &writes {
|
||||
sh.cells.set(*c, cell.clone());
|
||||
sh.set_cell(*c, cell.clone());
|
||||
}
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
@@ -393,7 +409,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, out) in &writes {
|
||||
sh.cells.set(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||||
}
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
+179
-28
@@ -1,9 +1,9 @@
|
||||
//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export.
|
||||
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::Workbook;
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::{EngineError, Result};
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
Xlsx,
|
||||
Xlsb,
|
||||
Ods,
|
||||
Csv,
|
||||
Tsv,
|
||||
Json,
|
||||
@@ -22,6 +24,8 @@ impl FileKind {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
|
||||
"xlsb" => FileKind::Xlsb,
|
||||
"ods" => FileKind::Ods,
|
||||
"csv" => FileKind::Csv,
|
||||
"tsv" | "tab" | "txt" => FileKind::Tsv,
|
||||
"json" | "scjson" => FileKind::Json,
|
||||
@@ -36,6 +40,28 @@ impl FileKind {
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
if sniffed == gridcraft_xlsx::Format::Encrypted {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
|
||||
)));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again."
|
||||
)));
|
||||
}
|
||||
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
|
||||
// whatever it is called; the extension only decides when the bytes don't say.
|
||||
let import = match sniffed {
|
||||
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
|
||||
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
|
||||
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
|
||||
};
|
||||
if let Some(import) = import {
|
||||
let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb };
|
||||
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
@@ -81,6 +107,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
let sheet = wb.active_sheet;
|
||||
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
@@ -92,6 +120,109 @@ fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
css.push_str("font-weight:bold;");
|
||||
}
|
||||
if st.font.italic {
|
||||
css.push_str("font-style:italic;");
|
||||
}
|
||||
if let Some(h) = st.font.color.hex(&wb.theme) {
|
||||
let _ = write!(css, "color:{h};");
|
||||
}
|
||||
if st.fill.pattern != gridcraft_model::PatternType::None
|
||||
&& let Some(h) = st.fill.fg.hex(&wb.theme)
|
||||
{
|
||||
let _ = write!(css, "background:{h};");
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
css
|
||||
}
|
||||
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// An HTML fragment for the same range as `plain_text`, previously produced by
|
||||
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
|
||||
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
|
||||
let r = crate::display::clipboard_range(r);
|
||||
if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
}
|
||||
out.write_str("<tr>").ok()?;
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
|
||||
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
|
||||
if c != CellRef::new(first_row, merged.start.col) {
|
||||
continue;
|
||||
}
|
||||
let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count();
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
|
||||
// Use the visible selected cell, even when the merge's original anchor
|
||||
// is outside the range or hidden. This matches the plain-text copy.
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'&' => out.write_str("&"),
|
||||
'<' => out.write_str("<"),
|
||||
'>' => out.write_str(">"),
|
||||
'"' => out.write_str("""),
|
||||
'\r' => {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
out.write_str("<br>")
|
||||
}
|
||||
'\n' => out.write_str("<br>"),
|
||||
_ => out.write_char(ch),
|
||||
}
|
||||
.ok()?;
|
||||
}
|
||||
out.write_str("</td>").ok()?;
|
||||
}
|
||||
out.write_str("</tr>").ok()?;
|
||||
}
|
||||
out.write_str("</table>").ok()?;
|
||||
Some(out.0)
|
||||
}
|
||||
|
||||
/// Enforce the budget while escaping, before HTML expansion can allocate a large string.
|
||||
struct ClipboardHtml(String);
|
||||
|
||||
impl std::fmt::Write for ClipboardHtml {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) {
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
self.0.push_str(s);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page).
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
||||
@@ -112,31 +243,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
css.push_str("font-weight:bold;");
|
||||
}
|
||||
if st.font.italic {
|
||||
css.push_str("font-style:italic;");
|
||||
}
|
||||
if let Some(h) = st.font.color.hex(&wb.theme) {
|
||||
let _ = write!(css, "color:{h};");
|
||||
}
|
||||
if st.fill.pattern != gridcraft_model::PatternType::None
|
||||
&& let Some(h) = st.fill.fg.hex(&wb.theme)
|
||||
{
|
||||
let _ = write!(css, "background:{h};");
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
@@ -244,3 +351,47 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
|
||||
/// root entry and the given stream names.
|
||||
fn cfb(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(30, &9u16.to_le_bytes());
|
||||
put(44, &1u32.to_le_bytes());
|
||||
put(48, &1u32.to_le_bytes());
|
||||
put(68, &0xFFFF_FFFEu32.to_le_bytes());
|
||||
for i in 1..109 {
|
||||
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
put(512, &0xFFFF_FFFDu32.to_le_bytes());
|
||||
put(516, &0xFFFF_FFFEu32.to_le_bytes());
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() {
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
put(1024 + k * 128, &units);
|
||||
put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_workbook_is_reported_not_misread() {
|
||||
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("password-protected"), "got: {msg}");
|
||||
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_xls_is_not_called_encrypted() {
|
||||
let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}");
|
||||
assert!(!msg.contains("password"), "got: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,25 @@ impl Session {
|
||||
pub fn take_ui_requests(&mut self) -> Vec<UiRequest> {
|
||||
std::mem::take(&mut self.ui_requests)
|
||||
}
|
||||
|
||||
/// Runs a command for a caller with no UI (MCP, CLI): queued UI requests are dropped, and a command that only
|
||||
/// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
|
||||
/// file yet, `data.getData`) is an error instead of a silent success.
|
||||
pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
|
||||
let r = self.execute(id, params);
|
||||
let requests = self.take_ui_requests();
|
||||
let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
|
||||
if r.is_ok() && asked_for_file {
|
||||
let what = match id {
|
||||
"file.open" => "`path` or `base64`",
|
||||
"data.getData" | "data.fromTextCsv" => "`path` or `text`",
|
||||
_ => "`path`",
|
||||
};
|
||||
let msg = format!("missing {what}: programmatic calls never open dialogs");
|
||||
return Err(EngineError::BadParams { cmd: id.to_string(), msg });
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: the active workbook's active sheet.
|
||||
|
||||
@@ -126,18 +126,25 @@ pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<So
|
||||
return Err("The source range is too large for a PivotTable.".into());
|
||||
}
|
||||
let mut headers: Vec<String> = Vec::new();
|
||||
// Names taken so far (lower-case), and the next suffix to try for each repeated header, so
|
||||
// that thousands of equal headers don't take cubic time.
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
let mut next_suffix: HashMap<String, usize> = HashMap::new();
|
||||
let mut formats = Vec::new();
|
||||
for c in r.start.col..=r.end.col {
|
||||
let h = crate::display::cell_text(wb, sh, CellRef::new(r.start.row, c)).trim().to_string();
|
||||
if h.is_empty() {
|
||||
return Err(FIELD_NAME_ERR.into());
|
||||
}
|
||||
let key = h.to_ascii_lowercase();
|
||||
let mut n = h.clone();
|
||||
let mut k = 2;
|
||||
while headers.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
|
||||
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
|
||||
while taken.contains(&n.to_ascii_lowercase()) {
|
||||
n = format!("{h}{k}");
|
||||
k += 1;
|
||||
}
|
||||
next_suffix.insert(key, k);
|
||||
taken.insert(n.to_ascii_lowercase());
|
||||
headers.push(n);
|
||||
let fmt = if r.end.row > r.start.row {
|
||||
wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).num_fmt.as_str().to_string()
|
||||
@@ -1184,11 +1191,11 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
}
|
||||
let shm = wb.sheet_mut(sheet).ok_or("There's no such sheet.")?;
|
||||
if let Some(old) = pt.last_range {
|
||||
shm.cells.take_range(old);
|
||||
shm.take_cells(old);
|
||||
}
|
||||
shm.cells.take_range(area);
|
||||
shm.take_cells(area);
|
||||
for (c, cell) in cells {
|
||||
shm.cells.set(c, cell);
|
||||
shm.set_cell(c, cell);
|
||||
}
|
||||
for (col, wpt) in widths {
|
||||
if wpt > shm.col_width(col) && !shm.is_col_hidden(col) {
|
||||
@@ -1205,7 +1212,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return };
|
||||
shm.cells.take_range(r);
|
||||
shm.take_cells(r);
|
||||
if let Some(p) = shm.pivots.get_mut(idx) {
|
||||
p.last_range = None;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,10 @@ impl Selection {
|
||||
|
||||
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
|
||||
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
if dr == 0 && dc == 0 {
|
||||
// No direction: nowhere to go (the search below would never advance).
|
||||
return from;
|
||||
}
|
||||
let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty());
|
||||
let Some(mut cur) = from.offset(dr, dc) else { return from };
|
||||
let here = filled(from);
|
||||
@@ -175,38 +179,34 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
|
||||
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
||||
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||
let filled = |c: CellRef| sheet.value_ref(c).is_some_and(|v| !v.is_empty());
|
||||
let mut r = RangeRef::cell(c);
|
||||
for _ in 0..10_000 {
|
||||
let mut grown = r;
|
||||
let r0 = r.start.row.saturating_sub(1);
|
||||
let r1 = (r.end.row + 1).min(MAX_ROWS - 1);
|
||||
let c0 = r.start.col.saturating_sub(1);
|
||||
let c1 = (r.end.col + 1).min(MAX_COLS - 1);
|
||||
// Check the ring around r.
|
||||
let ring = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
|
||||
let mut any = false;
|
||||
for (cc, cell) in sheet.cells.iter_range(ring) {
|
||||
if !r.contains(cc) && (!cell.value.is_empty() || cell.formula.is_some()) {
|
||||
grown = grown.union(&RangeRef::cell(cc));
|
||||
any = true;
|
||||
}
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
||||
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
||||
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
||||
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
||||
let (mut r0, mut r1, mut c0, mut c1) = (c.row, c.row, c.col, c.col);
|
||||
// Grow one side at a time while the line next to it (diagonals included) has data. Each
|
||||
// pass scans only the lines next to the region, so tall regions take linear time.
|
||||
loop {
|
||||
let before = (r0, r1, c0, c1);
|
||||
let (lo_c, hi_c) = (c0.saturating_sub(1), (c1 + 1).min(MAX_COLS - 1));
|
||||
while r0 > 0 && row_has(r0 - 1, lo_c, hi_c) {
|
||||
r0 -= 1;
|
||||
}
|
||||
for (cc, _) in sheet.spill.range(CellRef::new(r0, 0)..=CellRef::new(r1, MAX_COLS - 1)) {
|
||||
if ring.contains(*cc) && !r.contains(*cc) {
|
||||
grown = grown.union(&RangeRef::cell(*cc));
|
||||
any = true;
|
||||
}
|
||||
while r1 + 1 < MAX_ROWS && row_has(r1 + 1, lo_c, hi_c) {
|
||||
r1 += 1;
|
||||
}
|
||||
if !any || grown == r {
|
||||
let (lo_r, hi_r) = (r0.saturating_sub(1), (r1 + 1).min(MAX_ROWS - 1));
|
||||
while c0 > 0 && col_has(c0 - 1, lo_r, hi_r) {
|
||||
c0 -= 1;
|
||||
}
|
||||
while c1 + 1 < MAX_COLS && col_has(c1 + 1, lo_r, hi_r) {
|
||||
c1 += 1;
|
||||
}
|
||||
if (r0, r1, c0, c1) == before {
|
||||
break;
|
||||
}
|
||||
r = grown;
|
||||
}
|
||||
if r.is_single() && !filled(c) {
|
||||
return RangeRef::cell(c);
|
||||
}
|
||||
r
|
||||
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -222,6 +222,8 @@ mod tests {
|
||||
}
|
||||
s.set_value(CellRef::new(10, 0), Value::Number(1.0));
|
||||
assert_eq!(jump(&s, CellRef::new(0, 0), 1, 0), CellRef::new(4, 0));
|
||||
// Without a direction it stays put (it used to loop forever from a blank cell).
|
||||
assert_eq!(jump(&s, CellRef::new(7, 0), 0, 0), CellRef::new(7, 0));
|
||||
assert_eq!(jump(&s, CellRef::new(4, 0), 1, 0), CellRef::new(10, 0));
|
||||
assert_eq!(jump(&s, CellRef::new(10, 0), 1, 0), CellRef::new(MAX_ROWS - 1, 0));
|
||||
assert_eq!(jump(&s, CellRef::new(0, 0), 0, 1), CellRef::new(0, MAX_COLS - 1));
|
||||
@@ -239,6 +241,13 @@ mod tests {
|
||||
assert_eq!(current_region(&s, CellRef::new(3, 2)).a1(), "B3:D5");
|
||||
assert_eq!(current_region(&s, CellRef::new(5, 4)).a1(), "B3:E6");
|
||||
assert_eq!(current_region(&s, CellRef::new(20, 20)).a1(), "U21");
|
||||
// Taller than 10,000 rows: it used to stop growing after 10,000 steps.
|
||||
let mut s = Sheet::new("S");
|
||||
for r in 0..20_000 {
|
||||
s.set_value(CellRef::new(r, 0), Value::Number(r as f64));
|
||||
s.set_value(CellRef::new(r, 1), Value::Number(1.0));
|
||||
}
|
||||
assert_eq!(current_region(&s, CellRef::new(5, 0)).a1(), "A1:B20000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+155
-2
@@ -1,7 +1,7 @@
|
||||
//! Table styles: how a cell inside a table looks (our own palettes, keyed by the familiar
|
||||
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
|
||||
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
||||
@@ -9,7 +9,7 @@ pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
||||
let rest = name.strip_prefix("TableStyle").unwrap_or(name);
|
||||
for fam in ["Light", "Medium", "Dark"] {
|
||||
if let Some(n) = rest.strip_prefix(fam) {
|
||||
let k = n.parse().unwrap_or(2);
|
||||
let k = n.parse::<u32>().unwrap_or(2).max(1); // the gallery starts at 1; 0 would underflow `n - 1`
|
||||
return (fam, k);
|
||||
}
|
||||
}
|
||||
@@ -147,3 +147,156 @@ pub fn gallery() -> Vec<String> {
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Editing a table's header row renames the column, as in Excel: an empty or repeated header
|
||||
/// gets a unique name written back into the cell, and structured references follow the rename.
|
||||
pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {
|
||||
if cx.wb.sheets.iter().all(|s| s.tables.is_empty()) {
|
||||
return;
|
||||
}
|
||||
// (sheet, table name, table range, old column name, new column name)
|
||||
let mut renames: Vec<(usize, String, RangeRef, String, String)> = Vec::new();
|
||||
let heads: Vec<(usize, RangeRef)> = (0..cx.wb.sheets.len())
|
||||
.flat_map(|si| cx.wb.sheet(si).into_iter().flat_map(move |sh| sh.tables.iter().filter(|t| t.header_row).map(move |t| (si, t.range))))
|
||||
.map(|(si, r)| (si, RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))))
|
||||
.collect();
|
||||
// A structural edit can move anything into a header row: then every header is checked.
|
||||
let changed: std::collections::BTreeSet<(usize, CellRef)> = if cx.structural {
|
||||
heads.iter().flat_map(|(s, r)| r.iter().map(move |c| (*s, c))).collect()
|
||||
} else {
|
||||
cx.changed.iter().filter(|(si, c)| heads.iter().any(|(s, r)| s == si && r.contains(*c))).copied().collect()
|
||||
};
|
||||
// Per table: lower-case column name → column, and the next number to try for a repeated name
|
||||
// (both kept up to date, so a wide header row stays linear).
|
||||
type Names = (std::collections::HashMap<String, usize>, std::collections::HashMap<String, usize>);
|
||||
let mut names: std::collections::HashMap<(usize, usize), Names> = std::collections::HashMap::new();
|
||||
for (si, c) in changed {
|
||||
let Some(sh) = cx.wb.sheet(si) else { continue };
|
||||
let Some(ti) = sh.tables.iter().position(|t| t.header_row && c.row == t.range.start.row && t.range.contains(c)) else { continue };
|
||||
let Some(t) = sh.tables.get(ti) else { continue };
|
||||
let col = (c.col - t.range.start.col) as usize;
|
||||
let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
|
||||
let text = crate::display::cell_text(&cx.wb, sh, c);
|
||||
let is_text = sh.value(c).is_text();
|
||||
if text == old && is_text {
|
||||
continue;
|
||||
}
|
||||
let (taken, next) = names
|
||||
.entry((si, ti))
|
||||
.or_insert_with(|| (t.columns.iter().enumerate().map(|(i, x)| (x.name.to_lowercase(), i)).collect(), Default::default()));
|
||||
if taken.get(&old.to_lowercase()) == Some(&col) {
|
||||
taken.remove(&old.to_lowercase());
|
||||
}
|
||||
let base = if text.trim().is_empty() { format!("Column{}", col + 1) } else { text.clone() };
|
||||
let k = next.entry(base.to_lowercase()).or_insert(2);
|
||||
let mut name = base.clone();
|
||||
while taken.contains_key(&name.to_lowercase()) {
|
||||
name = format!("{base}{k}");
|
||||
*k += 1;
|
||||
}
|
||||
taken.insert(name.to_lowercase(), col);
|
||||
let (table, range) = (t.name.clone(), t.range);
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
if let Some(x) = sh.tables.get_mut(ti).and_then(|t| t.columns.get_mut(col)) {
|
||||
x.name = name.clone();
|
||||
}
|
||||
// Headers are text: a number typed there, or a generated name, is written back as text.
|
||||
if name != text || !is_text {
|
||||
let style = sh.style_id(c);
|
||||
sh.cells.set(c, Cell { value: gridcraft_core::Value::text(name.as_str()), style, ..Default::default() });
|
||||
}
|
||||
if !old.eq_ignore_ascii_case(&name) {
|
||||
renames.push((si, table, range, old, name));
|
||||
}
|
||||
}
|
||||
if renames.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Structured references follow the new names.
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
||||
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
for (c, f) in formulas {
|
||||
let Some(e) = f.expr() else { continue };
|
||||
let mut hit = false;
|
||||
let ne = e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Struct(mut st) => {
|
||||
for (si, table, range, old, name) in &renames {
|
||||
let this = if st.table.is_empty() { i == *si && range.contains(c) } else { st.table.eq_ignore_ascii_case(table) };
|
||||
if this {
|
||||
for end in [&mut st.col_start, &mut st.col_end].into_iter().flatten() {
|
||||
if end.eq_ignore_ascii_case(old) {
|
||||
*end = name.clone();
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gridcraft_formula::Expr::Struct(st)
|
||||
}
|
||||
o => o,
|
||||
});
|
||||
if hit && let Some(cell) = cx.wb.sheet_mut(i).and_then(|s| s.cells.get_mut(c)) {
|
||||
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_core::Value;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn editing_a_header_renames_the_column() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b", "c"], [1, 2, 3]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:C2", "header": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(Table1[a])"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "zz"})).unwrap();
|
||||
s.execute("edit.clearContents", json!({"range": "B1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "ZZ"})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "E1")["formula"], "=SUM(Table1[zz])");
|
||||
assert_eq!(get(&mut s, "E1")["value"], json!(1.0));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["zz", "Column2", "ZZ2"]);
|
||||
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 1)), Value::from("Column2"));
|
||||
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 2)), Value::from("ZZ2"));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["x", "x", 5]]})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.sheet(0).unwrap();
|
||||
let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["x", "x2", "5"]);
|
||||
assert_eq!(sh.value(gridcraft_core::CellRef::new(0, 2)), Value::from("5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_a_table_selects_its_data() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"], [1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
|
||||
assert_eq!(s.execute("edit.goTo", json!({"reference": "Table1"})).unwrap()["selection"], "A2:B3");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod style_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_numbered_styles_do_not_underflow() {
|
||||
assert_eq!(parse_style_name("TableStyleLight0"), ("Light", 1));
|
||||
assert_eq!(parse_style_name("TableStyleMedium0"), ("Medium", 1));
|
||||
for name in ["TableStyleLight0", "TableStyleMedium0", "TableStyleDark0"] {
|
||||
let _ = look(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) {
|
||||
s.execute(command, params).unwrap();
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, cell: &str, input: &str) {
|
||||
run(s, "cell.set", json!({"cell": cell, "input": input}));
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, sheet: &str) {
|
||||
run(s, "sheet.activate", json!({"sheet": sheet}));
|
||||
}
|
||||
|
||||
fn value(s: &Session, sheet: &str, cell: &str) -> Value {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
fn formula(s: &Session, sheet: &str, cell: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone()
|
||||
}
|
||||
|
||||
fn cut_to(s: &mut Session, source: &str, range: &str, destination: &str, at: &str) {
|
||||
activate(s, source);
|
||||
run(s, "edit.cut", json!({"range": range}));
|
||||
activate(s, destination);
|
||||
run(s, "edit.paste", json!({"at": at}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_sheet_cut_preserves_total_through_undo_redo_and_xlsx() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
|
||||
set(&mut s, "B1", "=SUM(A1:A2)");
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Sheet2"}));
|
||||
cut_to(&mut s, "Sheet1", "A1:A2", "Sheet2", "C3");
|
||||
|
||||
let assert_moved = |s: &Session| {
|
||||
assert_eq!(value(s, "Sheet1", "A1"), Value::Empty);
|
||||
assert_eq!(value(s, "Sheet1", "A2"), Value::Empty);
|
||||
assert_eq!(value(s, "Sheet2", "C3"), Value::Number(10.0));
|
||||
assert_eq!(value(s, "Sheet2", "C4"), Value::Number(20.0));
|
||||
assert_eq!(value(s, "Sheet1", "B1"), Value::Number(30.0));
|
||||
assert_eq!(formula(s, "Sheet1", "B1"), "SUM(Sheet2!C3:C4)");
|
||||
};
|
||||
assert_moved(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(value(&s, "Sheet1", "A1"), Value::Number(10.0));
|
||||
assert_eq!(value(&s, "Sheet1", "A2"), Value::Number(20.0));
|
||||
assert_eq!(value(&s, "Sheet2", "C3"), Value::Empty);
|
||||
assert_eq!(value(&s, "Sheet2", "C4"), Value::Empty);
|
||||
assert_eq!(value(&s, "Sheet1", "B1"), Value::Number(30.0));
|
||||
assert_eq!(formula(&s, "Sheet1", "B1"), "SUM(A1:A2)");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_moved(&s);
|
||||
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
run(&mut s, "file.open", json!({"name": "moved.xlsx", "base64": saved["base64"]}));
|
||||
assert_moved(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_on_all_sheets_follow_cut_but_destination_local_refs_do_not() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "sheet.rename", json!({"name": "Source Data"}));
|
||||
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
|
||||
set(&mut s, "B1", "=SUM($A$1:$A$2)");
|
||||
run(&mut s, "formulas.defineName", json!({"name": "MovedData", "refersTo": "='Source Data'!$A$1:$A$2"}));
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Target's Data"}));
|
||||
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[100], [200]]}));
|
||||
set(&mut s, "B1", "=SUM('Source Data'!$A$1:$A$2)");
|
||||
set(&mut s, "B2", "=SUM(A1:A2)");
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Summary"}));
|
||||
set(&mut s, "A1", "='Source Data'!$A$1+'Source Data'!A$2");
|
||||
set(&mut s, "A2", "=SUM(MovedData)");
|
||||
cut_to(&mut s, "Source Data", "A1:A2", "Target's Data", "C3");
|
||||
|
||||
for (sheet, cell) in [("Source Data", "B1"), ("Target's Data", "B1"), ("Summary", "A1"), ("Summary", "A2")] {
|
||||
assert_eq!(value(&s, sheet, cell), Value::Number(30.0), "{sheet}!{cell}");
|
||||
}
|
||||
assert_eq!(formula(&s, "Source Data", "B1"), "SUM('Target''s Data'!$C$3:$C$4)");
|
||||
assert_eq!(formula(&s, "Summary", "A1"), "'Target''s Data'!$C$3+'Target''s Data'!C$4");
|
||||
assert_eq!(value(&s, "Target's Data", "B2"), Value::Number(300.0));
|
||||
assert_eq!(formula(&s, "Target's Data", "B2"), "SUM(A1:A2)");
|
||||
|
||||
// Recalculation must keep tracking the destination after the move, not just retain a cache.
|
||||
activate(&mut s, "Target's Data");
|
||||
set(&mut s, "C3", "40");
|
||||
for (sheet, cell) in [("Source Data", "B1"), ("Target's Data", "B1"), ("Summary", "A1"), ("Summary", "A2")] {
|
||||
assert_eq!(value(&s, sheet, cell), Value::Number(60.0), "{sheet}!{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_formulas_keep_external_source_refs_and_move_internal_refs_once() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "sheet.rename", json!({"name": "Source Data"}));
|
||||
set(&mut s, "A1", "10");
|
||||
set(&mut s, "D1", "7");
|
||||
set(&mut s, "A2", "=$A$1+$D$1+'Source Data'!A1");
|
||||
set(&mut s, "A3", "=A2*2");
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Destination"}));
|
||||
set(&mut s, "D1", "1000");
|
||||
// Overlapping coordinates on different sheets catch accidental double adjustment.
|
||||
cut_to(&mut s, "Source Data", "A1:A3", "Destination", "A2");
|
||||
|
||||
assert_eq!(value(&s, "Destination", "A2"), Value::Number(10.0));
|
||||
assert_eq!(value(&s, "Destination", "A3"), Value::Number(27.0));
|
||||
assert_eq!(value(&s, "Destination", "A4"), Value::Number(54.0));
|
||||
assert_eq!(value(&s, "Source Data", "D1"), Value::Number(7.0));
|
||||
assert!(formula(&s, "Destination", "A3").contains("'Source Data'!$D$1"));
|
||||
for cell in ["A1", "A2", "A3"] {
|
||||
assert_eq!(value(&s, "Source Data", cell), Value::Empty);
|
||||
}
|
||||
activate(&mut s, "Source Data");
|
||||
set(&mut s, "D1", "8");
|
||||
assert_eq!(value(&s, "Destination", "A3"), Value::Number(28.0));
|
||||
assert_eq!(value(&s, "Destination", "A4"), Value::Number(56.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_sheet_overlapping_cut_still_updates_references_once() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "A1", "values": [[10], [20]]}));
|
||||
set(&mut s, "B1", "=SUM(A1:A2)");
|
||||
set(&mut s, "A3", "=A1+A2");
|
||||
run(&mut s, "edit.cut", json!({"range": "A1:A3"}));
|
||||
run(&mut s, "edit.paste", json!({"at": "A2"}));
|
||||
assert_eq!(value(&s, "Sheet1", "A1"), Value::Empty);
|
||||
assert_eq!(value(&s, "Sheet1", "A2"), Value::Number(10.0));
|
||||
assert_eq!(value(&s, "Sheet1", "A3"), Value::Number(20.0));
|
||||
assert_eq!(value(&s, "Sheet1", "A4"), Value::Number(30.0));
|
||||
assert_eq!(value(&s, "Sheet1", "B1"), Value::Number(30.0));
|
||||
assert_eq!(formula(&s, "Sheet1", "A4"), "A2+A3");
|
||||
assert_eq!(formula(&s, "Sheet1", "B1"), "SUM(A2:A3)");
|
||||
}
|
||||
@@ -56,6 +56,14 @@ pub enum Edit {
|
||||
Delete { axis: Axis, at: u32, count: u32 },
|
||||
/// Cut-paste of a block: references into `from` now point into the block at `to` (top-left).
|
||||
Move { from: RangeRef, to_row: u32, to_col: u32 },
|
||||
/// Insert Cells shifting down (`Rows`) or right (`Cols`): the cells of `range` and those
|
||||
/// below (right of) it move by its height (width), within its columns (rows) only.
|
||||
InsertCells { axis: Axis, range: RangeRef },
|
||||
/// Delete Cells shifting up (`Rows`) or left (`Cols`): `range` goes and the cells below
|
||||
/// (right of) it move back by its height (width), within its columns (rows) only.
|
||||
DeleteCells { axis: Axis, range: RangeRef },
|
||||
/// Cut-paste to another sheet; fully contained references follow the destination sheet too.
|
||||
MoveToSheet { from: RangeRef, to_sheet: String, to_row: u32, to_col: u32 },
|
||||
}
|
||||
|
||||
/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`.
|
||||
@@ -158,6 +166,21 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
|
||||
// Like inserting/deleting lines, for references that lie within the shifted
|
||||
// columns (rows); Excel leaves references reaching outside them as they are.
|
||||
let span = r.range();
|
||||
let (inside, at, count) = match axis {
|
||||
Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()),
|
||||
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
|
||||
};
|
||||
if !inside {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let axis = *axis;
|
||||
let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
adjust_ref(r, &lines)
|
||||
}
|
||||
Edit::Move { from, to_row, to_col } => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
@@ -176,6 +199,26 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
// Whole-row/column references have no anchors, but their indices must move too.
|
||||
moved.kind = match &r.kind {
|
||||
RefKind::Rows(a, aa, b, ba) => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
|
||||
}
|
||||
RefKind::Cols(a, aa, b, ba) => {
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
_ => moved.kind,
|
||||
};
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
Some(moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,14 +240,39 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr {
|
||||
}
|
||||
Expr::Ref(r)
|
||||
}
|
||||
// Sheet-qualified defined name `Old!MyName` is stored as one string.
|
||||
Expr::Name(n) => match n.split_at_checked(old.len()) {
|
||||
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => {
|
||||
Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new)))
|
||||
}
|
||||
_ => Expr::Name(n),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
/// References to a deleted sheet become `#REF!`.
|
||||
pub fn delete_sheet(e: Expr, name: &str) -> Expr {
|
||||
/// References to a deleted sheet become `#REF!`. `order` lists the sheet names (before the deletion) in tab order:
|
||||
/// a 3-D span that loses an end sheet shrinks to the neighbouring surviving sheet, as in Excel.
|
||||
pub fn delete_sheet(e: Expr, name: &str, order: &[String]) -> Expr {
|
||||
let pos = |n: &str| order.iter().position(|o| o.eq_ignore_ascii_case(n));
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) if r.sheet_name().is_some_and(|n| n.eq_ignore_ascii_case(name)) => Expr::Error(CellError::Ref),
|
||||
Expr::Ref(mut r) => {
|
||||
if let SheetSel::Span(a, b) = &r.sheet
|
||||
&& (a.eq_ignore_ascii_case(name) || b.eq_ignore_ascii_case(name))
|
||||
{
|
||||
let (Some(ia), Some(ib)) = (pos(a), pos(b)) else { return Expr::Ref(r) };
|
||||
if ia == ib {
|
||||
return Expr::Error(CellError::Ref);
|
||||
}
|
||||
// The deleted end moves one sheet towards the other end; the span keeps its direction.
|
||||
let inward = |from: usize, to: usize| order.get(if from < to { from + 1 } else { from.saturating_sub(1) });
|
||||
let (na, nb) = if a.eq_ignore_ascii_case(name) { (inward(ia, ib), order.get(ib)) } else { (order.get(ia), inward(ib, ia)) };
|
||||
let (Some(na), Some(nb)) = (na, nb) else { return Expr::Error(CellError::Ref) };
|
||||
r.sheet = if na.eq_ignore_ascii_case(nb) { SheetSel::Named(na.clone()) } else { SheetSel::Span(na.clone(), nb.clone()) };
|
||||
}
|
||||
Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ pub enum UnOp {
|
||||
Percent,
|
||||
/// Implicit intersection `@`.
|
||||
At,
|
||||
/// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the
|
||||
/// cell (`ANCHORARRAY` in files).
|
||||
Spill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Localized aliases accepted at user-entry boundaries; file syntax stays English.
|
||||
|
||||
use crate::{Expr, ParseError, parser};
|
||||
|
||||
/// Formula-entry syntax selected by the interface language. Other interface languages
|
||||
/// currently use the canonical English function table until a translation table is supplied.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum FormulaLocale {
|
||||
#[default]
|
||||
En,
|
||||
Es,
|
||||
}
|
||||
|
||||
const SPANISH: &[(&str, &str)] = &[
|
||||
("SUM", "SUMA"),
|
||||
("AVERAGE", "PROMEDIO"),
|
||||
("IF", "SI"),
|
||||
("COUNT", "CONTAR"),
|
||||
("SUMIF", "SUMAR.SI"),
|
||||
("COUNTIF", "CONTAR.SI"),
|
||||
("VLOOKUP", "BUSCARV"),
|
||||
("TODAY", "HOY"),
|
||||
("TRUE", "VERDADERO"),
|
||||
("FALSE", "FALSO"),
|
||||
];
|
||||
|
||||
impl FormulaLocale {
|
||||
pub fn list_separator(self) -> char {
|
||||
if self == Self::Es { ';' } else { ',' }
|
||||
}
|
||||
pub fn decimal_separator(self) -> char {
|
||||
if self == Self::Es { ',' } else { '.' }
|
||||
}
|
||||
pub fn array_column_separator(self) -> char {
|
||||
if self == Self::Es { '\\' } else { ',' }
|
||||
}
|
||||
pub fn function_name(self, canonical: &str) -> &str {
|
||||
if self == Self::Es {
|
||||
SPANISH.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, es)| *es).unwrap_or(canonical)
|
||||
} else {
|
||||
canonical
|
||||
}
|
||||
}
|
||||
pub fn canonical_name(self, localized: &str) -> &str {
|
||||
if self == Self::Es {
|
||||
SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized)
|
||||
} else {
|
||||
localized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses UI input using the selected function table and separators. Existing names and
|
||||
/// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`].
|
||||
pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {
|
||||
let mut expr = parser::parse_syntax(src, locale)?;
|
||||
if locale == FormulaLocale::Es {
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
/// Prints canonical expressions for editing. Name collisions retain the canonical spelling
|
||||
/// so displaying then committing a formula never changes a defined-name reference.
|
||||
pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<String, ParseError> {
|
||||
// Check before cloning/printing: a flat operator chain can produce a deep AST even
|
||||
// though the parser itself did not recurse deeply.
|
||||
let mut pending = vec![(expr, 0usize)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
if depth > parser::MAX_DEPTH {
|
||||
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
|
||||
}
|
||||
match node {
|
||||
Expr::Unary(_, child) | Expr::Paren(child) => pending.push((child, depth + 1)),
|
||||
Expr::Binary(_, left, right) => {
|
||||
pending.push((left, depth + 1));
|
||||
pending.push((right, depth + 1));
|
||||
}
|
||||
Expr::Call(_, args) => pending.extend(args.iter().map(|e| (e, depth + 1))),
|
||||
Expr::Invoke(callee, args) => {
|
||||
pending.push((callee, depth + 1));
|
||||
pending.extend(args.iter().map(|e| (e, depth + 1)));
|
||||
}
|
||||
Expr::Array(rows) => pending.extend(rows.iter().flatten().map(|e| (e, depth + 1))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if locale == FormulaLocale::En {
|
||||
return Ok(crate::print(expr));
|
||||
}
|
||||
let mut expr = expr.clone();
|
||||
normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?;
|
||||
let canonical = crate::print(&expr);
|
||||
let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let mut out = String::new();
|
||||
let mut array_depth = 0usize;
|
||||
for token in tokens {
|
||||
use crate::lexer::Tok;
|
||||
let text = canonical.get(token.start..token.end).unwrap_or("");
|
||||
match token.tok {
|
||||
Tok::LBrace => {
|
||||
array_depth += 1;
|
||||
out.push_str(text);
|
||||
}
|
||||
Tok::RBrace => {
|
||||
array_depth = array_depth.saturating_sub(1);
|
||||
out.push_str(text);
|
||||
}
|
||||
Tok::Number(_) => out.push_str(&text.replace('.', &locale.decimal_separator().to_string())),
|
||||
Tok::Comma => out.push(if array_depth > 0 { locale.array_column_separator() } else { locale.list_separator() }),
|
||||
_ => out.push_str(text),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn boolean_alias(name: &str) -> Option<bool> {
|
||||
if name.eq_ignore_ascii_case("VERDADERO") {
|
||||
Some(true)
|
||||
} else if name.eq_ignore_ascii_case("FALSO") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {
|
||||
if depth > parser::MAX_DEPTH {
|
||||
return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });
|
||||
}
|
||||
let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name);
|
||||
match expr {
|
||||
Expr::Bool(value) if display => {
|
||||
let alias = if *value { "VERDADERO" } else { "FALSO" };
|
||||
if !bound(alias, bindings) {
|
||||
*expr = Expr::Name(alias.into());
|
||||
}
|
||||
}
|
||||
Expr::Name(name) if !display && !bound(name, bindings) => {
|
||||
if let Some(value) = boolean_alias(name) {
|
||||
*expr = Expr::Bool(value);
|
||||
}
|
||||
}
|
||||
Expr::Call(name, args) if name == "LET" && args.len() >= 3 && !args.len().is_multiple_of(2) => {
|
||||
let saved = bindings.len();
|
||||
if let Some((body, pairs)) = args.split_last_mut() {
|
||||
for pair in pairs.chunks_mut(2) {
|
||||
if let [declaration, value] = pair {
|
||||
// The new name is not in scope in its own value expression.
|
||||
normalize(value, known_name, bindings, depth + 1, display)?;
|
||||
if let Expr::Name(name) = declaration {
|
||||
bindings.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
normalize(body, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
bindings.truncate(saved);
|
||||
}
|
||||
Expr::Call(name, args) if name == "LAMBDA" => {
|
||||
let saved = bindings.len();
|
||||
if let Some((body, params)) = args.split_last_mut() {
|
||||
for param in params {
|
||||
if let Expr::Name(name) = param {
|
||||
bindings.push(name.clone());
|
||||
}
|
||||
}
|
||||
normalize(body, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
bindings.truncate(saved);
|
||||
}
|
||||
Expr::Call(name, args) => {
|
||||
let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) };
|
||||
if !bound(name, bindings) && (!display || !bound(alias, bindings)) {
|
||||
*name = alias.to_string();
|
||||
}
|
||||
for arg in args {
|
||||
normalize(arg, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
}
|
||||
Expr::Invoke(callee, args) => {
|
||||
normalize(callee, known_name, bindings, depth + 1, display)?;
|
||||
for arg in args {
|
||||
normalize(arg, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
}
|
||||
Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?,
|
||||
Expr::Binary(_, left, right) => {
|
||||
normalize(left, known_name, bindings, depth + 1, display)?;
|
||||
normalize(right, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
Expr::Array(rows) if display => {
|
||||
for row in rows {
|
||||
for child in row {
|
||||
normalize(child, known_name, bindings, depth + 1, display)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Array elements have already been parsed as literals. Sheet/structured references
|
||||
// contain user-defined labels and must not be translated.
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Tokenizer for Excel formulas (A1 style, en-US separators).
|
||||
//! Tokenizer for Excel formulas (A1 style, canonical or selected UI separators).
|
||||
//!
|
||||
//! Token spans are byte offsets into the source, which the editor uses to colour references.
|
||||
|
||||
@@ -51,12 +51,18 @@ fn is_word_char(c: char) -> bool {
|
||||
|
||||
/// Tokenizes formula text (without the leading `=`).
|
||||
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
tokenize_locale(src, crate::FormulaLocale::En)
|
||||
}
|
||||
|
||||
/// Tokenizes UI syntax while retaining byte spans in the original text.
|
||||
pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<Token>, LexError> {
|
||||
let chars: Vec<(usize, char)> = src.char_indices().collect();
|
||||
let n = chars.len();
|
||||
let pos_of = |i: usize| chars.get(i).map(|c| c.0).unwrap_or(src.len());
|
||||
let ch = |i: usize| chars.get(i).map(|c| c.1);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut array_depth = 0usize;
|
||||
while i < n {
|
||||
let start = pos_of(i);
|
||||
let Some(c) = ch(i) else { break };
|
||||
@@ -124,6 +130,11 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
push(&mut out, Tok::Sheet(a, b), j + 1);
|
||||
i = j + 1;
|
||||
}
|
||||
'#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => {
|
||||
// Spill range operator right after a reference: `A1#`.
|
||||
push(&mut out, Tok::Op("#"), i + 1);
|
||||
i += 1;
|
||||
}
|
||||
'#' => {
|
||||
// Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct).
|
||||
let rest = src.get(start..).unwrap_or("");
|
||||
@@ -167,7 +178,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
push(&mut out, Tok::Struct(inner), j);
|
||||
i = j;
|
||||
}
|
||||
'0'..='9' | '.' if c != '.' || ch(i + 1).is_some_and(|d| d.is_ascii_digit()) => {
|
||||
c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
// Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers
|
||||
// followed by ':' and digits are row refs: lex as Word.
|
||||
let mut j = i;
|
||||
@@ -188,7 +199,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if ch(j) == Some('.') {
|
||||
if ch(j) == Some(locale.decimal_separator()) {
|
||||
j += 1;
|
||||
while ch(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||
j += 1;
|
||||
@@ -207,7 +218,10 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
}
|
||||
}
|
||||
let text = src.get(start..pos_of(j)).unwrap_or("");
|
||||
let v: f64 = text.parse().map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
|
||||
let v: f64 = text
|
||||
.replace(locale.decimal_separator(), ".")
|
||||
.parse()
|
||||
.map_err(|_| LexError { msg: format!("bad number `{text}`"), pos: start })?;
|
||||
push(&mut out, Tok::Number(v), j);
|
||||
i = j;
|
||||
}
|
||||
@@ -220,14 +234,16 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
i += 1;
|
||||
}
|
||||
'{' => {
|
||||
array_depth += 1;
|
||||
push(&mut out, Tok::LBrace, i + 1);
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
array_depth = array_depth.saturating_sub(1);
|
||||
push(&mut out, Tok::RBrace, i + 1);
|
||||
i += 1;
|
||||
}
|
||||
',' => {
|
||||
c if (array_depth == 0 && c == locale.list_separator()) || (array_depth > 0 && c == locale.array_column_separator()) => {
|
||||
push(&mut out, Tok::Comma, i + 1);
|
||||
i += 1;
|
||||
}
|
||||
@@ -268,7 +284,7 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
}
|
||||
c if is_word_char(c) => {
|
||||
let mut j = i;
|
||||
while ch(j).is_some_and(is_word_char) {
|
||||
while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
j += 1;
|
||||
}
|
||||
let w: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();
|
||||
@@ -278,13 +294,13 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
|
||||
} else if ch(j) == Some(':') && {
|
||||
// Sheet1:Sheet3!A1 — look ahead for word then '!'
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(is_word_char) {
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
k += 1;
|
||||
}
|
||||
k > j + 1 && ch(k) == Some('!')
|
||||
} {
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(is_word_char) {
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
k += 1;
|
||||
}
|
||||
let w2: String = chars.get(j + 1..k).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default();
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
pub mod adjust;
|
||||
pub mod ast;
|
||||
mod input;
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
pub mod printer;
|
||||
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
pub use input::{FormulaLocale, parse_input, print_input};
|
||||
pub use parser::{ParseError, normalize_function_name, parse};
|
||||
pub use printer::{print, print_r1c1, quote_sheet};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
||||
use gridcraft_core::{CellError, MAX_ROWS};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::lexer::{Tok, Token, tokenize};
|
||||
use crate::lexer::{Tok, Token, tokenize_locale};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
#[error("{msg}")]
|
||||
@@ -15,15 +15,19 @@ pub struct ParseError {
|
||||
pub pos: usize,
|
||||
}
|
||||
|
||||
const MAX_DEPTH: usize = 256;
|
||||
pub(crate) const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
|
||||
/// for `=`.
|
||||
pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
parse_syntax(src, crate::FormulaLocale::En)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
|
||||
let body = src.strip_prefix('=').unwrap_or(src);
|
||||
let toks = tokenize(body).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let toks = significant_spaces(toks);
|
||||
let mut p = Parser { toks, i: 0, depth: 0 };
|
||||
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
|
||||
let e = p.expr(0, false)?;
|
||||
match p.peek() {
|
||||
Tok::Eof => Ok(e),
|
||||
@@ -62,6 +66,7 @@ struct Parser {
|
||||
toks: Vec<Token>,
|
||||
i: usize,
|
||||
depth: usize,
|
||||
input: bool,
|
||||
}
|
||||
|
||||
fn word_is_cols(w: &str) -> Option<(u32, bool)> {
|
||||
@@ -223,7 +228,14 @@ impl Parser {
|
||||
Tok::LBrace => self.array(),
|
||||
Tok::Func(name) => {
|
||||
let name = normalize_function_name(&name);
|
||||
let args = self.args()?;
|
||||
let mut args = self.args()?;
|
||||
// Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`.
|
||||
if name == "ANCHORARRAY"
|
||||
&& let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice()
|
||||
&& let Some(r) = args.pop()
|
||||
{
|
||||
return Ok(Expr::Unary(UnOp::Spill, Box::new(r)));
|
||||
}
|
||||
Ok(Expr::Call(name, args))
|
||||
}
|
||||
Tok::Sheet(a, b) => {
|
||||
@@ -263,7 +275,12 @@ impl Parser {
|
||||
self.next();
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) }));
|
||||
}
|
||||
return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }));
|
||||
let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) });
|
||||
if *self.peek() == Tok::Op("#") {
|
||||
self.next();
|
||||
return Ok(Expr::Unary(UnOp::Spill, Box::new(cell)));
|
||||
}
|
||||
return Ok(cell);
|
||||
}
|
||||
let second = match self.peek_at(1) {
|
||||
Tok::Word(w2) => Some(w2.clone()),
|
||||
@@ -312,6 +329,9 @@ impl Parser {
|
||||
},
|
||||
Tok::Text(s) => Expr::Text(Arc::from(s)),
|
||||
Tok::Bool(b) => Expr::Bool(b),
|
||||
Tok::Word(w) if self.input => crate::input::boolean_alias(&w)
|
||||
.map(Expr::Bool)
|
||||
.ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?,
|
||||
Tok::Error(e) => Expr::Error(e),
|
||||
t => return Err(self.err(format!("array constants may only contain constants, found {}", describe(&t)))),
|
||||
};
|
||||
|
||||
@@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
write_expr(s, x, r1c1);
|
||||
s.push('%');
|
||||
}
|
||||
UnOp::Spill => {
|
||||
write_expr(s, x, r1c1);
|
||||
// A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`.
|
||||
if !matches!(**x, Expr::Error(_)) {
|
||||
s.push('#');
|
||||
}
|
||||
}
|
||||
},
|
||||
Expr::Binary(op, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
|
||||
@@ -52,6 +52,34 @@ fn normalises() {
|
||||
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_operator() {
|
||||
for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] {
|
||||
assert_eq!(rt(f), f);
|
||||
}
|
||||
assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _)));
|
||||
// Files spell it ANCHORARRAY.
|
||||
assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#");
|
||||
assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)");
|
||||
// Only a cell can spill.
|
||||
assert!(parse("A1:B2#").is_err());
|
||||
assert!(parse("Name#").is_err());
|
||||
// Deleting the anchor's sheet leaves #REF!.
|
||||
assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data", &[])), "SUM(#REF!)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_sheet_shrinks_3d_span() {
|
||||
let order: Vec<String> = ["Sheet1", "Sheet2", "Sheet3", "Results"].iter().map(|s| s.to_string()).collect();
|
||||
let del = |f: &str, n: &str| print(&crate::adjust::delete_sheet(parse(f).unwrap(), n, &order));
|
||||
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet1"), "SUM(Sheet2:Sheet3!A1)");
|
||||
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet3"), "SUM(Sheet1:Sheet2!A1)");
|
||||
assert_eq!(del("SUM(Sheet3:Sheet1!A1)", "Sheet1"), "SUM(Sheet3:Sheet2!A1)");
|
||||
assert_eq!(del("SUM(Sheet1:Sheet3!A1)", "Sheet2"), "SUM(Sheet1:Sheet3!A1)");
|
||||
assert_eq!(del("SUM(Sheet1:Sheet2!A1)", "Sheet1"), "SUM(Sheet2!A1)");
|
||||
assert_eq!(del("Sheet1!A1+Sheet3!A1", "Sheet1"), "#REF!+Sheet3!A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence() {
|
||||
let e = parse("1+2*3").unwrap();
|
||||
@@ -120,6 +148,24 @@ fn insert_delete() {
|
||||
assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells() {
|
||||
let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap();
|
||||
let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e));
|
||||
// Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't.
|
||||
let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") };
|
||||
assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)");
|
||||
// Insert B2 shifting right.
|
||||
let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") };
|
||||
assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)");
|
||||
// Delete A3:A4 shifting up: references into it become #REF!, those below move up.
|
||||
let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") };
|
||||
assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)");
|
||||
// Delete B1:C2 shifting left.
|
||||
let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") };
|
||||
assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_renames() {
|
||||
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 };
|
||||
@@ -129,6 +175,37 @@ fn moves_and_renames() {
|
||||
assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_sheet_updates_qualified_names() {
|
||||
let e = parse("Data!MyName+MyName+Other!MyName").unwrap();
|
||||
assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName");
|
||||
let e = parse("Data!MyName").unwrap();
|
||||
assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_between_sheets() {
|
||||
let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_sheet: "New Name".into(), to_row: 9, to_col: 3 };
|
||||
for (host, input, expected) in [
|
||||
("Source", "A1+$B$2+C3+SUM(A1:B2)", "'New Name'!D10+'New Name'!$E$11+C3+SUM('New Name'!D10:E11)"),
|
||||
("Other", "A1+sOuRcE!$B2+Other!B2", "A1+'New Name'!$E11+Other!B2"),
|
||||
("Source", "SUM(A1:C3)+SUM(Source:Other!A1)", "SUM(A1:C3)+SUM(Source:Other!A1)"),
|
||||
] {
|
||||
assert_eq!(print(&adjust(parse(input).unwrap(), host, "Source", &mv)), expected);
|
||||
}
|
||||
let edge = Edit::MoveToSheet {
|
||||
from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(),
|
||||
to_sheet: "New Name".into(),
|
||||
to_row: gridcraft_core::MAX_ROWS - 1,
|
||||
to_col: 3,
|
||||
};
|
||||
assert_eq!(print(&adjust(parse("B2").unwrap(), "Source", "Source", &edge)), "#REF!");
|
||||
for (from, input, row, col, expected) in [("A:A", "$A:$A", 0, 2, "Dest!$C:$C"), ("1:1", "$1:$1", 2, 0, "Dest!$3:$3")] {
|
||||
let mv = Edit::MoveToSheet { from: gridcraft_core::RangeRef::parse(from).unwrap(), to_sheet: "Dest".into(), to_row: row, to_col: col };
|
||||
assert_eq!(print(&adjust(parse(input).unwrap(), "Source", "Source", &mv)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r1c1() {
|
||||
let e = parse("A1+$B$2+SUM(C:C)").unwrap();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input};
|
||||
|
||||
fn input(src: &str) -> String {
|
||||
print(&parse_input(src, FormulaLocale::Es, |_| false).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_aliases_and_argument_separators() {
|
||||
for (src, expected) in [
|
||||
("=suma(A1:A10)", "SUM(A1:A10)"),
|
||||
("PROMEDIO(A1:A10)", "AVERAGE(A1:A10)"),
|
||||
("SI(A1>0;\"Sí\";\"No\")", "IF(A1>0,\"Sí\",\"No\")"),
|
||||
("CONTAR(A1:A10)", "COUNT(A1:A10)"),
|
||||
("SUMAR.SI(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"),
|
||||
("CONTAR.SI(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"),
|
||||
("BUSCARV(A2;D:E;2;FALSO)", "VLOOKUP(A2,D:E,2,FALSE)"),
|
||||
("HOY()", "TODAY()"),
|
||||
("=verdadero", "TRUE"),
|
||||
("=falso", "FALSE"),
|
||||
("VERDADERO()", "TRUE()"),
|
||||
("FALSO()", "FALSE()"),
|
||||
("SUMA(1;SUM(2;3);PROMEDIO(4;6))", "SUM(1,SUM(2,3),AVERAGE(4,6))"),
|
||||
("SI(VERDADERO;;)", "IF(TRUE,,)"),
|
||||
("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"),
|
||||
] {
|
||||
assert_eq!(input(src), expected, "{src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_input_preserves_reference_and_array_syntax() {
|
||||
for (src, expected) in [
|
||||
("SUMA({1\\2;3\\4};5)", "SUM({1,2;3,4},5)"),
|
||||
("{VERDADERO\\FALSO;TRUE\\FALSE}", "{TRUE,FALSE;TRUE,FALSE}"),
|
||||
("SUM((A1:A3;C1:C3))", "SUM((A1:A3,C1:C3))"),
|
||||
("\"SUMA;FALSO\"&\"a\"\"b\"", "\"SUMA;FALSO\"&\"a\"\"b\""),
|
||||
("'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO", "'SUMA;FALSO'!A1+SUMA!B2+SUMA!FALSO"),
|
||||
("SUMA[CONTAR.SI]+FALSO[SI]+CONTAR", "SUMA[CONTAR.SI]+FALSO[SI]+CONTAR"),
|
||||
] {
|
||||
assert_eq!(input(src), expected, "{src}");
|
||||
}
|
||||
for bad in [r"SUMA({1;2\3})", "{SUMA(A1)}", "{FALSO;}"] {
|
||||
assert!(parse_input(bad, FormulaLocale::Es, |_| false).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(print(&parse("SUMA(1,2)+FALSO").unwrap()), "SUMA(1,2)+FALSO");
|
||||
assert!(parse("SUM(1;2)").is_err());
|
||||
assert!(parse("{VERDADERO}").is_err());
|
||||
// Pratt parsing can produce a deep left-hand AST without deep parser recursion.
|
||||
let long_chain = "FALSO+".repeat(300) + "VERDADERO";
|
||||
assert!(parse_input(&long_chain, FormulaLocale::Es, |_| false).is_err());
|
||||
assert!(print_input(&parse(&long_chain).unwrap(), FormulaLocale::Es, |_| false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_input_preserves_defined_and_local_names() {
|
||||
let known = |name: &str| ["SUMA", "FALSO"].iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||
let expr = parse_input("SUMA(2;3)+FALSO+SI(VERDADERO;4;5)", FormulaLocale::Es, known).unwrap();
|
||||
assert_eq!(print(&expr), "SUMA(2,3)+FALSO+IF(TRUE,4,5)");
|
||||
for (src, expected) in [
|
||||
("LET(FALSO;7;FALSO)", "LET(FALSO,7,FALSO)"),
|
||||
("LET(falso;FALSO;SI(falso;1;2))", "LET(falso,FALSE,IF(falso,1,2))"),
|
||||
("LET(SUMA;LAMBDA(x;x+1);SUMA(2))", "LET(SUMA,LAMBDA(x,x+1),SUMA(2))"),
|
||||
("LET(falso;7;valor;FALSO;valor)", "LET(falso,7,valor,FALSO,valor)"),
|
||||
("LAMBDA(FALSO;FALSO)(7)", "LAMBDA(FALSO,FALSO)(7)"),
|
||||
("LAMBDA(SUMA;SUMA(2))(LAMBDA(x;x+1))", "LAMBDA(SUMA,SUMA(2))(LAMBDA(x,x+1))"),
|
||||
("LET(FALSO;7;FALSO)+FALSO", "LET(FALSO,7,FALSO)+FALSE"),
|
||||
("LET(FALSO;7;LAMBDA(x;FALSO+x)(1))", "LET(FALSO,7,LAMBDA(x,FALSO+x)(1))"),
|
||||
] {
|
||||
assert_eq!(input(src), expected, "{src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_controls_decimal_list_array_separators_and_display() {
|
||||
let es = FormulaLocale::Es;
|
||||
let en = FormulaLocale::En;
|
||||
let expr = parse_input("SI(VERDADERO;SUMA(1,5;2,25);0)", es, |_| false).unwrap();
|
||||
assert_eq!(print(&expr), "IF(TRUE,SUM(1.5,2.25),0)");
|
||||
assert_eq!(print_input(&expr, es, |_| false).unwrap(), "SI(VERDADERO;SUMA(1,5;2,25);0)");
|
||||
assert_eq!(print_input(&expr, en, |_| false).unwrap(), print(&expr));
|
||||
assert!(parse_input("SUM(1;2)", en, |_| false).is_err());
|
||||
assert_eq!(print(&parse_input("SUMA(1,2)+FALSO", en, |_| false).unwrap()), "SUMA(1,2)+FALSO");
|
||||
assert!(parse_input("SUMA(1.5;2)", es, |_| false).is_err());
|
||||
let expr = parse("SUM({1.5,2.25;3,4})+SUM((A1:A3,C1:C3))").unwrap();
|
||||
let shown = print_input(&expr, es, |_| false).unwrap();
|
||||
assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr));
|
||||
let expr = parse("LET(SUMA,LAMBDA(x,x+1),SUM(SUMA(2),FALSE))").unwrap();
|
||||
let shown = print_input(&expr, es, |n| n == "FALSO").unwrap();
|
||||
assert_eq!(shown, "LET(SUMA;LAMBDA(x;x+1);SUM(SUMA(2);FALSE))");
|
||||
assert_eq!(print(&parse_input(&shown, es, |n| n == "FALSO").unwrap()), print(&expr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_display_round_trips_text_containing_separators() {
|
||||
let es = FormulaLocale::Es;
|
||||
let expr = parse(r#"IF(A1="a,b;c",CONCAT("1.5",",",";"),SUM(0.25,A1:A3 B2))"#).unwrap();
|
||||
let shown = print_input(&expr, es, |_| false).unwrap();
|
||||
assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#);
|
||||
assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr));
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use gridcraft_core::parse::parse_number_text;
|
||||
use gridcraft_core::{CellError, Value, compare_text};
|
||||
use gridcraft_core::{CellError, Value, compare_numbers, compare_text};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Op {
|
||||
@@ -113,7 +113,7 @@ impl Criterion {
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, x.partial_cmp(n).unwrap_or(Ordering::Equal)),
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
|
||||
compare_text(p, t) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => a == b,
|
||||
(Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
|
||||
(Value::Bool(a), Value::Bool(b)) => a == b,
|
||||
(Value::Error(a), Value::Error(b)) => a == b,
|
||||
(Value::Empty, Value::Empty) => true,
|
||||
@@ -294,6 +294,8 @@ mod tests {
|
||||
assert!(!m("<b", Value::Number(1.0)));
|
||||
assert!(m("#N/A", Value::Error(CellError::NA)));
|
||||
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
|
||||
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Date and time functions.
|
||||
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd};
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
|
||||
use gridcraft_core::parse::parse_input;
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
@@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64);
|
||||
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
|
||||
num_val(ymd_serial(sys, y, mo, dd)? as f64)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
|
||||
}
|
||||
|
||||
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -652,6 +652,36 @@ mod tests {
|
||||
is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fictitious_1900_02_29() {
|
||||
// Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month
|
||||
// overflow through it.
|
||||
assert_eq!(d(1900.0, 2.0, 29.0), 60.0);
|
||||
assert_eq!(d(1900.0, 3.0, 0.0), 60.0);
|
||||
assert_eq!(d(1900.0, 2.0, 28.0), 59.0);
|
||||
assert_eq!(d(1900.0, 3.0, 1.0), 61.0);
|
||||
assert_eq!(d(1900.0, 2.0, 30.0), 61.0);
|
||||
assert_eq!(d(1900.0, 1.0, 60.0), 60.0);
|
||||
assert_eq!(d(1900.0, 4.0, -31.0), 60.0);
|
||||
assert_eq!(d(1901.0, -10.0, 29.0), 60.0);
|
||||
assert_eq!(d(0.0, 2.0, 29.0), 60.0);
|
||||
assert_eq!(d(1900.0, 1.0, 0.0), 0.0);
|
||||
close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0);
|
||||
close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0);
|
||||
close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0);
|
||||
close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0);
|
||||
is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value);
|
||||
// EDATE and EOMONTH land on it as the end of February 1900.
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0));
|
||||
close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datedif_units() {
|
||||
let s = n(d(2001.0, 6.0, 1.0));
|
||||
@@ -755,5 +785,16 @@ mod tests {
|
||||
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
|
||||
let spec = crate::lookup("YEAR").unwrap();
|
||||
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
|
||||
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
|
||||
let spec = crate::lookup("DATE").unwrap();
|
||||
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);
|
||||
close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0);
|
||||
close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0);
|
||||
is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num);
|
||||
let spec = crate::lookup("EOMONTH").unwrap();
|
||||
close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0);
|
||||
let spec = crate::lookup("DATEVALUE").unwrap();
|
||||
close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0);
|
||||
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R<Vec<u64>> {
|
||||
v.iter()
|
||||
.map(|x| {
|
||||
let t = x.trunc();
|
||||
if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) }
|
||||
if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1006,8 +1006,9 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(range.iter().filter(|v| c.matches(v)).count() as f64)
|
||||
}
|
||||
|
||||
/// SUMIF / AVERAGEIF: matched numbers from the sum range, which takes the criteria range's
|
||||
/// shape anchored at its own top-left.
|
||||
/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions.
|
||||
/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell;
|
||||
/// the evaluator does that resizing (it has the references), so cells line up one to one here.
|
||||
fn if_numbers(a: &[Arg]) -> R<Vec<f64>> {
|
||||
let range = as_array(&crate::util::arg(a, 0)?.value);
|
||||
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
|
||||
|
||||
@@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
|
||||
let p = num(a, 1)?;
|
||||
let s1 = num(a, 2)?.trunc();
|
||||
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
|
||||
if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
||||
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
if s2 - s1 > 10_000.0 {
|
||||
@@ -1632,7 +1632,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
"Population variance counting text as 0 and logicals as 1/0.",
|
||||
|a: &[Arg], _c: C| num_val(var_p(&numbers_a(a)?)?)
|
||||
),
|
||||
f!("MIN", 1, VAR, Statistical, A, "MIN(number1, [number2], ...)", "Smallest number (0 when there are none).", |a: &[Arg], _c: C| num_val(
|
||||
f!("MIN", 0, VAR, Statistical, A, "MIN(number1, [number2], ...)", "Smallest number (0 when there are none).", |a: &[Arg], _c: C| num_val(
|
||||
min_of(&numbers(a)?)
|
||||
)),
|
||||
f!(
|
||||
@@ -2290,6 +2290,7 @@ mod tests {
|
||||
fn min_max_order() {
|
||||
close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0);
|
||||
close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0);
|
||||
close(ev("MIN", vec![]), 0.0);
|
||||
close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0);
|
||||
close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0);
|
||||
close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0);
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> {
|
||||
|
||||
/// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`).
|
||||
pub(crate) fn has(args: &[Arg], i: usize) -> bool {
|
||||
matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref))
|
||||
matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref)
|
||||
}
|
||||
|
||||
/// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses.
|
||||
@@ -235,7 +235,9 @@ pub(crate) fn round_half_away(x: f64, digits: i32) -> f64 {
|
||||
// value = 0.d1d2…d15 × 10^(e+1); keep the first `keep` digits.
|
||||
let keep = e + 1 + digits;
|
||||
if keep >= d.len() as i64 {
|
||||
return x;
|
||||
// Nothing to round off, but Excel still keeps only 15 significant digits, which drops binary noise
|
||||
// such as 0.1 + 0.2 = 0.30000000000000004.
|
||||
return to_sig_digits(x, d.len());
|
||||
}
|
||||
if keep < 0 {
|
||||
return 0.0;
|
||||
@@ -416,6 +418,8 @@ mod tests {
|
||||
assert_eq!(round_half_away(1234.5678, -2), 1200.0);
|
||||
assert_eq!(round_half_away(0.285, 2), 0.29);
|
||||
assert_eq!(round_half_away(1.005, 2), 1.01);
|
||||
assert_eq!(round_half_away(0.1 + 0.2, 15), 0.3);
|
||||
assert_eq!(round_half_away(0.1 + 0.2, 20), 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,12 +34,8 @@ impl Headless {
|
||||
}
|
||||
|
||||
fn execute(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = self.session.execute(id, params).map_err(|e| e.to_string());
|
||||
// Programmatic calls never open dialogs; drop anything a command queued for a UI.
|
||||
for req in self.session.take_ui_requests() {
|
||||
log::debug!("headless: ignoring UI request {req:?}");
|
||||
}
|
||||
r
|
||||
// Programmatic calls never open dialogs; anything a command queued for a UI is dropped.
|
||||
self.session.execute_headless(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands(&self, params: &Value) -> Value {
|
||||
|
||||
@@ -381,3 +381,14 @@ fn remote_backend_over_tcp() {
|
||||
handle.join().unwrap();
|
||||
assert!(Remote::connect("127.0.0.1:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_commands_without_a_file_fail() {
|
||||
let mut s = server();
|
||||
// `file.save` on Book1, which has no file yet, would ask for one too.
|
||||
for command in ["file.open", "file.saveAs", "file.save", "data.getData", "data.fromTextCsv"] {
|
||||
let msg = fails(&mut s, "execute_command", json!({"command": command}));
|
||||
assert!(msg.contains("missing") && msg.contains("never open dialogs"), "{command}: {msg}");
|
||||
fails(&mut s, "execute_command", json!({"command": command, "params": {}}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open_workbook",
|
||||
"Open workbook",
|
||||
"Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.",
|
||||
"Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
|
||||
obj(json!({"path": string("File path")}), &["path"]),
|
||||
false,
|
||||
),
|
||||
|
||||
@@ -42,6 +42,16 @@ impl Formula {
|
||||
}
|
||||
}
|
||||
|
||||
/// A static picture stored as cell content, independent of floating drawing anchors.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CellPicture {
|
||||
/// Encoded PNG/JPEG bytes, base64 in JSON. Sheet entries share the payload through Arc.
|
||||
#[serde(with = "crate::b64")]
|
||||
pub data: Vec<u8>,
|
||||
pub mime: String,
|
||||
pub alt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Cell {
|
||||
/// Constant value, or the cached result of the formula.
|
||||
@@ -75,4 +85,12 @@ impl Cell {
|
||||
v => v.display(),
|
||||
}
|
||||
}
|
||||
/// What the formula bar shows when not editing: [`input_text`](Self::input_text), with a
|
||||
/// legacy array formula in braces (`{=A1:A2*2}`).
|
||||
pub fn bar_text(&self) -> String {
|
||||
match &self.formula {
|
||||
Some(f) if f.array.is_some() => format!("{{={}}}", f.text),
|
||||
_ => self.input_text(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Serde helpers for maps keyed by `CellRef`. JSON object keys must be strings, so the keys are
|
||||
//! written as `A1`-style text.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use serde::de::Error;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer, V: Serialize>(map: &BTreeMap<CellRef, V>, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_map(map.iter().map(|(k, v)| (k.a1(), v)))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>, V: Deserialize<'de>>(d: D) -> Result<BTreeMap<CellRef, V>, D::Error> {
|
||||
let raw = BTreeMap::<String, V>::deserialize(d)?;
|
||||
let mut out = BTreeMap::new();
|
||||
for (k, v) in raw {
|
||||
let cell = CellRef::parse(&k).ok_or_else(|| D::Error::custom(format!("invalid cell reference `{k}`")))?;
|
||||
out.insert(cell, v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Comment, Hyperlink, Sheet};
|
||||
use gridcraft_core::CellRef;
|
||||
|
||||
#[test]
|
||||
fn comments_and_hyperlinks_survive_json() {
|
||||
let mut s = Sheet::new("S");
|
||||
let a1 = CellRef::new(0, 0);
|
||||
let c3 = CellRef::new(2, 2);
|
||||
s.comments.insert(
|
||||
a1,
|
||||
Comment {
|
||||
author: "Example".into(),
|
||||
text: "note".into(),
|
||||
replies: vec![("B".into(), "r".into())],
|
||||
threaded: true,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
},
|
||||
);
|
||||
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
|
||||
let json = serde_json::to_string(&s).expect("serialize");
|
||||
let back: Sheet = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back.comments, s.comments);
|
||||
assert_eq!(back.hyperlinks, s.hyperlinks);
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,20 @@ pub struct Hyperlink {
|
||||
|
||||
// ---------------------------------------------------------------- drawing objects
|
||||
|
||||
/// How a floating object is pinned to the grid when its cells move or resize. Mirrors Excel's
|
||||
/// Format ▸ Size and Properties ▸ Properties, stored in the file as the anchor's `editAs`:
|
||||
/// `twoCell` (absent `editAs`), `oneCell` and `absolute`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AnchorMode {
|
||||
/// Move and size with cells (Excel's default; `<xdr:twoCellAnchor>`).
|
||||
#[default]
|
||||
MoveAndSize,
|
||||
/// Move but don't size with cells (`<xdr:twoCellAnchor editAs="oneCell">` / `<xdr:oneCellAnchor>`).
|
||||
MoveOnly,
|
||||
/// Don't move or size with cells (`editAs="absolute"` / `<xdr:absoluteAnchor>`).
|
||||
Absolute,
|
||||
}
|
||||
|
||||
/// Position of a floating object: top-left cell plus offset, size in points.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
@@ -298,6 +312,9 @@ pub struct Anchor {
|
||||
pub dy: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// How the object follows its anchor cell when rows/columns move (sort, insert, delete).
|
||||
#[serde(default)]
|
||||
pub mode: AnchorMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -340,7 +357,7 @@ pub enum LegendPos {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Series {
|
||||
/// Formula text for the series name (`Sheet1!$B$1`) or a literal.
|
||||
pub name: Option<String>,
|
||||
@@ -352,6 +369,9 @@ pub struct Series {
|
||||
/// Combo charts: this series drawn as a line on the secondary axis.
|
||||
pub secondary: bool,
|
||||
pub kind: Option<ChartKind>,
|
||||
/// Draw line segments as a smoothed (spline) curve.
|
||||
#[serde(default)]
|
||||
pub smooth: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
|
||||
mod b64;
|
||||
pub mod cell;
|
||||
mod cellmap;
|
||||
pub mod features;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
pub mod workbook;
|
||||
|
||||
pub use cell::{Cell, Formula};
|
||||
pub use cell::{Cell, CellPicture, Formula};
|
||||
pub use features::*;
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
|
||||
pub use store::CellStore;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Worksheets.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::Cell;
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::*;
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
@@ -22,6 +23,9 @@ pub enum Visibility {
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
pub cells: CellStore,
|
||||
/// Static pictures are sparse rich content. Ordinary cells carry no picture pointer.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")]
|
||||
pub cell_pictures: BTreeMap<CellRef, Arc<CellPicture>>,
|
||||
/// Values spilled by dynamic-array formulas (maintained by calc), keyed by cell.
|
||||
#[serde(skip)]
|
||||
pub spill: BTreeMap<CellRef, Value>,
|
||||
@@ -48,7 +52,9 @@ pub struct Sheet {
|
||||
pub validations: Vec<Validation>,
|
||||
pub tables: Vec<Table>,
|
||||
pub autofilter: Option<AutoFilter>,
|
||||
#[serde(with = "crate::cellmap")]
|
||||
pub comments: BTreeMap<CellRef, Comment>,
|
||||
#[serde(with = "crate::cellmap")]
|
||||
pub hyperlinks: BTreeMap<CellRef, Hyperlink>,
|
||||
pub charts: Vec<Chart>,
|
||||
pub images: Vec<Image>,
|
||||
@@ -73,6 +79,7 @@ impl Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
spill: BTreeMap::new(),
|
||||
spill_ranges: BTreeMap::new(),
|
||||
cols: BTreeMap::new(),
|
||||
@@ -134,7 +141,30 @@ impl Sheet {
|
||||
}
|
||||
pub fn set_value(&mut self, c: CellRef, v: Value) {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell { value: v, formula: None, style });
|
||||
self.set_cell(c, Cell { value: v, formula: None, style });
|
||||
}
|
||||
/// Replaces content, removing any picture at this address. Style-only edits use set_style.
|
||||
pub fn set_cell(&mut self, c: CellRef, cell: Cell) {
|
||||
self.cell_pictures.remove(&c);
|
||||
self.cells.set(c, cell);
|
||||
}
|
||||
pub fn remove_cell(&mut self, c: CellRef) -> Option<Cell> {
|
||||
self.cell_pictures.remove(&c);
|
||||
self.cells.remove(c)
|
||||
}
|
||||
/// Removes content in a range; returns scalar cells, with pictures removed from the side map.
|
||||
pub fn take_cells(&mut self, range: RangeRef) -> Vec<(CellRef, Cell)> {
|
||||
self.cell_pictures.retain(|c, _| !range.contains(*c));
|
||||
self.cells.take_range(range)
|
||||
}
|
||||
/// Pictures retain a scalar fallback for calculation and unsupported consumers.
|
||||
pub fn set_picture(&mut self, c: CellRef, picture: Arc<CellPicture>) {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style });
|
||||
self.cell_pictures.insert(c, picture);
|
||||
}
|
||||
pub fn input_text(&self, c: CellRef) -> String {
|
||||
if self.cell_pictures.contains_key(&c) { String::new() } else { self.cell(c).map(Cell::input_text).unwrap_or_default() }
|
||||
}
|
||||
pub fn set_style(&mut self, c: CellRef, style: StyleId) {
|
||||
let mut cell = self.cells.get(c).cloned().unwrap_or_default();
|
||||
@@ -339,3 +369,15 @@ mod tests {
|
||||
assert_eq!(s.col_left(2), 128.0);
|
||||
}
|
||||
}
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
mod picture_map {
|
||||
use super::*;
|
||||
use serde::{Deserializer, Serializer};
|
||||
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
map.iter().collect::<Vec<_>>().serialize(serializer)
|
||||
}
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BTreeMap<CellRef, Arc<CellPicture>>, D::Error> {
|
||||
Ok(Vec::<(CellRef, Arc<CellPicture>)>::deserialize(deserializer)?.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,40 @@ impl CellStore {
|
||||
pub fn has(&self, c: CellRef) -> bool {
|
||||
self.get(c).is_some_and(|cell| !cell.value.is_empty() || cell.formula.is_some())
|
||||
}
|
||||
/// Positions whose cells differ between `self` and `other` (stored on one side only, or not
|
||||
/// equal). Bands both stores share are skipped, so comparing an edited copy with the store
|
||||
/// it was cloned from only visits the bands the edit touched.
|
||||
pub fn diff(&self, other: &CellStore) -> Vec<CellRef> {
|
||||
let empty = Band::default();
|
||||
let mut keys: Vec<u32> = self.bands.keys().chain(other.bands.keys()).copied().collect();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
let mut out = Vec::new();
|
||||
for k in keys {
|
||||
let (a, b) = (self.bands.get(&k), other.bands.get(&k));
|
||||
if let (Some(a), Some(b)) = (a, b)
|
||||
&& Arc::ptr_eq(a, b)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (a.map_or(&empty, |x| x), b.map_or(&empty, |x| x));
|
||||
for (row, cols) in &a.rows {
|
||||
for (col, cell) in cols {
|
||||
if b.rows.get(row).and_then(|r| r.get(col)) != Some(cell) {
|
||||
out.push(CellRef::new(*row, *col));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (row, cols) in &b.rows {
|
||||
for col in cols.keys() {
|
||||
if !a.rows.get(row).is_some_and(|r| r.contains_key(col)) {
|
||||
out.push(CellRef::new(*row, *col));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Removes every cell in `r` and returns them.
|
||||
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
|
||||
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
|
||||
@@ -210,6 +244,20 @@ mod tests {
|
||||
assert_eq!(s.get(c(5, 0)).unwrap().value, Value::Number(99.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_visits_changed_bands() {
|
||||
let mut s = CellStore::new();
|
||||
for r in 0..1000 {
|
||||
s.set(c(r, 0), Cell::value((r as f64).into()));
|
||||
}
|
||||
let snap = s.clone();
|
||||
assert!(s.diff(&snap).is_empty());
|
||||
s.set(c(5, 0), Cell::value(99.0.into()));
|
||||
s.set(c(700, 3), Cell::value("new".into()));
|
||||
s.remove(c(900, 0));
|
||||
assert_eq!(snap.diff(&s), [c(5, 0), c(700, 3), c(900, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_iteration_order() {
|
||||
let mut s = CellStore::new();
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_formula::Expr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::Formula;
|
||||
use crate::sheet::Sheet;
|
||||
use crate::style::{StyleTable, Theme};
|
||||
|
||||
@@ -143,6 +145,53 @@ impl Workbook {
|
||||
.find(|n| n.scope == Some(sheet) && n.name.eq_ignore_ascii_case(name))
|
||||
.or_else(|| self.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
/// Spells calls of LAMBDA names the way the names are defined (`Double(4)`, where the
|
||||
/// parser gives `DOUBLE(4)`: it upper-cases function names, as built-in ones are shown).
|
||||
pub fn name_call_case(&self, e: Expr) -> Expr {
|
||||
spell_calls(e, &self.lambda_names())
|
||||
}
|
||||
/// [`Workbook::name_call_case`] for every cell formula and defined name (after reading a
|
||||
/// file).
|
||||
pub fn apply_name_call_case(&mut self) {
|
||||
let lambdas = self.lambda_names();
|
||||
if lambdas.is_empty() {
|
||||
return;
|
||||
}
|
||||
for sheet in self.sheets.iter_mut() {
|
||||
let fixed: Vec<_> = sheet
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
let ne = spell_calls(e.clone(), &lambdas);
|
||||
(ne != e).then(|| (c, Formula { array: f.array, ..Formula::from_expr(ne) }))
|
||||
})
|
||||
.collect();
|
||||
if fixed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let sheet = Arc::make_mut(sheet);
|
||||
for (c, f) in fixed {
|
||||
if let Some(cell) = sheet.cells.get_mut(c) {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in self.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
let ne = spell_calls(e.clone(), &lambdas);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Names of the defined names holding a LAMBDA (callable like functions).
|
||||
fn lambda_names(&self) -> Vec<String> {
|
||||
let is_lambda = |f: &str| f.trim_start_matches('=').trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("LAMBDA("));
|
||||
self.names.iter().filter(|n| is_lambda(&n.formula)).map(|n| n.name.clone()).collect()
|
||||
}
|
||||
/// Table by name across sheets: (sheet index, table index).
|
||||
pub fn table(&self, name: &str) -> Option<(usize, usize)> {
|
||||
for (si, s) in self.sheets.iter().enumerate() {
|
||||
@@ -172,6 +221,20 @@ impl Workbook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls of the names in `lambdas` spelled as the names are.
|
||||
fn spell_calls(e: Expr, lambdas: &[String]) -> Expr {
|
||||
if lambdas.is_empty() {
|
||||
return e;
|
||||
}
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Call(n, args) => match lambdas.iter().find(|l| l.eq_ignore_ascii_case(&n)) {
|
||||
Some(l) => Expr::Call(l.clone(), args),
|
||||
None => Expr::Call(n, args),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
|
||||
|
||||
/// Renders a date/time. `None` when the serial is outside the supported range.
|
||||
pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out> {
|
||||
if !v.is_finite() || v < 0.0 || v >= 2_958_466.0 {
|
||||
if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) {
|
||||
return None;
|
||||
}
|
||||
let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3);
|
||||
|
||||
@@ -20,6 +20,14 @@ image = { workspace = true }
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
sys-locale = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Read the browser's preferred language for the first-run web interface language.
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
@@ -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]
|
||||
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt]
|
||||
//! [--dialog 'name={json}'] out.png`
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
@@ -20,6 +20,7 @@ fn main() {
|
||||
let mut pane: Option<String> = None;
|
||||
let mut chart: Option<u32> = None;
|
||||
let mut dialog: Option<String> = None;
|
||||
let mut language: Option<String> = None;
|
||||
let mut out = "snapshot.png".to_string();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -65,6 +66,10 @@ fn main() {
|
||||
i += 1;
|
||||
dialog = args.get(i).cloned();
|
||||
}
|
||||
"--language" => {
|
||||
i += 1;
|
||||
language = args.get(i).cloned();
|
||||
}
|
||||
other => out = other.to_string(),
|
||||
}
|
||||
i += 1;
|
||||
@@ -93,6 +98,11 @@ fn main() {
|
||||
{
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.ui.dark = dark;
|
||||
if let Some(code) = &language
|
||||
&& let Some(l) = gridcraft_ui_egui::i18n::Language::parse(code)
|
||||
{
|
||||
app.ui.language = l;
|
||||
}
|
||||
if let Some(t) = tab {
|
||||
app.ui.ribbon_tab = t;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Original border diagrams, painted with the same strokes as worksheet cells.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use gridcraft_engine::model::{BorderStyle, Borders, Theme};
|
||||
|
||||
use crate::grid::paint_border;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// A labelled menu button with a two-by-two diagram to distinguish inside edges
|
||||
/// from the selection outline. Labels stay available to accessibility tools.
|
||||
pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response {
|
||||
let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0)));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0));
|
||||
let edges = [
|
||||
[rect.left_top(), rect.right_top()],
|
||||
[rect.left_bottom(), rect.right_bottom()],
|
||||
[rect.left_top(), rect.left_bottom()],
|
||||
[rect.right_top(), rect.right_bottom()],
|
||||
[rect.left_center(), rect.right_center()],
|
||||
[rect.center_top(), rect.center_bottom()],
|
||||
];
|
||||
use BorderStyle::{Double, None, Thick, Thin};
|
||||
let styles = match preset {
|
||||
"bottom" => [None, Thin, None, None, None, None],
|
||||
"top" => [Thin, None, None, None, None, None],
|
||||
"left" => [None, None, Thin, None, None, None],
|
||||
"right" => [None, None, None, Thin, None, None],
|
||||
"all" => [Thin; 6],
|
||||
"outside" => [Thin, Thin, Thin, Thin, None, None],
|
||||
"thickOutside" => [Thick, Thick, Thick, Thick, None, None],
|
||||
"doubleBottom" => [None, Double, None, None, None, None],
|
||||
"thickBottom" => [None, Thick, None, None, None, None],
|
||||
"topBottom" => [Thin, Thin, None, None, None, None],
|
||||
"topThickBottom" => [Thin, Thick, None, None, None, None],
|
||||
"topDoubleBottom" => [Thin, Double, None, None, None, None],
|
||||
"inside" => [None, None, None, None, Thin, Thin],
|
||||
_ => [None; 6],
|
||||
};
|
||||
for (points, style) in edges.into_iter().zip(styles) {
|
||||
if style == None {
|
||||
ui.painter().extend(egui::Shape::dotted_line(&points, t.text_disabled, 3.0, 0.45));
|
||||
} else {
|
||||
paint_border(ui.painter(), points, style, t.text);
|
||||
}
|
||||
}
|
||||
ui.painter().text(pos2(response.rect.left() + 36.0, response.rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(12.5), t.text);
|
||||
response
|
||||
}
|
||||
|
||||
/// Preview only reads the dialog draft. A white worksheet surface keeps automatic
|
||||
/// and explicit border colors faithful in both light and dark application themes.
|
||||
pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Preview");
|
||||
let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview"));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(area, 4.0, t.grid_bg);
|
||||
ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let rect = area.shrink2(vec2(30.0, 20.0));
|
||||
let edges = [
|
||||
(borders.top, [rect.left_top(), rect.right_top()]),
|
||||
(borders.bottom, [rect.left_bottom(), rect.right_bottom()]),
|
||||
(borders.left, [rect.left_top(), rect.left_bottom()]),
|
||||
(borders.right, [rect.right_top(), rect.right_bottom()]),
|
||||
(borders.diag_down, [rect.left_top(), rect.right_bottom()]),
|
||||
(borders.diag_up, [rect.left_bottom(), rect.right_top()]),
|
||||
];
|
||||
for (line, points) in edges.iter().take(4) {
|
||||
if line.is_none() {
|
||||
ui.painter().extend(egui::Shape::dotted_line(points, Color32::from_gray(190), 4.0, 0.5));
|
||||
}
|
||||
}
|
||||
for (line, points) in edges {
|
||||
if !line.is_none() {
|
||||
let color = line.color.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
paint_border(ui.painter(), points, line.style, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Cell pictures use the current cell geometry, independent of floating objects.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use egui::{Color32, Painter, Rect, TextureHandle, pos2, vec2};
|
||||
use gridcraft_engine::model::CellPicture;
|
||||
|
||||
const MAX_ENCODED: usize = 16 * 1024 * 1024;
|
||||
const MAX_PIXELS: u64 = 16_000_000;
|
||||
const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
// The byte budget controls texture memory; sparse entries have a separate modest bound.
|
||||
const MAX_CACHE_ITEMS: usize = 4096;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
entries: HashMap<usize, Entry>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
// Holding a Weak prevents this allocation address from being reused. The
|
||||
// immutable Arc is the content identity: copy/undo share it; replacement
|
||||
// creates another allocation. No source bytes are cloned or hashed per frame.
|
||||
source: Weak<CellPicture>,
|
||||
texture: Option<TextureHandle>,
|
||||
bytes: usize,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn texture(&mut self, ctx: &egui::Context, picture: &Arc<CellPicture>) -> Option<TextureHandle> {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let key = Arc::as_ptr(picture) as usize;
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.used = frame;
|
||||
return entry.texture.clone();
|
||||
}
|
||||
// Prune discarded workbooks/payloads before allocating another texture.
|
||||
self.entries.retain(|_, entry| {
|
||||
if entry.source.strong_count() == 0 {
|
||||
self.bytes = self.bytes.saturating_sub(entry.bytes);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Read only the header before admission. Never decode an image just to evict it
|
||||
// again while painting a larger visible set on the next frame.
|
||||
let source = dimensions(picture);
|
||||
let estimate = source.map_or(0, |(_, (w, h))| w as usize * h as usize * 4);
|
||||
while self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(estimate) > MAX_CACHE_BYTES {
|
||||
let oldest = self.entries.iter().filter(|(_, e)| e.used.saturating_add(1) < frame).min_by_key(|(_, e)| e.used).map(|(key, _)| *key);
|
||||
let Some(oldest) = oldest else {
|
||||
// Keep visible images stable under pressure; show the placeholder for
|
||||
// excess content instead of re-decoding it each frame.
|
||||
return None;
|
||||
};
|
||||
if let Some(entry) = self.entries.remove(&oldest) {
|
||||
self.bytes = self.bytes.saturating_sub(entry.bytes);
|
||||
}
|
||||
}
|
||||
let decoded = source.and_then(|(format, _)| decode_format(picture, format));
|
||||
let bytes = decoded.as_ref().map_or(0, |image| image.pixels.len() * 4);
|
||||
let texture = decoded.map(|image| ctx.load_texture("cell picture", image, egui::TextureOptions::LINEAR));
|
||||
self.bytes += bytes;
|
||||
self.entries.insert(key, Entry { source: Arc::downgrade(picture), texture: texture.clone(), bytes, used: frame });
|
||||
texture
|
||||
}
|
||||
}
|
||||
|
||||
fn dimensions(picture: &CellPicture) -> Option<(image::ImageFormat, (u32, u32))> {
|
||||
if picture.data.len() > MAX_ENCODED {
|
||||
return None;
|
||||
}
|
||||
let format = image::guess_format(&picture.data).ok()?;
|
||||
if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg) {
|
||||
return None;
|
||||
}
|
||||
let dimensions = image::ImageReader::with_format(Cursor::new(&picture.data), format).into_dimensions().ok()?;
|
||||
if dimensions.0 == 0
|
||||
|| dimensions.1 == 0
|
||||
|| dimensions.0 > 8192
|
||||
|| dimensions.1 > 8192
|
||||
|| u64::from(dimensions.0) * u64::from(dimensions.1) > MAX_PIXELS
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((format, dimensions))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn decode(picture: &CellPicture) -> Option<egui::ColorImage> {
|
||||
let (format, _) = dimensions(picture)?;
|
||||
decode_format(picture, format)
|
||||
}
|
||||
|
||||
fn decode_format(picture: &CellPicture, format: image::ImageFormat) -> Option<egui::ColorImage> {
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(&picture.data), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let rgba = reader.decode().ok()?.into_rgba8();
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()))
|
||||
}
|
||||
|
||||
fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option<Rect> {
|
||||
if cell.width() <= 0.0 || cell.height() <= 0.0 || size.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let source = vec2(size[0] as f32, size[1] as f32);
|
||||
let scale = (cell.width() / source.x).min(cell.height() / source.y);
|
||||
Some(Rect::from_center_size(cell.center(), source * scale))
|
||||
}
|
||||
|
||||
pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
||||
if cell.width() <= 0.0 || cell.height() <= 0.0 || !cell.intersects(painter.clip_rect()) {
|
||||
return;
|
||||
}
|
||||
// Context-local handles must never leak between offscreen, native or browser
|
||||
// egui contexts, even when a workbook shares its Arc payload with another view.
|
||||
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.cell_pictures")).clone());
|
||||
let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture);
|
||||
let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect()));
|
||||
if let Some(texture) = texture {
|
||||
if let Some(rect) = fitted_rect(cell, texture.size()) {
|
||||
painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
} else {
|
||||
crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn picture(color: [u8; 4]) -> Result<Arc<CellPicture>, image::ImageError> {
|
||||
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba(color));
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image.write_to(&mut bytes, image::ImageFormat::Png)?;
|
||||
Ok(Arc::new(CellPicture { data: bytes.into_inner(), mime: "image/png".into(), alt: "Test picture".into() }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_fit_and_bounded_content_cache() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cell = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 100.0));
|
||||
assert_eq!(fitted_rect(cell, [200, 100]), Some(Rect::from_min_size(pos2(10.0, 45.0), vec2(100.0, 50.0))));
|
||||
assert_eq!(fitted_rect(cell, [100, 200]), Some(Rect::from_min_size(pos2(35.0, 20.0), vec2(50.0, 100.0))));
|
||||
assert!(fitted_rect(Rect::NOTHING, [200, 100]).is_none());
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = Cache::default();
|
||||
let red = picture([255, 0, 0, 255])?;
|
||||
let blue = picture([0, 0, 255, 255])?;
|
||||
// Distinct content with identical dimensions must receive distinct handles.
|
||||
let red_id = cache.texture(&ctx, &red).ok_or("missing red texture")?.id();
|
||||
assert_eq!(cache.texture(&ctx, &red).ok_or("missing red texture")?.id(), red_id);
|
||||
assert_ne!(cache.texture(&ctx, &blue).ok_or("missing blue texture")?.id(), red_id);
|
||||
let payloads: Vec<_> = (0..133).map(|_| picture([0, 0, 255, 255])).collect::<Result<_, _>>()?;
|
||||
for payload in &payloads {
|
||||
cache.texture(&ctx, payload);
|
||||
}
|
||||
let ids: Vec<_> =
|
||||
payloads.iter().map(|p| cache.texture(&ctx, p).ok_or("missing visible texture").map(|t| t.id())).collect::<Result<_, _>>()?;
|
||||
for _ in 0..3 {
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {
|
||||
for (payload, id) in payloads.iter().zip(&ids) {
|
||||
assert_eq!(cache.texture(&ctx, payload).map(|t| t.id()), Some(*id));
|
||||
}
|
||||
});
|
||||
// This test observes handles without a renderer consuming their upload deltas.
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(cache.entries.len() <= MAX_CACHE_ITEMS);
|
||||
assert!(cache.bytes <= MAX_CACHE_BYTES);
|
||||
assert!(decode(&CellPicture { data: vec![], mime: "image/png".into(), alt: String::new() }).is_none());
|
||||
assert_eq!(decode(&red).ok_or("decode failed")?.pixels.first(), Some(&Color32::RED));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
|
||||
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
|
||||
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
|
||||
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
|
||||
Rect::from_min_size(pos2(x, y), vec2(a.width * geo.z, a.height.max(1.0) * geo.z))
|
||||
@@ -243,7 +243,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si:
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sp.text.is_empty() {
|
||||
if !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,6 +254,9 @@ 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) {
|
||||
selection_frame(p, r);
|
||||
}
|
||||
}
|
||||
for im in &sh.images {
|
||||
let r = anchor_rect(geo, sh, &im.anchor);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
|
||||
//! - `engine.execute {command, params}` — run any engine or UI command
|
||||
//! - `engine.commands` — every command with enablement and params docs
|
||||
//! - `engine.commands {search?}` — every command with enablement and params docs
|
||||
//! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state
|
||||
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, grid geometry, perf)
|
||||
//! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`,
|
||||
@@ -144,6 +144,17 @@ fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m:
|
||||
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]);
|
||||
}
|
||||
|
||||
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
|
||||
fn filter_commands(commands: Vec<Json>, search: Option<&str>) -> Vec<Json> {
|
||||
let Some(q) = search.map(str::to_ascii_lowercase) else { return commands };
|
||||
let has = |c: &Json, k: &str| c.get(k).and_then(Json::as_str).is_some_and(|v| v.to_ascii_lowercase().contains(&q));
|
||||
let in_menu = |c: &Json| {
|
||||
let menu = c.get("menu").and_then(Json::as_array);
|
||||
menu.is_some_and(|m| m.iter().filter_map(Json::as_str).any(|v| v.to_ascii_lowercase().contains(&q)))
|
||||
};
|
||||
commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect()
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Json::as_str);
|
||||
let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32;
|
||||
@@ -155,17 +166,21 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
}
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
for (id, label) in [
|
||||
("ui.ribbonTab", "Ribbon Tab"),
|
||||
("view.formulaBar", "Formula Bar"),
|
||||
("view.collapseRibbon", "Collapse Ribbon"),
|
||||
("view.darkMode", "Dark Mode"),
|
||||
("view.zoom100", "100%"),
|
||||
("ui.dialog", "Open Dialog"),
|
||||
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%", ""),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"),
|
||||
("app.language.english", "Interface Language: English", ""),
|
||||
("app.language.japanese", "Interface Language: Japanese", ""),
|
||||
] {
|
||||
v.push(json!({"id": id, "label": label, "enabled": true, "ui": true}));
|
||||
v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true}));
|
||||
}
|
||||
ok(Json::Array(v))
|
||||
ok(Json::Array(filter_commands(v, s("search"))))
|
||||
}
|
||||
"engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::<Vec<_>>())),
|
||||
"document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(app.session.run(method, p.clone())),
|
||||
@@ -178,6 +193,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
"ui.set" => {
|
||||
if let Some(v) = p.get("dark").and_then(Json::as_bool) {
|
||||
app.ui.dark = v;
|
||||
app.ui.system_theme = false;
|
||||
}
|
||||
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
|
||||
app.ui.formula_bar = v;
|
||||
@@ -403,7 +419,7 @@ pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
if now < *deadline {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use `gridcraft-cli snapshot`"}));
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"}));
|
||||
false
|
||||
});
|
||||
}
|
||||
@@ -428,3 +444,30 @@ pub fn save_image(image: &egui::ColorImage, path: Option<&str>) -> Json {
|
||||
None => json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Vec<Json> {
|
||||
vec![
|
||||
json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}),
|
||||
json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}),
|
||||
json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}),
|
||||
]
|
||||
}
|
||||
|
||||
fn ids(v: &[Json]) -> Vec<&str> {
|
||||
v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_filters_case_insensitively() {
|
||||
assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]);
|
||||
assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border")));
|
||||
assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]);
|
||||
assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]);
|
||||
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
|
||||
assert_eq!(filter_commands(sample(), None).len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+159
-27
@@ -31,6 +31,8 @@ pub struct Dialog {
|
||||
pub search: String,
|
||||
pub list_index: usize,
|
||||
pub result: Option<Json>,
|
||||
picture_inbox: Option<crate::Inbox>,
|
||||
picture_target: Option<(u64, usize)>,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
@@ -49,6 +51,8 @@ impl Dialog {
|
||||
search: String::new(),
|
||||
list_index: 0,
|
||||
result: None,
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +67,8 @@ impl Dialog {
|
||||
search: String::new(),
|
||||
list_index: 0,
|
||||
result: None,
|
||||
picture_inbox: None,
|
||||
picture_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +194,10 @@ impl Dialog {
|
||||
label: "Paste",
|
||||
options: vec![
|
||||
("all", "All"),
|
||||
("formulas", "Formulas"),
|
||||
("values", "Values"),
|
||||
("formats", "Formats"),
|
||||
("comments", "Comments and notes"),
|
||||
("formulas", "Formulas (F)"),
|
||||
("values", "Values (V)"),
|
||||
("formats", "Formats (T)"),
|
||||
("comments", "Comments and notes (C)"),
|
||||
("validation", "Validation"),
|
||||
("allExceptBorders", "All except borders"),
|
||||
("columnWidths", "Column widths"),
|
||||
@@ -205,7 +211,7 @@ impl Dialog {
|
||||
options: vec![("none", "None"), ("add", "Add"), ("subtract", "Subtract"), ("multiply", "Multiply"), ("divide", "Divide")],
|
||||
},
|
||||
Check { key: "skipBlanks", label: "Skip blanks" },
|
||||
Check { key: "transpose", label: "Transpose" },
|
||||
Check { key: "transpose", label: "Transpose (E)" },
|
||||
Check { key: "link", label: "Paste Link" },
|
||||
],
|
||||
json!({"what": "all", "operation": "none"}),
|
||||
@@ -548,7 +554,18 @@ impl Dialog {
|
||||
],
|
||||
json!({"chart": p("chart")}),
|
||||
),
|
||||
"insertPicture" => Dialog::custom("insertPicture", "Insert Picture", json!({"path": ""})),
|
||||
"insertPicture" => {
|
||||
let placement = params.get("placement").and_then(Json::as_str).unwrap_or("overCells");
|
||||
let mut dialog = Dialog::form(
|
||||
"insertPicture",
|
||||
if placement == "cell" { "Place Picture in Cell" } else { "Place Picture over Cells" },
|
||||
"insert.picture",
|
||||
vec![],
|
||||
json!({"path": "", "placement": placement, "at": active}),
|
||||
);
|
||||
dialog.picture_target = app.view_key();
|
||||
dialog
|
||||
}
|
||||
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
|
||||
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
|
||||
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title")})),
|
||||
@@ -632,6 +649,9 @@ fn build_params(d: &Dialog) -> Json {
|
||||
|
||||
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
if d.name == "pasteSpecial" {
|
||||
paste_special_shortcuts(&mut d, ctx);
|
||||
}
|
||||
let mut open = true;
|
||||
let mut win_open = true;
|
||||
let mut confirm = false;
|
||||
@@ -641,14 +661,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
egui::Window::new(d.title.clone())
|
||||
let window = egui::Window::new(d.title.clone())
|
||||
.id(egui::Id::new("dialog").with(&d.name))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
|
||||
.open(&mut win_open)
|
||||
.show(ctx, |ui| {
|
||||
.open(&mut win_open);
|
||||
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
|
||||
window.show(ctx, |ui| {
|
||||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
@@ -687,20 +708,55 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
"insertPicture" => {
|
||||
ui.label("Picture file path (PNG or JPEG):");
|
||||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.text_edit_singleline(&mut path);
|
||||
d.values.insert("path".into(), json!(path));
|
||||
if let Some(pick) = &app.services.pick_open
|
||||
&& ui.button("Browse…").clicked()
|
||||
&& let Some(p) = pick()
|
||||
{
|
||||
d.values.insert("path".into(), json!(p));
|
||||
// A private inbox dies with this dialog; late browser results
|
||||
// cannot affect a later picture choice or another workbook.
|
||||
let arrived = d.picture_inbox.as_ref().and_then(|inbox| {
|
||||
inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop()
|
||||
});
|
||||
if let Some((name, bytes)) = arrived {
|
||||
d.picture_inbox = None;
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into()));
|
||||
} else {
|
||||
d.values.remove("path");
|
||||
d.values.insert("alt".into(), json!(name));
|
||||
d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes)));
|
||||
}
|
||||
}
|
||||
if app.services.pick_picture_async.is_some() {
|
||||
let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected");
|
||||
ui.label(name);
|
||||
} else {
|
||||
ui.label("Picture file path (PNG or JPEG):");
|
||||
let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.text_edit_singleline(&mut path).changed() {
|
||||
d.values.remove("base64");
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() {
|
||||
if let Some(start) = &app.services.pick_picture_async {
|
||||
let inbox = crate::Inbox::default();
|
||||
start(inbox.clone());
|
||||
d.picture_inbox = Some(inbox);
|
||||
} else if let Some(pick) = &app.services.pick_picture
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
d.values.remove("base64");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
ui.label("Description (optional):");
|
||||
let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.text_edit_singleline(&mut alt).changed() {
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
}
|
||||
if d.values.get("placement").and_then(Json::as_str) == Some("cell") {
|
||||
ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small());
|
||||
}
|
||||
ok_cancel(ui, &mut confirm, &mut open);
|
||||
if confirm {
|
||||
app.run_or_alert("insert.picture", json!({"path": d.values.get("path")}));
|
||||
}
|
||||
}
|
||||
"pickList" => {
|
||||
let items: Vec<String> = app
|
||||
@@ -814,6 +870,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open {
|
||||
open = false;
|
||||
}
|
||||
if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() {
|
||||
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Some(cmd) = d.command {
|
||||
let params = build_params(&d);
|
||||
match app.run(cmd, params) {
|
||||
@@ -828,6 +888,71 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
|
||||
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
if ctx.text_edit_focused() {
|
||||
return;
|
||||
}
|
||||
let right_alt = ctx.input(|i| {
|
||||
i.key_down(Key::AltRight)
|
||||
|| i.events.iter().any(|event| {
|
||||
matches!(event, egui::Event::Key { key: Key::AltRight, .. })
|
||||
|| matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. })
|
||||
})
|
||||
});
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
|
||||
let mnemonic = |key| match key {
|
||||
Key::T => Some('t'),
|
||||
Key::V => Some('v'),
|
||||
Key::F => Some('f'),
|
||||
Key::C => Some('c'),
|
||||
Key::E => Some('e'),
|
||||
_ => None,
|
||||
};
|
||||
let mut text_to_consume = Vec::new();
|
||||
for event in ctx.input(|i| i.events.clone()) {
|
||||
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
|
||||
if !allowed(modifiers) {
|
||||
continue;
|
||||
}
|
||||
let Some(letter) = mnemonic(key) else { continue };
|
||||
text_to_consume.push(letter);
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if key == Key::E {
|
||||
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
|
||||
d.values.insert("transpose".into(), json!(!transpose));
|
||||
} else {
|
||||
let what = match key {
|
||||
Key::T => "formats",
|
||||
Key::V => "values",
|
||||
Key::F => "formulas",
|
||||
_ => "comments",
|
||||
};
|
||||
d.values.insert("what".into(), json!(what));
|
||||
}
|
||||
}
|
||||
// Browsers can deliver Text before or after Key. Act only on Key, then remove
|
||||
// its matching text so one physical press cannot toggle Transpose twice.
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|event| match event {
|
||||
egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()),
|
||||
egui::Event::Text(text) if text.len() == 1 => {
|
||||
let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase();
|
||||
if let Some(index) = text_to_consume.iter().position(|&c| c == letter) {
|
||||
text_to_consume.remove(index);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -1087,12 +1212,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
(BorderStyle::Dotted, "dotted"),
|
||||
(BorderStyle::Double, "double"),
|
||||
] {
|
||||
if ui.selectable_label(slot.style == s, label).clicked() {
|
||||
let response = ui.selectable_label(slot.style == s, label);
|
||||
response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
|
||||
if response.clicked() {
|
||||
slot.style = s;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default();
|
||||
crate::border_preview::sample(ui, &st.borders, &theme);
|
||||
}
|
||||
"Fill" => {
|
||||
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
|
||||
@@ -1135,20 +1265,21 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date"));
|
||||
r.request_focus();
|
||||
});
|
||||
let locale = app.ui.language.formula_locale();
|
||||
let list = gridcraft_engine::cmd::formulas::function_list();
|
||||
let q = d.search.to_ascii_lowercase();
|
||||
let hits: Vec<&Json> = list
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
q.is_empty()
|
||||
|| f["name"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q))
|
||||
|| f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q))
|
||||
|| f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q))
|
||||
})
|
||||
.take(300)
|
||||
.collect();
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (i, f) in hits.iter().enumerate() {
|
||||
let name = f["name"].as_str().unwrap_or("");
|
||||
let name = locale.function_name(f["name"].as_str().unwrap_or(""));
|
||||
let r = ui.add(
|
||||
egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or("")))
|
||||
.min_size(vec2(480.0, 20.0)),
|
||||
@@ -1164,12 +1295,13 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
|
||||
});
|
||||
if let Some(f) = hits.get(d.list_index) {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(f["signature"].as_str().unwrap_or("")).strong());
|
||||
ui.label(f["description"].as_str().unwrap_or(""));
|
||||
ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong());
|
||||
ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default());
|
||||
}
|
||||
let mut open = true;
|
||||
ok_cancel(ui, confirm, &mut open);
|
||||
if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) {
|
||||
let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n);
|
||||
let cur = app.editor.as_ref().map(|e| e.text.clone());
|
||||
let text = match cur {
|
||||
Some(t) if t.starts_with('=') => format!("{t}{n}("),
|
||||
@@ -1368,7 +1500,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(n["scope"].as_str().unwrap_or(""));
|
||||
if ui.small_button("Delete").clicked() {
|
||||
app.run_or_alert("formulas.deleteName", json!({"name": n["name"]}));
|
||||
app.run_or_alert("formulas.deleteName", json!({"name": n["name"], "scope": n["scope"]}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -19,6 +19,8 @@ pub const REF_COLORS: [Color32; 8] = [
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditState {
|
||||
pub sheet: usize,
|
||||
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
|
||||
pub locale: gridcraft_engine::formula::FormulaLocale,
|
||||
pub cell: CellRef,
|
||||
pub text: String,
|
||||
/// Started by typing (Enter mode: arrows commit) rather than F2 (Edit mode: arrows move the caret).
|
||||
@@ -46,6 +48,7 @@ impl EditState {
|
||||
let caret = text.chars().count();
|
||||
EditState {
|
||||
sheet,
|
||||
locale: Default::default(),
|
||||
cell,
|
||||
text,
|
||||
enter_mode,
|
||||
@@ -163,7 +166,8 @@ impl EditState {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
')' => depth += 1,
|
||||
')' | '}' | ']' => depth += 1,
|
||||
'{' | '[' => depth -= 1,
|
||||
'(' => {
|
||||
if depth == 0 {
|
||||
let name: String = chars[..i]
|
||||
@@ -181,7 +185,7 @@ impl EditState {
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
',' if depth == 0 => arg += 1,
|
||||
c if c == self.locale.list_separator() && depth == 0 => arg += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,19 +65,31 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
fn active_input(app: &SheetApp) -> String {
|
||||
let Some(d) = app.session.active() else { return String::new() };
|
||||
let Some(sh) = d.wb.active() else { return String::new() };
|
||||
let a = d.selection.active;
|
||||
let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
if let Some(picture) = sh.cell_pictures.get(&a) {
|
||||
return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) };
|
||||
}
|
||||
if let Some(c) = sh.cell(a) {
|
||||
if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
|
||||
return String::new();
|
||||
}
|
||||
return c.input_text();
|
||||
return if let Some(f) = &c.formula {
|
||||
let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
|
||||
// A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does.
|
||||
if f.array.is_some() { format!("{{{shown}}}") } else { shown }
|
||||
} else {
|
||||
c.input_text()
|
||||
};
|
||||
}
|
||||
// A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly.
|
||||
for (anchor, r) in &sh.spill_ranges {
|
||||
if r.contains(a)
|
||||
&& let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref())
|
||||
{
|
||||
return format!("={}", f.text);
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) {
|
||||
return String::new();
|
||||
}
|
||||
return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
@@ -166,7 +178,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ed.caret_to_end = false;
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
if !ed.autocomplete.is_empty() && (tab || (enter && false)) {
|
||||
if !ed.autocomplete.is_empty() && tab {
|
||||
ed.accept_autocomplete();
|
||||
} else if enter {
|
||||
action = Some((if shift { -1 } else { 1 }, 0, false, false));
|
||||
@@ -293,7 +305,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ed.completion = crate::editor::column_completion(sh, ed.cell, &ed.text);
|
||||
}
|
||||
ed.point = None;
|
||||
let names: Vec<String> = function_names();
|
||||
let names: Vec<String> = function_names()
|
||||
.iter()
|
||||
.map(|name| {
|
||||
app.session.active().map(|d| crate::formula_locale::completion_name(name, ed.locale, &d.wb, ed.sheet)).unwrap_or(name).to_string()
|
||||
})
|
||||
.collect();
|
||||
ed.update_autocomplete(&names);
|
||||
app.session.mode = if ed.can_point() {
|
||||
gridcraft_engine::Mode::Point
|
||||
@@ -334,11 +351,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
if r.clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
if sel && let Some(desc) = function_description(n) {
|
||||
if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) {
|
||||
r.on_hover_text(desc);
|
||||
}
|
||||
}
|
||||
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| function_description(n)) {
|
||||
if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) {
|
||||
ui.separator();
|
||||
ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap());
|
||||
}
|
||||
@@ -348,20 +365,20 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ed.request_focus = true;
|
||||
}
|
||||
} else if let Some((name, arg)) = ed.current_function()
|
||||
&& let Some(sig) = function_signature(&name)
|
||||
&& let Some(sig) = crate::formula_locale::signature(&name, ed.locale)
|
||||
{
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let (head, rest) = sig.split_once('(').unwrap_or((&sig, ""));
|
||||
ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0)));
|
||||
ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0)));
|
||||
let inner = rest.trim_end_matches(')');
|
||||
let parts: Vec<&str> = inner.split(", ").collect();
|
||||
let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect();
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let is_cur = i == arg.min(parts.len().saturating_sub(1)) || (part.contains("...") && arg >= i);
|
||||
let txt = egui::RichText::new(*part).font(if is_cur { theme::ui_bold(12.0) } else { theme::ui_font(12.0) });
|
||||
ui.label(txt);
|
||||
if i + 1 < parts.len() {
|
||||
ui.label(egui::RichText::new(", ").font(theme::ui_font(12.0)));
|
||||
ui.label(egui::RichText::new(format!("{} ", ed.locale.list_separator())).font(theme::ui_font(12.0)));
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(")").font(theme::ui_font(12.0)));
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English.
|
||||
use gridcraft_engine::formula::{self, FormulaLocale, ParseError};
|
||||
use gridcraft_engine::model::Workbook;
|
||||
|
||||
pub(crate) fn canonical(
|
||||
text: &str,
|
||||
locale: FormulaLocale,
|
||||
wb: &Workbook,
|
||||
sheet: usize,
|
||||
cell: gridcraft_engine::core::CellRef,
|
||||
) -> Result<String, ParseError> {
|
||||
if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") {
|
||||
return Ok(text.to_string());
|
||||
}
|
||||
// Same formula detection as the engine's `input_to_cell` (no trimming: " =1" is text).
|
||||
let body = text;
|
||||
if body.starts_with('=')
|
||||
|| ((body.starts_with('+') || body.starts_with('-')) && body.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
|
||||
{
|
||||
let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some();
|
||||
// Match the engine's existing convenience for missing closing parentheses.
|
||||
let mut body = body.to_string();
|
||||
let mut expr = formula::parse_input(&body, locale, known);
|
||||
for _ in 0..8 {
|
||||
if expr.is_ok() {
|
||||
break;
|
||||
}
|
||||
body.push(')');
|
||||
expr = formula::parse_input(&body, locale, known);
|
||||
}
|
||||
return Ok(format!("={}", formula::print(&expr?)));
|
||||
}
|
||||
// Decimal-comma number entry follows the same preference, without rewriting text or dates.
|
||||
let normalized = body.trim().replace(locale.decimal_separator(), ".");
|
||||
let number = normalized.strip_suffix('%').unwrap_or(&normalized);
|
||||
if number.parse::<f64>().is_ok_and(f64::is_finite) {
|
||||
return Ok(normalized);
|
||||
}
|
||||
Ok(text.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String {
|
||||
// English (and every language without a formula table) shows the stored text untouched,
|
||||
// and skips a parse per frame.
|
||||
if locale == FormulaLocale::En {
|
||||
return text.to_string();
|
||||
}
|
||||
let Some(body) = text.strip_prefix('=') else { return text.to_string() };
|
||||
formula::parse(body)
|
||||
.and_then(|expr| formula::print_input(&expr, locale, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some()))
|
||||
.map(|body| format!("={body}"))
|
||||
.unwrap_or_else(|_| text.to_string())
|
||||
}
|
||||
|
||||
// Descriptions/signatures for the supported Spanish function table; untranslated functions
|
||||
// retain their English help. Parameter punctuation follows the same locale as entry.
|
||||
const SPANISH_HELP: &[(&str, &str, &str)] = &[
|
||||
("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."),
|
||||
("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."),
|
||||
("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."),
|
||||
("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."),
|
||||
("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."),
|
||||
("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."),
|
||||
("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."),
|
||||
("TODAY", "HOY()", "Devuelve la fecha actual."),
|
||||
("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."),
|
||||
("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."),
|
||||
];
|
||||
|
||||
pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option<String> {
|
||||
let canonical = locale.canonical_name(name);
|
||||
if locale == FormulaLocale::Es
|
||||
&& let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical)
|
||||
{
|
||||
return Some((*signature).into());
|
||||
}
|
||||
crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option<String> {
|
||||
let canonical = locale.canonical_name(name);
|
||||
if locale == FormulaLocale::Es
|
||||
&& let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical)
|
||||
{
|
||||
return Some((*description).into());
|
||||
}
|
||||
crate::formula_bar::function_description(canonical)
|
||||
}
|
||||
|
||||
/// Avoid suggesting an alias that would call a workbook/table name instead of the function.
|
||||
pub(crate) fn completion_name<'a>(name: &'a str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> &'a str {
|
||||
let alias = locale.function_name(name);
|
||||
if wb.name(alias, sheet).is_some() || wb.table(alias).is_some() { name } else { alias }
|
||||
}
|
||||
|
||||
/// Engine-generated edit templates use canonical syntax. The only incomplete template is
|
||||
/// the bare function opening emitted by Insert Function; typed user input never enters here.
|
||||
pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String {
|
||||
if let Some(name) = text.strip_prefix('=').and_then(|s| s.strip_suffix('('))
|
||||
&& !name.is_empty()
|
||||
&& name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
|
||||
{
|
||||
return format!("={}(", completion_name(name, locale, wb, sheet));
|
||||
}
|
||||
display(text, locale, wb, sheet)
|
||||
}
|
||||
+226
-109
@@ -312,7 +312,8 @@ fn display_text(
|
||||
|
||||
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
let available = ui.available_rect_before_wrap();
|
||||
let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom()));
|
||||
let resp = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
app.grid.rect = Some(rect);
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -323,7 +324,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let mut view = app.view();
|
||||
// Scrolling.
|
||||
let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
|
||||
if resp.hovered() || resp.contains_pointer() {
|
||||
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
|
||||
if zoom_delta != 1.0 {
|
||||
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
@@ -373,7 +374,6 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, 0.0, t.grid_bg);
|
||||
let mut cf = CfCache::new();
|
||||
let char_w = ui.fonts_mut(|f| f.glyph_width(&FontId::new(11.0 * 96.0 / 72.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), '0')).max(1.0);
|
||||
// Quadrants: (rows, cols, clip).
|
||||
let (sr0, sr1) = geo.scroll_rows(sh);
|
||||
let (sc0, sc1) = geo.scroll_cols(sh);
|
||||
@@ -395,7 +395,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
continue;
|
||||
}
|
||||
let p = painter.with_clip_rect(*clip);
|
||||
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, char_w, &mut cf, &t);
|
||||
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t);
|
||||
paint_selection(&p, &geo, sh, &sel, &t, app);
|
||||
}
|
||||
// Freeze lines.
|
||||
@@ -416,33 +416,74 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
|
||||
}
|
||||
// Interaction.
|
||||
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
|
||||
interact(app, ui, &resp, &geo, sh, &wb);
|
||||
crate::text_box::show(app, ui, &geo, sh);
|
||||
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
|
||||
in_cell_editor(app, ui, &geo, sh, &wb);
|
||||
}
|
||||
filter_menu(app, ui, &geo);
|
||||
list_picker(app, ui, &geo);
|
||||
header_menu(app, ui);
|
||||
filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);
|
||||
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
|
||||
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
|
||||
context_menu(app, ui);
|
||||
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
|
||||
if app.session.clipboard.is_some() {
|
||||
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
|
||||
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
|
||||
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, right: f32, limit: f32, t: &Tokens) {
|
||||
let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom()));
|
||||
if track.height() <= 0.0 || track.width() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let visible = track.height() / geo.z;
|
||||
let scroll = app.view().scroll.y;
|
||||
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
|
||||
let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32;
|
||||
let extent = (content - visible).max(scroll).max(0.0).min(limit);
|
||||
let id = ui.id().with("vscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
|
||||
}
|
||||
let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
|
||||
let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height());
|
||||
let travel = track.height() - thumb_h;
|
||||
let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h));
|
||||
ui.painter().rect_filled(track, 0.0, t.tab_bar);
|
||||
ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
|
||||
let mut next = scroll;
|
||||
if resp.dragged() && travel > 0.0 {
|
||||
next += resp.drag_delta().y / travel * extent;
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
if p.y < thumb.top() {
|
||||
next -= visible;
|
||||
} else if p.y > thumb.bottom() {
|
||||
next += visible;
|
||||
}
|
||||
}
|
||||
if resp.hovered() {
|
||||
next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z;
|
||||
}
|
||||
next = next.clamp(0.0, limit);
|
||||
if next != scroll {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.scroll.y = next;
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_quadrant(
|
||||
p: &Painter,
|
||||
geo: &Geo,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
sh: &Sheet,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
char_w: f32,
|
||||
cf: &mut CfCache,
|
||||
t: &Tokens,
|
||||
) {
|
||||
fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) {
|
||||
let z = geo.z;
|
||||
let (r0, r1) = rows;
|
||||
let (c0, c1) = cols;
|
||||
@@ -504,6 +545,10 @@ fn paint_quadrant(
|
||||
// Text and per-cell decorations.
|
||||
for (c, rect, lk, cfl) in &looks {
|
||||
let c = *c;
|
||||
if let Some(picture) = sh.cell_pictures.get(&c) {
|
||||
crate::cell_pictures::paint(p, *rect, picture);
|
||||
continue;
|
||||
}
|
||||
let v = sh.value(c);
|
||||
if let Some(l) = cfl
|
||||
&& let Some((frac, rgb, gradient)) = l.bar
|
||||
@@ -560,8 +605,13 @@ fn paint_quadrant(
|
||||
let size = st.font.size * 96.0 / 72.0 * z;
|
||||
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
||||
let font = FontId::new(size, fam);
|
||||
// Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns).
|
||||
let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0);
|
||||
let avail_w = rect.right() - text_left;
|
||||
let (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
|
||||
let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
|
||||
if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) {
|
||||
text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si);
|
||||
}
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -577,7 +627,9 @@ fn paint_quadrant(
|
||||
};
|
||||
let indent = st.align.indent as f32 * 9.0 * z;
|
||||
let pad = 2.5 * z;
|
||||
if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap {
|
||||
let rotated = st.align.rotation != 0 && st.align.rotation != 255;
|
||||
// Rotated text wraps along its own direction (below), not across the column width.
|
||||
if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) {
|
||||
let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0);
|
||||
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w);
|
||||
job.halign = match halign {
|
||||
@@ -622,9 +674,9 @@ fn paint_quadrant(
|
||||
galley = p.layout_no_wrap(s, font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Overflow into empty neighbours for text.
|
||||
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
|
||||
let mut clip = *rect;
|
||||
if !numeric && !matches!(v, Value::Number(_)) && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
|
||||
if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
|
||||
let mut right = rect.right();
|
||||
let mut left = rect.left();
|
||||
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
|
||||
@@ -667,9 +719,23 @@ fn paint_quadrant(
|
||||
VAlign::Bottom => rect.bottom() - gh - 0.5 * z,
|
||||
};
|
||||
let cp = p.with_clip_rect(clip.intersect(p.clip_rect()));
|
||||
if st.align.rotation != 0 && st.align.rotation != 255 {
|
||||
if rotated {
|
||||
let angle = -(st.align.rotation as f32).to_radians();
|
||||
let mut shape = egui::epaint::TextShape::new(pos2(rect.left() + pad, rect.bottom() - pad), galley.clone(), color);
|
||||
// Upright (±90°) wrapped text wraps to the row height: lines then stack across the column.
|
||||
let galley = if st.align.wrap && st.align.rotation.abs() == 90 {
|
||||
let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
|
||||
p.layout_job(job)
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
// Upward text grows up and right from the bottom-left corner; downward text grows down
|
||||
// and left from its anchor, so that anchor sits one block-height in from the top-left.
|
||||
let pos = if angle > 0.0 {
|
||||
pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad)
|
||||
} else {
|
||||
pos2(rect.left() + pad, rect.bottom() - pad)
|
||||
};
|
||||
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
|
||||
shape.angle = angle;
|
||||
cp.add(shape);
|
||||
} else {
|
||||
@@ -769,7 +835,7 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
|
||||
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
|
||||
}
|
||||
|
||||
fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
|
||||
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
|
||||
let w = style.width();
|
||||
let (a, b) = (pts[0], pts[1]);
|
||||
match style {
|
||||
@@ -982,7 +1048,13 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel
|
||||
// ------------------------------------------------------------------ interaction
|
||||
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
// Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates.
|
||||
let primary_clicked = resp.clicked_by(egui::PointerButton::Primary);
|
||||
let pointer = ctx.input(|i| i.pointer.clone());
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
let pos = pointer.interact_pos().or(pointer.hover_pos());
|
||||
@@ -1006,6 +1078,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeRow);
|
||||
} else if handle.contains(p) && app.editor.is_none() {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
|
||||
}
|
||||
} else if in_cells && app.editor.is_none() && on_border(cur_rect, p) {
|
||||
ctx.set_cursor_icon(CursorIcon::Move);
|
||||
} else if in_cells {
|
||||
@@ -1048,7 +1123,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
return;
|
||||
}
|
||||
if app.session.draw_tool == "eraser" && (resp.clicked() || resp.dragged()) {
|
||||
if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) {
|
||||
if let Some(p) = pos
|
||||
&& let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p)
|
||||
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink)
|
||||
@@ -1059,11 +1134,18 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
}
|
||||
// Objects (charts, pictures, shapes) take clicks first.
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
if resp.drag_started() || primary_clicked {
|
||||
if let Some(p) = pos
|
||||
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
|
||||
{
|
||||
app.selected_chart = Some(id);
|
||||
if kind == "shape"
|
||||
&& resp.double_clicked()
|
||||
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox)
|
||||
{
|
||||
app.begin_text_box_edit(id);
|
||||
return;
|
||||
}
|
||||
if kind == "chart" && resp.double_clicked() {
|
||||
app.grid.pane = Some("formatChart".into());
|
||||
}
|
||||
@@ -1075,13 +1157,39 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if resp.clicked() || resp.drag_started() {
|
||||
} else if primary_clicked || resp.drag_started() {
|
||||
app.selected_chart = None;
|
||||
}
|
||||
}
|
||||
|
||||
// A double-click is also a click: handle header AutoFit before resize setup returns.
|
||||
if resp.double_clicked()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
let autofit = if in_col_header {
|
||||
col_edge(geo, sh, p.x).map(|(col, _)| {
|
||||
let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col);
|
||||
("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) })
|
||||
})
|
||||
} else if in_row_header {
|
||||
row_edge(geo, sh, p.y).map(|(row, _)| {
|
||||
let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row);
|
||||
("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) })
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((command, params)) = autofit {
|
||||
app.grid.drag = Drag::None;
|
||||
app.grid.drag_select = false;
|
||||
app.run_or_alert(command, params);
|
||||
resp.request_focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Press: decide the drag mode.
|
||||
if resp.drag_started() || (resp.clicked() && app.grid.drag == Drag::None) {
|
||||
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
|
||||
// Where the press began (a drag is only recognised after the pointer has moved).
|
||||
let Some(p) = pointer.press_origin().or(pos) else { return };
|
||||
if in_corner {
|
||||
@@ -1140,6 +1248,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
ed.point_cell = Some(c);
|
||||
app.grid.drag = Drag::Select;
|
||||
app.grid.drag_select = true;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
|
||||
@@ -1177,12 +1290,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()}));
|
||||
} else {
|
||||
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
|
||||
if resp.clicked() && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
||||
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
|
||||
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
|
||||
}
|
||||
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
|
||||
if let Some(h) = sh.hyperlinks.get(&c).cloned()
|
||||
&& resp.clicked()
|
||||
&& primary_clicked
|
||||
&& !mods.any()
|
||||
{
|
||||
let r = geo.cell_rect(sh, c);
|
||||
@@ -1193,6 +1306,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
app.grid.drag = Drag::Select;
|
||||
app.grid.drag_select = true;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
// The first recognised drag already includes movement away from the press.
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1221,26 +1340,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
);
|
||||
match app.grid.drag.clone() {
|
||||
Drag::Select => {
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.point.is_some()
|
||||
{
|
||||
if let Some(start) = ed.point_cell {
|
||||
let r = RangeRef::new(start, pc);
|
||||
let text = if r.is_single() { r.start.a1() } else { r.a1() };
|
||||
let keep = ed.point_cell;
|
||||
ed.insert_ref(&text);
|
||||
ed.point_cell = keep;
|
||||
}
|
||||
} else {
|
||||
let anchor = sel.anchor;
|
||||
let mut ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
|
||||
ranges.pop();
|
||||
ranges.push(RangeRef::new(anchor, pc).a1());
|
||||
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": sel.active.a1()}));
|
||||
if let Some(d) = app.session.active_mut() {
|
||||
d.selection.anchor = anchor;
|
||||
}
|
||||
}
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
scroll_by(app, auto);
|
||||
}
|
||||
Drag::Rows(anchor) => {
|
||||
@@ -1313,6 +1413,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
// Release.
|
||||
if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) {
|
||||
match std::mem::take(&mut app.grid.drag) {
|
||||
Drag::Select if resp.drag_stopped() => {
|
||||
if let Some(p) = pos {
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
}
|
||||
Drag::Fill { target: Some(t) } => {
|
||||
let src = sel.current();
|
||||
if t != src {
|
||||
@@ -1334,29 +1439,22 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
app.grid.drag_select = false;
|
||||
}
|
||||
|
||||
// Double-click on header edges autofits.
|
||||
// Double-click the fill handle.
|
||||
if resp.double_clicked()
|
||||
&& let Some(p) = pos
|
||||
&& handle.contains(p)
|
||||
{
|
||||
if in_col_header && let Some((c, _)) = col_edge(geo, sh, p.x) {
|
||||
let _ = app.run("home.autofitColumnWidth", json!({"cols": RangeRef::cols(c, c).a1()}));
|
||||
} else if in_row_header && let Some((r, _)) = row_edge(geo, sh, p.y) {
|
||||
let _ = app.run("home.autofitRowHeight", json!({"rows": format!("{}:{}", r + 1, r + 1)}));
|
||||
} else if handle.contains(p) {
|
||||
// Double-click the fill handle: fill down as far as the neighbouring column goes.
|
||||
let src = sel.current();
|
||||
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
|
||||
let mut last = src.end.row;
|
||||
while last + 1 < MAX_ROWS
|
||||
&& sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty())
|
||||
&& last - src.end.row < 1_000_000
|
||||
{
|
||||
last += 1;
|
||||
}
|
||||
if last > src.end.row {
|
||||
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
|
||||
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
|
||||
}
|
||||
// Fill down as far as the neighbouring column goes.
|
||||
let src = sel.current();
|
||||
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
|
||||
let mut last = src.end.row;
|
||||
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
|
||||
{
|
||||
last += 1;
|
||||
}
|
||||
if last > src.end.row {
|
||||
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
|
||||
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,12 +1484,41 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
}
|
||||
|
||||
if resp.clicked() || resp.drag_started() {
|
||||
if primary_clicked || resp.drag_started() {
|
||||
resp.request_focus();
|
||||
}
|
||||
keyboard(app, &ctx, resp, geo, sh);
|
||||
}
|
||||
|
||||
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
|
||||
let cell = CellRef::new(
|
||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
|
||||
);
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.point.is_some()
|
||||
{
|
||||
if let Some(start) = ed.point_cell {
|
||||
let range = RangeRef::new(start, cell);
|
||||
let text = if range.is_single() { range.start.a1() } else { range.a1() };
|
||||
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
|
||||
ed.insert_ref(&text);
|
||||
ed.point_cell = Some(start);
|
||||
}
|
||||
} else if let Some(d) = app.session.active() {
|
||||
// Read the current anchor: starting a drag may have just replaced the selection.
|
||||
let anchor = d.selection.anchor;
|
||||
let active = d.selection.active;
|
||||
let mut ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
ranges.pop();
|
||||
ranges.push(RangeRef::new(anchor, cell).a1());
|
||||
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
|
||||
if let Some(d) = app.session.active_mut() {
|
||||
d.selection.anchor = anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesce_last_undo(app: &mut SheetApp) {
|
||||
// Merge consecutive drag steps into one undo entry.
|
||||
if let Some(d) = app.session.active_mut()
|
||||
@@ -1503,12 +1630,13 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
|
||||
/// Grid keyboard handling when no text field has focus.
|
||||
fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
|
||||
// Focus left on an editor that no longer exists goes back to the grid.
|
||||
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar")];
|
||||
if app.editor.is_none() && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
|
||||
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()];
|
||||
let editing = app.editor.is_some() || app.text_box_editor.is_some();
|
||||
if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
|
||||
resp.request_focus();
|
||||
}
|
||||
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && !(app.editor.is_none() && stale.contains(&f)));
|
||||
if app.editor.is_some() || app.dialog.is_some() || other_focus || app.message.is_some() {
|
||||
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
|
||||
if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
|
||||
return;
|
||||
}
|
||||
if ctx.memory(|m| m.focused()).is_none() {
|
||||
@@ -1523,18 +1651,10 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
for ev in events {
|
||||
match ev {
|
||||
egui::Event::Copy => {
|
||||
if let Ok(r) = app.run("edit.copy", json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
app.copy_to_clipboard(ctx, "edit.copy");
|
||||
}
|
||||
egui::Event::Cut => {
|
||||
if let Ok(r) = app.run("edit.cut", json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})),
|
||||
egui::Event::Text(text) => {
|
||||
@@ -1643,7 +1763,13 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0);
|
||||
}
|
||||
|
||||
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||
/// A trigger can sit outside its popup, so its opening click must be ignored.
|
||||
fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool {
|
||||
response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere())
|
||||
}
|
||||
|
||||
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
|
||||
let Some((col, at)) = app.grid.filter_menu else { return };
|
||||
let Some(d) = app.session.active() else { return };
|
||||
let wb = d.wb.clone();
|
||||
@@ -1676,9 +1802,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
}
|
||||
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
|
||||
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
area.show(ui.ctx(), |ui| {
|
||||
let response = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(230.0);
|
||||
if ui.button("↑ Sort Ascending").clicked() {
|
||||
@@ -1691,7 +1818,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Filter").strong());
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());
|
||||
let all = checks.iter().all(|(_, b)| *b);
|
||||
let mut all_new = all;
|
||||
@@ -1710,7 +1836,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Clear Filter").clicked() {
|
||||
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
|
||||
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
|
||||
close = true;
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
@@ -1722,13 +1847,13 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
json!({"column": col_to_letters(col), "values": vals, "blanks": blanks})
|
||||
};
|
||||
let _ = app.run("data.filterBy", p);
|
||||
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
|
||||
if close || popup_dismissed(&response.response, was_open) {
|
||||
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
|
||||
app.grid.filter_menu = None;
|
||||
}
|
||||
}
|
||||
@@ -1790,11 +1915,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
"edit.copy" | "edit.cut" => {
|
||||
if let Ok(r) = app.run(id, json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ui.ctx().copy_text(t.to_string());
|
||||
}
|
||||
app.copy_to_clipboard(ui.ctx(), id);
|
||||
}
|
||||
other => app.run_or_alert(other, json!({})),
|
||||
}
|
||||
@@ -1939,7 +2060,7 @@ fn paint_overlays(
|
||||
let _ = (wb, si);
|
||||
}
|
||||
|
||||
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
|
||||
let Some(c) = app.grid.list_picker else { return };
|
||||
let Some(d) = app.session.active() else { return };
|
||||
let wb = d.wb.clone();
|
||||
@@ -1952,7 +2073,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
|
||||
let r = geo.cell_rect(sh, c);
|
||||
let mut close = false;
|
||||
egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_width(r.width().max(100.0));
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
@@ -1965,15 +2086,15 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
|
||||
});
|
||||
});
|
||||
});
|
||||
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
|
||||
if close || popup_dismissed(&response.response, was_open) {
|
||||
app.grid.list_picker = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((at, rows)) = app.grid.header_menu else { return };
|
||||
let mut close = false;
|
||||
egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let items: Vec<(&str, &str)> = if rows {
|
||||
@@ -2021,11 +2142,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
"ui:rowHeight" => app.open_dialog("rowHeight", json!({})),
|
||||
"ui:columnWidth" => app.open_dialog("columnWidth", json!({})),
|
||||
"edit.copy" | "edit.cut" => {
|
||||
if let Ok(r) = app.run(id, json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ui.ctx().copy_text(t.to_string());
|
||||
}
|
||||
app.copy_to_clipboard(ui.ctx(), id);
|
||||
}
|
||||
other => app.run_or_alert(other, json!({})),
|
||||
}
|
||||
@@ -2033,7 +2150,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
|
||||
if close || popup_dismissed(&response.response, was_open) {
|
||||
app.grid.header_menu = None;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user