diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml
new file mode 100644
index 000000000..cd8f12ab3
--- /dev/null
+++ b/.github/workflows/flatpak-pages.yml
@@ -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" <"$site/flatpak/index.html" <
GridCraft Flatpak repositoryGridCraft Flatpak repository
Latest published release: $RELEASE_TAG
Add the GridCraft repository
flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
+ flatpak install gridcraft $APP_ID
+ HTML
+ cat >"$site/index.html" <GridCraft downloadsGridCraft downloads
Flatpak repository
Release assets
+ 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
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 3e2370996..0a82a910f 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -1,7 +1,8 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
-# Builds run only from the `release` branch: release.yml calls this workflow, and a manual run
-# from any other branch is skipped.
+# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
+# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
+# from a branch builds the tarball too; they need no secrets.
#
# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
# release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the
@@ -41,7 +42,7 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
- if: github.ref == 'refs/heads/release'
+ if: github.ref == 'refs/heads/release' || inputs.package
runs-on: ubuntu-latest
timeout-minutes: 300
env:
@@ -50,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
@@ -87,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
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index ab3ad17ef..e9927c552 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -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
@@ -54,4 +54,13 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.gridcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.gridcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.gridcraft.metainfo.xml"
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.gridcraft.yml')); assert m['id'] == 'ai.storyteller.gridcraft' and m['command'] == 'gridcraft'"
+ python3 - <<'EOF'
+ import yaml
+ src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.yml"))
+ bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml"))
+ for m in (src, bundle):
+ assert m["id"] == "ai.storyteller.gridcraft" and m["command"] == "gridcraft", m["id"]
+ for key in ("runtime", "runtime-version", "sdk", "finish-args"):
+ assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
+ print("Flatpak manifests ok")
+ EOF
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ee6058148..6c9050a9e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,9 +2,12 @@
# creates or updates the draft GitHub Release "GridCraft v". See docs/releasing.md.
# Copied from DesignCraft (craftrules release/playbook.md); keep the two in step.
#
-# All jobs run in the `release` environment (restricted to the `release` branch), which holds
-# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
-# warning, never a failed build.
+# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
+# (restricted to the `release` branch), which holds the signing secrets. Every secret is
+# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
+# other jobs sign nothing and get no secrets, so a manual run from another branch is a dry run:
+# they build and upload their artifacts, the environment refuses the signing jobs, and the
+# draft-release job (which needs them) is skipped.
name: Release
on:
@@ -35,16 +38,14 @@ env:
jobs:
version:
name: version
- # Only the release branch builds; a manual run from any other branch skips every job.
- if: github.ref == 'refs/heads/release'
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: this job needs no secrets (see the header about dry runs).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -72,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
@@ -103,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/*
@@ -131,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
@@ -166,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/*
@@ -178,7 +177,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
@@ -190,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
@@ -203,12 +201,12 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
- desktop-file-utils appstream python3-yaml
+ desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- - uses: dtolnay/rust-toolchain@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
@@ -228,35 +226,75 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ # Update information is embedded, and the .zsync for delta updates sits beside it.
+ "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
+ test -s "$appimage.zsync"
done
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [version, linux]
+ # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
+ # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
+ runs-on: ${{ matrix.runner }}
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-24.04 }
+ - { arch: aarch64, runner: ubuntu-24.04-arm }
+ env:
+ GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/linux
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update
+ # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
+ # `appstreamcli compose`, which renders the scalable icon and fails without it.
+ sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
+ # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ - name: Build the Flatpak bundle
+ run: packaging/linux/flatpak-bundle.sh "dist/linux/gridcraft-$GRIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: release-flatpak-${{ matrix.arch }}
+ path: dist/release/*.flatpak
+ if-no-files-found: error
+
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
GRIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
GRIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- - uses: actions/checkout@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
@@ -272,15 +310,84 @@ 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--freebsd-x86_64.tar.gz). It needs no
- # secrets, so the called workflow runs without the release environment.
+ # secrets, so the called workflow runs without the release environment (and in dry runs).
freebsd:
name: FreeBSD
needs: version
@@ -292,7 +399,10 @@ jobs:
release:
name: Draft GitHub Release
- needs: [version, macos, windows, linux, 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'
runs-on: ubuntu-latest
environment: release
permissions:
@@ -303,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
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 927ce1df8..92239fa56 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 467a9e0be..aaf3448f9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,7 +43,7 @@ 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`.
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 35a5d7d1b..36678d8a4 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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 ``, 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 |
diff --git a/CLAUDE.md b/CLAUDE.md
index ff12ac81d..54e9d0303 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ 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`.
diff --git a/Cargo.lock b/Cargo.lock
index f3b1e6426..e3a414438 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1392,7 +1392,7 @@ dependencies = [
[[package]]
name = "gridcraft"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -1408,7 +1408,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1420,7 +1420,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1441,7 +1441,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -1450,7 +1450,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1470,7 +1470,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
"serde",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
"regex",
@@ -1487,7 +1487,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-engine",
"log",
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1507,18 +1507,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
-version = "0.1.0"
+version = "0.4.0"
[[package]]
name = "gridcraft-ui-egui"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"egui",
"egui_kittest",
@@ -1533,7 +1533,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -1550,7 +1550,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -4287,7 +4287,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"gridcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index a7f99e0cb..a1ce6a552 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
-version = "0.1.0"
+version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/README.md b/README.md
index d7a4b8c41..1344bc66d 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,19 @@ 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.
+### 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
@@ -160,6 +173,64 @@ alpha. Next up: signed release builds, XLSX fidelity on real-world files, perfor
sheets, then slicers, Solver, chart trendlines and a true Page Layout view. The plan and estimates
are in [ROADMAP.md](ROADMAP.md).
+## Downloads
+
+**Download GridCraft** from GitHub: the [latest release](https://github.com/storytold/gridcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/gridcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
+
+### Windows
+
+| Build | Installer | Portable |
+|---|---|---|
+| x64 (64-bit Intel/AMD) | `gridcraft--windows-x64.msi` | `gridcraft--windows-x64-portable.zip` |
+| arm64 (Snapdragon and other ARM PCs) | `gridcraft--windows-arm64.msi` | `gridcraft--windows-arm64-portable.zip` |
+| x86 (32-bit) | `gridcraft--windows-x86.msi` | `gridcraft--windows-x86-portable.zip` |
+
+Installers and executables are code-signed.
+
+**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
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `gridcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `gridcraft-cli--macos-universal.zip` | Signed and notarized |
+
+### Linux
+
+| Format | x86_64 | aarch64 (ARM64) | Notes |
+|---|---|---|---|
+| AppImage | `gridcraft--linux-x86_64.AppImage` | `gridcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| Flatpak | `gridcraft--linux-x86_64.flatpak` | `gridcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
+| Debian/Ubuntu | `gridcraft--linux-x86_64.deb` | `gridcraft--linux-aarch64.deb` | |
+| Fedora/RHEL/openSUSE | `gridcraft--linux-x86_64.rpm` | `gridcraft--linux-aarch64.rpm` | |
+| Tarball | `gridcraft--linux-x86_64.tar.gz` | `gridcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+
+### FreeBSD
+
+| Build | File |
+|---|---|
+| x86_64 | `gridcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| Build | File | Notes |
+|---|---|---|
+| Static site | `gridcraft-web-.zip` | Runs in a modern browser; host it on any static server |
+
## The Crafting Apps
GridCraft is one of the **Crafting Apps**: free, open-source creative tools from the
diff --git a/ROADMAP.md b/ROADMAP.md
index 1b0cd3468..32ddbcd49 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -81,11 +81,13 @@ by open equivalents or left out, not cloned.
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.
+ undo/redo with history, format painter. Quick selection drags retain their final endpoint;
+ the fill handle explains how to fill versus select cells.
- 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.
+ Border presets include visual diagrams; Format Cells previews draft borders before applying.
- 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.
diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs
new file mode 100644
index 000000000..c35f85703
--- /dev/null
+++ b/apps/gridcraft-cli/tests/text_to_columns_quotes.rs
@@ -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");
+}
diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs
index bdbc28d95..e9e05a2e6 100644
--- a/apps/gridcraft/src/control_server.rs
+++ b/apps/gridcraft/src/control_server.rs
@@ -21,7 +21,7 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result) {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs
new file mode 100644
index 000000000..d73cd7131
--- /dev/null
+++ b/apps/gridcraft/src/logging.rs
@@ -0,0 +1,579 @@
+//! 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:
+//! `/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..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 {
+ 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` → … → `..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: ` [] : \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,
+ pending: Vec,
+ 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,
+}
+
+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 {
+ // 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();
+ 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 = 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);
+ }
+}
diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs
index 6acaf5119..7343932f3 100644
--- a/apps/gridcraft/src/main.rs
+++ b/apps/gridcraft/src/main.rs
@@ -9,6 +9,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
+#[cfg(any(target_os = "windows", test))]
+mod graphics;
+mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -61,8 +64,19 @@ fn config_dir() -> Option {
}
}
+/// 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 {
+ 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;
}
let Some(dir) = config_dir() else { return };
@@ -79,7 +93,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 };
@@ -133,6 +147,26 @@ fn icon() -> Option {
}
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 = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
@@ -157,15 +191,29 @@ 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() {
@@ -187,6 +235,13 @@ 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
+ };
eframe::run_native(
"GridCraft",
options,
@@ -195,7 +250,7 @@ fn main() -> eframe::Result<()> {
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(
diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs
index 43e57a485..260d90054 100644
--- a/apps/gridcraft/src/native_menu.rs
+++ b/apps/gridcraft/src/native_menu.rs
@@ -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)> {
("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)> {
pub struct NativeMenu {
_menu: Menu,
events: Receiver,
+ 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,15 @@ 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();
+ if select_theme(app, &id) {
+ continue;
+ }
if let Some(url) = id.strip_prefix("url:") {
if let Some(open) = &app.services.open_url {
open(url);
@@ -252,5 +278,28 @@ impl NativeMenu {
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");
}
}
diff --git a/contributors/contributors.json b/contributors/contributors.json
index 10cba0513..d338d1656 100644
--- a/contributors/contributors.json
+++ b/contributors/contributors.json
@@ -2,23 +2,49 @@
"schema": 1,
"repo": "storytold/gridcraft",
"branch": "main",
- "head": "a60bd2f40edae13197965ded9433dcf276caca45",
- "head_date": "2026-10-07T18:45:37Z",
- "commits": 16,
+ "head": "51b531be3e8b3f5a19d2b7714a4780a79569ca92",
+ "head_date": "2026-10-08T13:12:32Z",
+ "commits": 24,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
- "prs": 1,
- "commits": 16,
- "lines_added": 78054,
- "lines_deleted": 3954,
+ "prs": 4,
+ "commits": 21,
+ "lines_added": 79088,
+ "lines_deleted": 3997,
"binary_added": 35,
"binary_deleted": 12,
"first_commit": "2026-10-06T02:09:42Z",
- "last_commit": "2026-10-07T18:45:37Z"
+ "last_commit": "2026-10-08T13:50:17Z"
+ },
+ {
+ "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": "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"
}
],
"models": [
@@ -27,9 +53,9 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
- "commits": 15,
- "lines_added": 78047,
- "lines_deleted": 3954
+ "commits": 23,
+ "lines_added": 79359,
+ "lines_deleted": 4032
}
]
}
diff --git a/contributors/people.toml b/contributors/people.toml
index 89c6db208..2e6b83bbb 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -21,3 +21,7 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
+
+[people.nihilist-narwhal]
+display_name = "nihilist-narwhal"
+sync_github_name = false
diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs
index abef54ffd..e36be2df4 100644
--- a/crates/calc/src/eval.rs
+++ b/crates/calc/src/eval.rs
@@ -54,6 +54,10 @@ 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 {
+ 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 {
range.iter().map(|c| self.cell_value(sheet, c)).collect()
@@ -322,6 +326,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 +360,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 +713,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 => {
@@ -1306,30 +1335,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, 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, 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") =>
{
diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs
index 13b47c41c..500dec2c5 100644
--- a/crates/calc/src/recalc.rs
+++ b/crates/calc/src/recalc.rs
@@ -225,6 +225,26 @@ impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
+ fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option {
+ 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) {
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 67db0830d..7b582acca 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -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();
@@ -215,3 +249,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);
+}
diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs
index 7fba23fe5..b82443470 100644
--- a/crates/core/src/date.rs
+++ b/crates/core/src/date.rs
@@ -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 {
// 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;
}
@@ -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));
}
diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs
index b08cf2aab..5d0be7ace 100644
--- a/crates/core/src/parse.rs
+++ b/crates/core/src/parse.rs
@@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option {
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;
}
@@ -357,4 +357,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));
+ }
}
diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs
index 43f59f54a..65853eadc 100644
--- a/crates/engine/src/cmd/data.rs
+++ b/crates/engine/src/cmd/data.rs
@@ -739,8 +739,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result {
}
}
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),
@@ -760,9 +759,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) {
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index cffe15430..9b7db40be 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -119,9 +119,10 @@ pub fn specs() -> Vec {
// ---------------------------------------------------------------- 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