diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 000000000..9bdb2d7a2 --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -0,0 +1,101 @@ +# 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. +# +# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the +# release binaries and uploads `sheetcraft--freebsd-x86_64.tar.gz` as the +# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the +# draft GitHub Release. +name: FreeBSD + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - packaging/freebsd/** + - .github/workflows/freebsd.yml + workflow_dispatch: + inputs: + package: + description: "Build the release tarball instead of running the tests" + type: boolean + default: false + workflow_call: + inputs: + version: + description: "Version for the artifact name (empty: Cargo.toml)" + type: string + default: "" + version_override: + description: "Also write the version into Cargo.toml before building (test runs)" + type: boolean + default: false + package: + type: boolean + default: false + +concurrency: + # Called from release.yml, github.workflow is the caller's name; keep the groups apart. + group: freebsd-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.workflow == 'FreeBSD' }} + +permissions: + contents: read + +jobs: + freebsd: + name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) + runs-on: ubuntu-latest + timeout-minutes: 300 + env: + SHEETCRAFT_VERSION: ${{ inputs.version }} + SHEETCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }} + SHEETCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} + SHEETCRAFT_BUILD_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases embed it. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - name: Build in a FreeBSD VM + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + envs: "SHEETCRAFT_VERSION SHEETCRAFT_VERSION_OVERRIDE SHEETCRAFT_PACKAGE SHEETCRAFT_BUILD_SHA" + # Only the packaging run brings files back (dist/release). The target dir lives outside + # the synced checkout, because copying it out of the VM fails on large builds. + copyback: ${{ inputs.package }} + prepare: | + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal + run: | + . "$HOME/.cargo/env" + # The checkout is synced in under another owner; let tests that call git read it. + git config --global --add safe.directory "*" + export CARGO_TARGET_DIR=/var/tmp/sheetcraft-target CARGO_PROFILE_DEV_DEBUG=0 + rustc --version + if [ -n "$SHEETCRAFT_PACKAGE" ]; then + if [ -n "$SHEETCRAFT_VERSION_OVERRIDE" ]; then + cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_VERSION" + fi + CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh + else + cargo build --workspace --all-targets --locked + CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked + fi + - uses: actions/upload-artifact@v4 + if: inputs.package + with: + name: release-freebsd + path: dist/release/* + if-no-files-found: error diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml new file mode 100644 index 000000000..6fb6a2fd3 --- /dev/null +++ b/.github/workflows/packaging-lint.yml @@ -0,0 +1,57 @@ +# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +name: Packaging lint + +on: + push: + branches: [main, release] + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + pull_request: + paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: packaging-lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + env: + ACTIONLINT_VERSION: 1.7.12 + steps: + - uses: actions/checkout@v4 + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml + curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" actionlint + - name: actionlint + run: | + "$RUNNER_TEMP/actionlint" -color + - name: shellcheck + run: shellcheck packaging/*.sh packaging/*/*.sh + - name: PowerShell + shell: pwsh + run: | + foreach ($f in Get-ChildItem packaging/windows/*.ps1) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } + Write-Output "ok $($f.Name)" + } + - name: XML, desktop entry, AppStream, Flatpak manifest + run: | + xmllint --noout packaging/windows/sheetcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* + # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. + if grep -oE '"$RUNNER_TEMP/ai.storyteller.sheetcraft.metainfo.xml" + appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.sheetcraft.metainfo.xml" + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.sheetcraft.yml')); assert m['id'] == 'ai.storyteller.sheetcraft' and m['command'] == 'sheetcraft'" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..84d21059f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,339 @@ +# Release pipeline: every push to `release` builds signed installers for every platform and +# creates or updates the draft GitHub Release "SheetCraft 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. +name: Release + +on: + push: + branches: [release] + workflow_dispatch: + inputs: + version: + description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)" + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + SHEETCRAFT_BUILD_SHA: ${{ github.sha }} + INPUT_VERSION: ${{ inputs.version }} + +jobs: + version: + name: version + runs-on: ubuntu-latest + environment: release + outputs: + version: ${{ steps.v.outputs.version }} + date: ${{ steps.v.outputs.date }} + prerelease: ${{ steps.v.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + - id: v + name: Resolve version + run: | + . packaging/env.sh + v="${INPUT_VERSION#v}" + v="${v:-$VERSION}" + if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then + echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" + exit 1 + fi + case "$v" in *-*) pre=true ;; *) pre=false ;; esac + { + echo "version=$v" + echo "date=$(date -u +%Y-%m-%d)" + echo "prerelease=$pre" + } >>"$GITHUB_OUTPUT" + echo "### SheetCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" + + macos: + name: macOS universal + needs: version + runs-on: macos-15 + environment: release + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_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. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + key: release-macos-universal + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_VERSION" + - name: Import signing certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: packaging/macos/import-cert.sh + - name: Build, sign, notarize + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packaging/macos/package.sh --arch universal + - uses: actions/upload-artifact@v4 + with: + name: release-macos + path: dist/release/* + if-no-files-found: error + - name: Remove temporary keychain + if: always() + run: | + if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi + + windows: + name: Windows ${{ matrix.arch }} + needs: version + runs-on: windows-latest + environment: release + strategy: + fail-fast: false + matrix: + include: + - { arch: x64, target: x86_64-pc-windows-msvc } + - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_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. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: release-windows-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + shell: bash + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_VERSION" + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build, sign, package + shell: pwsh + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} + - uses: actions/upload-artifact@v4 + with: + name: release-windows-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + linux: + name: Linux ${{ matrix.arch }} + needs: version + # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, + # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. + runs-on: ${{ matrix.runner }} + environment: release + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } + - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_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. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - name: Install build and packaging tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ + desktop-file-utils appstream python3-yaml + 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 + with: + key: release-linux-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_VERSION" + - name: Build and package (AppImage, deb, rpm, tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + run: packaging/linux/package.sh + - name: Check the packages + run: | + dpkg-deb --info dist/release/*.deb + dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/sheetcraft$|metainfo|applications' + ldd target/release/sheetcraft + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.sheetcraft.yml')); assert m['id'] == 'ai.storyteller.sheetcraft'" + for appimage in dist/release/*.AppImage; do + chmod +x "$appimage" + APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + done + - uses: actions/upload-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + web: + name: Web (wasm) + needs: version + runs-on: ubuntu-latest + environment: release + env: + SHEETCRAFT_VERSION: ${{ needs.version.outputs.version }} + SHEETCRAFT_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. + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: release-web + - name: Install trunk + run: | + curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "$HOME/.cargo/bin" trunk + trunk --version + - name: Apply version override + if: inputs.version != '' + run: cargo run -q -p xtask --no-default-features -- version set "$SHEETCRAFT_VERSION" + - name: Build and zip + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + run: packaging/web/package.sh + - uses: actions/upload-artifact@v4 + with: + name: release-web + path: dist/release/* + 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 (sheetcraft--freebsd-x86_64.tar.gz). It needs no + # secrets, so the called workflow runs without the release environment. + freebsd: + name: FreeBSD + needs: version + uses: ./.github/workflows/freebsd.yml + with: + version: ${{ needs.version.outputs.version }} + version_override: ${{ inputs.version != '' }} + package: true + + release: + name: Draft GitHub Release + needs: [version, macos, windows, linux, web, freebsd] + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + env: + VERSION: ${{ needs.version.outputs.version }} + PRERELEASE: ${{ needs.version.outputs.prerelease }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: artifacts + merge-multiple: true + - name: Checksums + working-directory: artifacts + run: | + sha256sum -- * | tee SHA256SUMS.txt + - name: Create or update the draft release + working-directory: artifacts + run: | + tag="v$VERSION" + title="SheetCraft v$VERSION" + pre=() + if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi + if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then + if [ "$draft" != "true" ]; then + echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." + exit 1 + fi + echo "Updating draft $tag: replacing its assets" + gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do + gh release delete-asset "$tag" "$asset" --yes + done + gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" + gh release upload "$tag" --clobber -- * + else + echo "Creating draft $tag" + gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * + fi + { + echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" + echo + sed 's/^/ /' SHA256SUMS.txt + } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 000000000..daad48c7e --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: sheetcraft-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install and run on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: sheetcraft-windows-arm64 + path: dist + - name: Install the MSI, check it, run it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + $dir = Join-Path $env:ProgramFiles 'SheetCraft' + foreach ($exe in 'sheetcraft.exe', 'sheetcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + } + # The installed CLI runs natively on ARM64. + $cli = Join-Path $dir 'sheetcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "sheetcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok sheetcraft-cli --version: $($out.Trim())" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path (Join-Path $dir 'sheetcraft.exe')) { throw 'uninstall left sheetcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3a539037b..73e36793f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,3 +17,19 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `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/sheetcraft.svg` | SheetCraft 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 | +| `assets/app-icon/sheetcraft-1024.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from sheetcraft.svg | +| `assets/app-icon/sheetcraft-macos-512.png` | SheetCraft 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/sheetcraft.icns` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon | +| `assets/app-icon/sheetcraft.ico` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Windows exe icon | +| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, Linux hicolor render | +| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, copy of sheetcraft.svg | +| `assets/app-icon/README.md` | SheetCraft contributors | original | MIT OR Apache-2.0 | Icon documentation | +| `assets/app-icon/LICENSE.txt` | (licence text) | original | — | Licence of the app icon files | diff --git a/Cargo.lock b/Cargo.lock index 0b5c7d67d..eb4a27c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,138 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +143,52 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.5.0" @@ -20,18 +198,504 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -41,6 +705,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -49,29 +750,717 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] [[package]] name = "indexmap" @@ -80,7 +1469,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", ] [[package]] @@ -89,6 +1506,197 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -101,6 +1709,734 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -110,6 +2446,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -119,6 +2473,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -128,6 +2491,100 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "regex" version = "1.13.1" @@ -157,6 +2614,125 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -184,7 +2760,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -200,6 +2776,41 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sheetcraft" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "image", + "log", + "rfd", + "serde_json", + "sheetcraft-engine", + "sheetcraft-ui-egui", + "winresource", +] + [[package]] name = "sheetcraft-calc" version = "0.1.0" @@ -212,13 +2823,34 @@ dependencies = [ "sheetcraft-numfmt", ] +[[package]] +name = "sheetcraft-chart" +version = "0.1.0" +dependencies = [ + "serde", + "sheetcraft-calc", + "sheetcraft-core", + "sheetcraft-model", + "sheetcraft-numfmt", +] + +[[package]] +name = "sheetcraft-cli" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "sheetcraft-engine", + "sheetcraft-mcp", +] + [[package]] name = "sheetcraft-core" version = "0.1.0" dependencies = [ "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -235,7 +2867,7 @@ dependencies = [ "sheetcraft-model", "sheetcraft-numfmt", "sheetcraft-xlsx", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -244,7 +2876,7 @@ version = "0.1.0" dependencies = [ "serde", "sheetcraft-core", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -255,6 +2887,15 @@ dependencies = [ "sheetcraft-core", ] +[[package]] +name = "sheetcraft-mcp" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "sheetcraft-engine", +] + [[package]] name = "sheetcraft-model" version = "0.1.0" @@ -263,7 +2904,7 @@ dependencies = [ "serde_json", "sheetcraft-core", "sheetcraft-formula", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -273,24 +2914,205 @@ dependencies = [ "sheetcraft-core", ] +[[package]] +name = "sheetcraft-ui-egui" +version = "0.1.0" +dependencies = [ + "egui", + "egui_kittest", + "image", + "log", + "serde", + "serde_json", + "sheetcraft-chart", + "sheetcraft-engine", +] + [[package]] name = "sheetcraft-xlsx" version = "0.1.0" dependencies = [ - "quick-xml", + "quick-xml 0.38.4", "sheetcraft-core", "sheetcraft-formula", "sheetcraft-model", - "thiserror", + "thiserror 2.0.21", "zip", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -302,13 +3124,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -319,15 +3183,1179 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", + "sheetcraft-engine", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zip" version = "3.0.0" @@ -365,3 +4393,59 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 7cd0d6507..fd5c98236 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/*"] -# default-members restored once apps exist +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/sheetcraft", "apps/sheetcraft-cli", "xtask"] [workspace.package] version = "0.1.0" diff --git a/apps/sheetcraft-cli/Cargo.toml b/apps/sheetcraft-cli/Cargo.toml new file mode 100644 index 000000000..571905af8 --- /dev/null +++ b/apps/sheetcraft-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sheetcraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft command line: inspect, convert, evaluate, script and serve MCP without a window" +publish = false + +[lints] +workspace = true + +[[bin]] +name = "sheetcraft-cli" +path = "src/main.rs" + +[dependencies] +sheetcraft-engine = { workspace = true } +sheetcraft-mcp = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/apps/sheetcraft-cli/src/main.rs b/apps/sheetcraft-cli/src/main.rs new file mode 100644 index 000000000..b933ef157 --- /dev/null +++ b/apps/sheetcraft-cli/src/main.rs @@ -0,0 +1,573 @@ +//! Headless SheetCraft. +//! +//! ```text +//! sheetcraft-cli info [--json] +//! sheetcraft-cli convert [--sheet NAME] +//! sheetcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] +//! sheetcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] +//! sheetcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] +//! sheetcraft-cli commands [--json] [--search X] +//! sheetcraft-cli functions [--json] [--search X] [--category C] +//! sheetcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] +//! sheetcraft-cli send [json] +//! sheetcraft-cli version | --version +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +/// `println!` that ends the program quietly when stdout is closed (`sheetcraft-cli commands | +/// head`) instead of panicking on a broken pipe. +macro_rules! outln { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} + +use serde_json::{Value, json}; +use sheetcraft_engine::Session; + +const USAGE: &str = "usage: + sheetcraft-cli info [--json] sheets, used ranges, tables, charts, names + sheetcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html by extension + sheetcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] + sheetcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] + sheetcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] + [--out FILE] [--print RANGE] [--quiet] + sheetcraft-cli commands [--json] [--search X] + sheetcraft-cli functions [--json] [--search X] [--category C] + sheetcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] + sheetcraft-cli send [json] + sheetcraft-cli version"; + +/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any +/// other write error is reported. +fn stdout_failed(e: std::io::Error) -> ! { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + eprintln!("sheetcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let rest = args.get(1..).unwrap_or_default(); + let r = match args.first().map(String::as_str) { + Some("--version" | "-V" | "version") => { + outln!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some("--help" | "-h" | "help") => { + outln!("{USAGE}"); + Ok(()) + } + Some("info") => info(rest), + Some("convert") => convert(rest), + Some("eval") => eval(rest), + Some("cat") => cat(rest), + Some("run") => run(rest), + Some("commands") => commands(rest), + Some("functions") => functions(rest), + Some("mcp") => mcp(rest), + Some("send") => send(rest), + Some(other) => Err(format!("unknown subcommand `{other}`\n{USAGE}")), + None => Err(USAGE.to_string()), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("sheetcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +// ---------------------------------------------------------------- argument parsing + +/// Parsed arguments: positionals, `--flag value` options and boolean `--switch`es. +struct Args { + positional: Vec, + opts: Vec<(String, String)>, + switches: Vec, +} + +impl Args { + /// `with_value` lists the options that take a value; `switches` the boolean ones. Anything + /// else starting with `--` is an error. + fn parse(args: &[String], with_value: &[&str], switches: &[&str]) -> Result { + let mut out = Args { positional: vec![], opts: vec![], switches: vec![] }; + let mut it = args.iter(); + while let Some(a) = it.next() { + if let Some(name) = a.strip_prefix("--").filter(|n| !n.is_empty()) { + let (name, inline) = match name.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (name, None), + }; + if with_value.contains(&name) { + let v = match inline { + Some(v) => v, + None => it.next().cloned().ok_or_else(|| format!("--{name} needs a value"))?, + }; + out.opts.push((name.to_string(), v)); + } else if switches.contains(&name) && inline.is_none() { + out.switches.push(name.to_string()); + } else { + return Err(format!("unknown option --{name}")); + } + } else { + out.positional.push(a.clone()); + } + } + Ok(out) + } + fn opt(&self, name: &str) -> Option<&str> { + self.opts.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) + } + fn has(&self, name: &str) -> bool { + self.switches.iter().any(|s| s == name) + } + fn pos(&self, i: usize, what: &str) -> Result<&str, String> { + self.positional.get(i).map(String::as_str).ok_or_else(|| format!("missing {what}\n{USAGE}")) + } + fn no_extra(&self, n: usize) -> Result<(), String> { + match self.positional.get(n) { + Some(x) => Err(format!("unexpected argument `{x}`")), + None => Ok(()), + } + } +} + +// ---------------------------------------------------------------- helpers + +fn exec(s: &mut Session, id: &str, params: Value) -> Result { + let r = s.execute(id, params).map_err(|e| e.to_string()); + s.take_ui_requests(); + r +} + +fn open(path: &str) -> Result { + let mut s = Session::new(); + exec(&mut s, "file.open", json!({"path": path}))?; + Ok(s) +} + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn activate_sheet(s: &mut Session, sheet: Option<&str>) -> Result<(), String> { + if let Some(name) = sheet { + let p = match name.parse::() { + // A number that isn't also a sheet name is an index. + Ok(i) if s.doc().is_ok_and(|d| d.wb.sheet_index(name).is_none()) => json!({"sheet": i}), + _ => json!({"sheet": name}), + }; + exec(s, "sheet.activate", p)?; + } + Ok(()) +} + +fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +/// A number as a person would type it: `6`, `0.5`, `1e+300`. +fn number_text(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") } +} + +/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text. +fn value_text(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + Value::Number(n) => n.as_f64().map(number_text).unwrap_or_else(|| n.to_string()), + Value::String(s) => s.clone(), + Value::Object(o) => o.get("error").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| v.to_string()), + Value::Array(rows) => rows + .iter() + .map(|r| match r { + Value::Array(cells) => cells.iter().map(value_text).collect::>().join("\t"), + other => value_text(other), + }) + .collect::>() + .join("\n"), + } +} + +fn csv_field(s: &str, delim: char) -> String { + if s.contains(delim) || s.contains('"') || s.contains('\n') || s.contains('\r') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +// ---------------------------------------------------------------- subcommands + +fn info(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["json"])?; + let path = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = open(path)?; + let v = exec(&mut s, "document.inspect", json!({}))?; + if a.has("json") { + outln!("{}", pretty(&v)); + return Ok(()); + } + outln!("{}", v["title"].as_str().unwrap_or(path)); + for (i, sh) in v["sheets"].as_array().into_iter().flatten().enumerate() { + let active = if v["activeSheet"].as_u64() == Some(i as u64) { "*" } else { " " }; + let vis = sh["visibility"].as_str().filter(|x| *x != "Visible").map(|x| format!(" ({x})")).unwrap_or_default(); + outln!( + "{active} {}{vis}: used {}, {} cells", + sh["name"].as_str().unwrap_or("?"), + sh["usedRange"].as_str().unwrap_or("(empty)"), + sh["cells"].as_u64().unwrap_or(0) + ); + for t in sh["tables"].as_array().into_iter().flatten() { + outln!(" table {} {} ({})", t["name"].as_str().unwrap_or("?"), t["range"].as_str().unwrap_or("?"), t["style"].as_str().unwrap_or("")); + } + for c in sh["charts"].as_array().into_iter().flatten() { + let title = c["title"].as_str().map(|t| format!(" \"{t}\"")).unwrap_or_default(); + outln!(" chart {}{title} at {}, {} series", c["kind"].as_str().unwrap_or("?"), c["at"].as_str().unwrap_or("?"), c["series"]); + } + if let Some(f) = sh["autofilter"].as_str() { + outln!(" filter {f}"); + } + } + let names = v["names"].as_array().cloned().unwrap_or_default(); + if !names.is_empty() { + outln!("names:"); + for n in names { + outln!(" {} {}", n["name"].as_str().unwrap_or("?"), n["refersTo"].as_str().unwrap_or("")); + } + } + Ok(()) +} + +fn convert(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["sheet"], &[])?; + let input = a.pos(0, "")?; + let out = a.pos(1, "")?; + a.no_extra(2)?; + if sheetcraft_engine::io::FileKind::from_path(out).is_none() { + return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)")); + } + let mut s = open(input)?; + activate_sheet(&mut s, a.opt("sheet"))?; + let d = s.doc().map_err(|e| e.to_string())?; + let bytes = sheetcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?; + sheetcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", bytes.len()); + Ok(()) +} + +fn eval(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["in", "sheet", "cell"], &["json"])?; + let formula = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = match a.opt("in") { + Some(p) => open(p)?, + None => blank(), + }; + activate_sheet(&mut s, a.opt("sheet"))?; + let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; + let mut p = json!({"formula": formula}); + if let Some(c) = a.opt("cell") { + p["cell"] = json!(c); + } + let v = exec(&mut s, "formulas.evaluate", p)?; + if a.has("json") { + outln!("{}", serde_json::to_string(&v).unwrap_or_default()); + } else { + outln!("{}", value_text(&v)); + } + Ok(()) +} + +/// Prints `range` of the active (or `sheet`) sheet as an aligned table or CSV. +fn print_range(s: &mut Session, range: Option<&str>, sheet: Option<&str>, formulas: bool, csv: bool) -> Result<(), String> { + let mut p = json!({}); + if let Some(r) = range { + p["range"] = json!(r); + } + if let Some(sh) = sheet { + p["sheet"] = json!(sh); + } + let raw = exec(s, "sheet.read", p.clone())?; + p["formatted"] = json!(true); + p["formulas"] = json!(formulas); + let shown = exec(s, "sheet.read", p)?; + let rows: Vec> = shown["values"] + .as_array() + .into_iter() + .flatten() + .map(|r| r.as_array().into_iter().flatten().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| value_text(c))).collect()) + .collect(); + if csv { + for r in &rows { + outln!("{}", r.iter().map(|c| csv_field(c, ',')).collect::>().join(",")); + } + return Ok(()); + } + let numeric = |ri: usize, ci: usize| raw["values"][ri][ci].is_number() && !formulas; + let ncols = rows.iter().map(Vec::len).max().unwrap_or(0); + let widths: Vec = (0..ncols).map(|c| rows.iter().filter_map(|r| r.get(c)).map(|t| t.chars().count()).max().unwrap_or(0).min(60)).collect(); + for (ri, r) in rows.iter().enumerate() { + let mut line = String::new(); + for (ci, cell) in r.iter().enumerate() { + let w = widths.get(ci).copied().unwrap_or(0); + let cell: String = cell.chars().map(|c| if c == '\n' || c == '\t' { ' ' } else { c }).collect(); + if ci > 0 { + line.push_str(" "); + } + if numeric(ri, ci) { + line.push_str(&format!("{cell:>w$}")); + } else { + line.push_str(&format!("{cell: Result<(), String> { + let a = Args::parse(args, &["range", "sheet"], &["formulas", "csv"])?; + let path = a.pos(0, "")?; + a.no_extra(1)?; + let mut s = open(path)?; + print_range(&mut s, a.opt("range"), a.opt("sheet"), a.has("formulas"), a.has("csv")) +} + +/// One command from `id={json}`, `id {json}`, `id` or `{"command": id, "params": {..}}`. +fn parse_command(text: &str) -> Result, String> { + let t = text.trim(); + if t.is_empty() || t.starts_with('#') || t.starts_with("//") { + return Ok(None); + } + if t.starts_with('{') { + let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; + return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); + } + let split = t.find(['=', ' ', '\t']); + let (id, rest) = match split { + Some(i) => (t.get(..i).unwrap_or(t), t.get(i + 1..).unwrap_or("").trim()), + None => (t, ""), + }; + let params = if rest.is_empty() { json!({}) } else { serde_json::from_str(rest).map_err(|e| format!("{id}: bad JSON params: {e}"))? }; + Ok(Some((id.to_string(), params))) +} + +fn run(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["in", "sample", "cmd", "script", "out", "print", "sheet"], &["quiet", "csv", "formulas"])?; + a.no_extra(0)?; + let mut s = match (a.opt("in"), a.opt("sample")) { + (Some(_), Some(_)) => return Err("give --in or --sample, not both".into()), + (Some(p), None) => open(p)?, + (None, Some(name)) => { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"sample": name}))?; + s + } + (None, None) => blank(), + }; + let quiet = a.has("quiet"); + let mut step = 0usize; + for (name, value) in &a.opts { + let cmds: Vec<(String, Value, String)> = match name.as_str() { + "cmd" => parse_command(value)?.map(|(id, p)| vec![(id, p, "--cmd".to_string())]).unwrap_or_default(), + "script" => { + let text = std::fs::read_to_string(value).map_err(|e| format!("{value}: {e}"))?; + let mut v = Vec::new(); + for (i, line) in text.lines().enumerate() { + if let Some((id, p)) = parse_command(line).map_err(|e| format!("{value}:{}: {e}", i + 1))? { + v.push((id, p, format!("{value}:{}", i + 1))); + } + } + v + } + _ => continue, + }; + for (id, p, origin) in cmds { + step += 1; + let r = exec(&mut s, &id, p).map_err(|e| format!("step {step} ({origin}, {id}): {e}"))?; + if !quiet && !r.is_null() { + outln!("{}", serde_json::to_string(&json!({"command": id, "result": r})).unwrap_or_default()); + } + } + } + if let Some(out) = a.opt("out") { + let r = exec(&mut s, "file.save", json!({"path": out}))?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); + } + for (name, range) in &a.opts { + if name == "print" { + let range = if range.is_empty() || range == "used" { None } else { Some(range.as_str()) }; + print_range(&mut s, range, a.opt("sheet"), a.has("formulas"), a.has("csv"))?; + } + } + Ok(()) +} + +fn commands(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &["search"], &["json"])?; + let q = a.opt("search").or(a.positional.first().map(String::as_str)).map(str::to_ascii_lowercase); + let s = Session::new(); + let list: Vec<_> = s + .commands() + .into_iter() + .filter(|c| { + q.as_ref().is_none_or(|q| { + c.id.to_ascii_lowercase().contains(q.as_str()) + || c.label.to_ascii_lowercase().contains(q.as_str()) + || c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str())) + }) + }) + .collect(); + if a.has("json") { + // Enablement depends on a live workbook; not meaningful here. + let v: Vec = + list.iter().map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params})).collect(); + outln!("{}", pretty(&Value::Array(v))); + return Ok(()); + } + let w = list.iter().map(|c| c.id.len()).max().unwrap_or(0); + for c in &list { + let sc = c.shortcut.map(|s| format!(" [{s}]")).unwrap_or_default(); + let menu = if c.menu.is_empty() { String::new() } else { format!(" ({})", c.menu.join(" > ")) }; + outln!("{: Result<(), String> { + let a = Args::parse(args, &["search", "category"], &["json"])?; + let mut p = json!({}); + if let Some(q) = a.opt("search").or(a.positional.first().map(String::as_str)) { + p["search"] = json!(q); + } + if let Some(c) = a.opt("category") { + p["category"] = json!(c); + } + let mut s = Session::new(); + let v = exec(&mut s, "formulas.functions", p)?; + if a.has("json") { + outln!("{}", pretty(&v)); + return Ok(()); + } + let list = v.as_array().cloned().unwrap_or_default(); + for f in &list { + let sig = f["signature"].as_str().or(f["name"].as_str()).unwrap_or("?"); + outln!("{sig}"); + let cat = f["category"].as_str().unwrap_or(""); + let desc = f["description"].as_str().unwrap_or(""); + outln!(" {cat}: {desc}"); + } + eprintln!("{} functions", list.len()); + Ok(()) +} + +/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app +/// started with `sheetcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. +fn mcp(args: &[String]) -> Result<(), String> { + use sheetcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; + let a = Args::parse(args, &["connect", "in", "sample"], &[])?; + a.no_extra(0)?; + let backend: Box = match a.opt("connect") { + Some(c) => { + let addr = control_addr(c); + Box::new( + Remote::connect(&addr) + .map_err(|e| format!("cannot connect to the SheetCraft app at {addr}: {e} (start it with `sheetcraft --control PORT`)"))?, + ) + } + None => { + let session = match (a.opt("in"), a.opt("sample")) { + (Some(p), _) => open(p)?, + (None, Some(name)) => { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"sample": name}))?; + s + } + (None, None) => blank(), + }; + Box::new(Headless::with_session(session)) + } + }; + eprintln!("sheetcraft-cli: MCP server on stdio ({})", backend.describe()); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) +} + +/// `send PORT METHOD [JSON]`: one control-channel request to a running app. +fn send(args: &[String]) -> Result<(), String> { + use sheetcraft_mcp::{Backend, Remote, control_addr}; + let port = args.first().ok_or_else(|| format!("missing \n{USAGE}"))?; + let method = args.get(1).ok_or_else(|| format!("missing \n{USAGE}"))?; + let params: Value = match args.get(2) { + Some(t) => serde_json::from_str(t).map_err(|e| format!("bad JSON params: {e}"))?, + None => json!({}), + }; + if let Some(x) = args.get(3) { + return Err(format!("unexpected argument `{x}`")); + } + let addr = control_addr(port); + let mut r = Remote::connect(&addr) + .map_err(|e| format!("cannot connect to the SheetCraft app at {addr}: {e} (start it with `sheetcraft --control {port}`)"))?; + let v = r.call(method, params)?; + outln!("{}", pretty(&v)); + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn parses_commands() { + assert_eq!(parse_command("home.bold={\"range\":\"A1\"}").unwrap(), Some(("home.bold".into(), json!({"range": "A1"})))); + assert_eq!(parse_command("edit.undo").unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command("cell.set {\"cell\":\"A1\",\"input\":\"=1\"}").unwrap(), + Some(("cell.set".into(), json!({"cell": "A1", "input": "=1"}))) + ); + assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); + assert_eq!(parse_command(" # comment").unwrap(), None); + assert!(parse_command("x={bad").is_err()); + } + + #[test] + fn value_texts() { + assert_eq!(value_text(&json!(6.0)), "6"); + assert_eq!(value_text(&json!(0.25)), "0.25"); + assert_eq!(value_text(&json!(true)), "TRUE"); + assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!"); + assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx"); + } + + #[test] + fn args_parsing() { + let v: Vec = ["a", "--range", "A1", "--csv", "--sheet=S"].iter().map(|s| s.to_string()).collect(); + let a = Args::parse(&v, &["range", "sheet"], &["csv"]).unwrap(); + assert_eq!(a.positional, ["a"]); + assert_eq!(a.opt("range"), Some("A1")); + assert_eq!(a.opt("sheet"), Some("S")); + assert!(a.has("csv")); + assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err()); + assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err()); + } +} diff --git a/apps/sheetcraft-cli/tests/cli.rs b/apps/sheetcraft-cli/tests/cli.rs new file mode 100644 index 000000000..f85903f21 --- /dev/null +++ b/apps/sheetcraft-cli/tests/cli.rs @@ -0,0 +1,152 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_sheetcraft-cli")) +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().expect("run sheetcraft-cli") +} + +fn ok(args: &[&str]) -> String { + let o = run(args); + assert!(o.status.success(), "sheetcraft-cli {args:?} failed: {}", String::from_utf8_lossy(&o.stderr)); + String::from_utf8(o.stdout).unwrap() +} + +fn tmpdir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("sheetcraft-cli-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn version_and_usage() { + assert_eq!(ok(&["--version"]).trim(), format!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION"))); + assert_eq!(ok(&["version"]).trim(), format!("sheetcraft-cli {}", env!("CARGO_PKG_VERSION"))); + let o = run(&["frobnicate"]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("unknown subcommand")); + let o = run(&[]); + assert!(!o.status.success()); +} + +#[test] +fn eval_formulas() { + assert_eq!(ok(&["eval", "=SUM(1,2,3)"]).trim(), "6"); + assert_eq!(ok(&["eval", "1/4"]).trim(), "0.25"); + assert_eq!(ok(&["eval", "=UPPER(\"abc\")"]).trim(), "ABC"); + assert_eq!(ok(&["eval", "=1/0"]).trim(), "#DIV/0!"); + assert_eq!(ok(&["eval", "=2>1", "--json"]).trim(), "true"); + let o = run(&["eval", "=1", "--in", "/no/such/file.xlsx"]); + assert!(!o.status.success()); + assert!(!String::from_utf8_lossy(&o.stderr).is_empty()); +} + +#[test] +fn run_then_cat_and_info_on_sample() { + let dir = tmpdir("run"); + let book = dir.join("budget.xlsx"); + let book_s = book.to_str().unwrap(); + let script = dir.join("steps.jsonl"); + std::fs::write( + &script, + "# steps\n{\"command\":\"cell.set\",\"params\":{\"cell\":\"K2\",\"input\":\"=K1*2\"}}\nhome.bold {\"range\":\"K1:K2\"}\n", + ) + .unwrap(); + let out = ok(&[ + "run", + "--sample", + "budget", + "--cmd", + r#"cell.set={"cell":"K1","input":"21"}"#, + "--script", + script.to_str().unwrap(), + "--out", + book_s, + "--print", + "K1:K2", + "--quiet", + ]); + assert_eq!(out.split_whitespace().collect::>(), ["21", "42"], "{out}"); + assert!(book.exists()); + + let table = ok(&["cat", book_s, "--range", "K1:K2"]); + assert_eq!(table.split_whitespace().collect::>(), ["21", "42"]); + let formulas = ok(&["cat", book_s, "--range", "K2", "--formulas"]); + assert_eq!(formulas.trim(), "=K1*2"); + let whole = ok(&["cat", book_s]); + assert!(whole.lines().count() > 3, "{whole}"); + + let info = ok(&["info", book_s]); + assert!(info.contains("Budget"), "{info}"); + let json: serde_json::Value = serde_json::from_str(&ok(&["info", book_s, "--json"])).unwrap(); + assert!(json["sheets"][0]["cells"].as_u64().unwrap() > 10); + + // A failing command gives a non-zero exit and names the step. + let o = run(&["run", "--cmd", "no.such.command"]); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("no.such.command")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn convert_xlsx_csv_roundtrip() { + let dir = tmpdir("convert"); + let xlsx = dir.join("in.xlsx"); + let csv = dir.join("out.csv"); + let back = dir.join("back.xlsx"); + ok(&[ + "run", + "--cmd", + r#"range.setValues={"range":"A1","values":[["Name","Qty"],["apple",3],["pear, green",4],["total","=SUM(B2:B3)"]]}"#, + "--out", + xlsx.to_str().unwrap(), + "--quiet", + ]); + ok(&["convert", xlsx.to_str().unwrap(), csv.to_str().unwrap()]); + let text = std::fs::read_to_string(&csv).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines, ["Name,Qty", "apple,3", "\"pear, green\",4", "total,7"], "{text}"); + ok(&["convert", csv.to_str().unwrap(), back.to_str().unwrap()]); + let again = ok(&["cat", back.to_str().unwrap(), "--csv"]); + assert_eq!(again.lines().collect::>(), lines); + let o = run(&["convert", xlsx.to_str().unwrap(), dir.join("x.unknown").to_str().unwrap()]); + assert!(!o.status.success()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn commands_and_functions() { + let out = ok(&["commands", "--search", "bold"]); + assert!(out.contains("home.bold")); + let v: serde_json::Value = serde_json::from_str(&ok(&["commands", "--json"])).unwrap(); + assert!(v.as_array().unwrap().len() > 100); + assert!(ok(&["functions", "--search", "vlookup"]).contains("VLOOKUP")); +} + +#[test] +fn mcp_over_stdio() { + let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"evaluate_formula","arguments":{{"formula":"=6*7"}}}}}}"# + ) + .unwrap(); + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + let lines: Vec = text.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert_eq!(lines[0]["result"]["serverInfo"]["name"], "sheetcraft"); + assert!(lines[1]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); +} diff --git a/apps/sheetcraft/Cargo.toml b/apps/sheetcraft/Cargo.toml new file mode 100644 index 000000000..2872e6792 --- /dev/null +++ b/apps/sheetcraft/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sheetcraft" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +default-run = "sheetcraft" +description = "SheetCraft desktop app" + +[lints] +workspace = true + +[dependencies] +sheetcraft-engine = { workspace = true } +sheetcraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +image = { workspace = true } + +[build-dependencies] +# Icon + VERSIONINFO for sheetcraft.exe; build.rs only uses it when the target is Windows. +winresource = "0.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } diff --git a/apps/sheetcraft/build.rs b/apps/sheetcraft/build.rs new file mode 100644 index 000000000..969f95613 --- /dev/null +++ b/apps/sheetcraft/build.rs @@ -0,0 +1,30 @@ +//! Windows only: embed the app icon and version info (VERSIONINFO) into `sheetcraft.exe`, so it +//! shows in Explorer, the taskbar, the Start menu and Alt-Tab. +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `SHEETCRAFT_REQUIRE_WINRES=1` turns it +//! into an error (for release builds). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/app-icon/sheetcraft.ico"); + println!("cargo:rerun-if-env-changed=SHEETCRAFT_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/app-icon/sheetcraft.ico") + .set("ProductName", "SheetCraft") + .set("FileDescription", "SheetCraft spreadsheet") + .set("CompanyName", "Learning Machines LLC") + .set("LegalCopyright", "Copyright (c) the SheetCraft authors. MIT OR Apache-2.0.") + .set("OriginalFilename", "sheetcraft.exe") + .set("InternalName", "sheetcraft"); + if let Err(e) = res.compile() { + if std::env::var_os("SHEETCRAFT_REQUIRE_WINRES").is_some() { + eprintln!("embedding Windows resources failed: {e}"); + std::process::exit(1); + } + println!("cargo:warning=sheetcraft.exe built without icon/version resources: {e}"); + } +} diff --git a/apps/sheetcraft/src/control_server.rs b/apps/sheetcraft/src/control_server.rs new file mode 100644 index 000000000..42c388d9a --- /dev/null +++ b/apps/sheetcraft/src/control_server.rs @@ -0,0 +1,67 @@ +//! Localhost JSON-lines control server. Each connection may send many requests; each line +//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value as Json, json}; +use sheetcraft_ui_egui::ControlRequest; + +const MAX_LINE: usize = 64 * 1024 * 1024; + +pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { + let listener = TcpListener::bind(("127.0.0.1", port))?; + let (tx, rx) = channel(); + std::thread::Builder::new().name("control".into()).spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx)); + } + })?; + eprintln!("SheetCraft control channel listening on 127.0.0.1:{port}"); + Ok(rx) +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + let reader = BufReader::new(read); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + if line.len() > MAX_LINE { + let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"})); + continue; + } + let msg: Json = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); + continue; + } + }; + let id = msg.get("id").cloned().unwrap_or(Json::Null); + let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + // Wake the UI thread: egui only runs frames when something happens. + ctx.request_repaint(); + let mut reply = rx + .recv_timeout(Duration::from_secs(60)) + .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); + if let Some(o) = reply.as_object_mut() { + o.insert("id".into(), id); + } + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/sheetcraft/src/main.rs b/apps/sheetcraft/src/main.rs new file mode 100644 index 000000000..d45289e16 --- /dev/null +++ b/apps/sheetcraft/src/main.rs @@ -0,0 +1,193 @@ +//! SheetCraft desktop app. +//! +//! Usage: `sheetcraft [--control ] [--sample ] [files…]` +//! +//! `--control ` (or `SHEETCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control +//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! See `sheetcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod control_server; + +use serde_json::json; +use sheetcraft_engine::Session; +use sheetcraft_ui_egui::{Services, SheetApp}; + +struct App(SheetApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + // Files dropped on the window open as workbooks. + for f in std::mem::take(&mut raw.dropped_files) { + if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) { + self.0.open_path(&p); + } + } + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } + fn on_exit(&mut self) { + save_prefs(&self.0); + } +} + +fn config_dir() -> Option { + if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SheetCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SheetCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("sheetcraft")) + } +} + +fn load_prefs(app: &mut SheetApp) { + if std::env::var_os("SHEETCRAFT_NO_PREFS").is_some() { + return; + } + let Some(dir) = config_dir() else { return }; + if let Ok(b) = std::fs::read(dir.join("ui.json")) + && let Ok(ui) = serde_json::from_slice(&b) + { + app.ui = ui; + } + if let Ok(b) = std::fs::read(dir.join("prefs.json")) + && let Ok(p) = serde_json::from_slice(&b) + { + app.session.prefs = p; + } +} + +fn save_prefs(app: &SheetApp) { + if std::env::var_os("SHEETCRAFT_NO_PREFS").is_some() { + return; + } + let Some(dir) = config_dir() else { return }; + let _ = std::fs::create_dir_all(&dir); + if let Ok(b) = serde_json::to_vec_pretty(&app.ui) { + let _ = std::fs::write(dir.join("ui.json"), b); + } + if let Ok(b) = serde_json::to_vec_pretty(&app.session.prefs) { + let _ = std::fs::write(dir.join("prefs.json"), b); + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]) + .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("CSV", &["csv"]) + .pick_file() + .and_then(|p| p.to_str().map(str::to_string)) + })), + pick_save: Some(Box::new(|suggested: &str| { + let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + rfd::FileDialog::new() + .set_file_name(format!("{stem}.xlsx")) + .add_filter("Excel Workbook (.xlsx)", &["xlsx"]) + .add_filter("CSV UTF-8 (.csv)", &["csv"]) + .add_filter("Web Page (.html)", &["html"]) + .save_file() + .and_then(|p| p.to_str().map(str::to_string)) + })), + open_url: Some(Box::new(|url: &str| { + let cmd = if cfg!(target_os = "macos") { + "open" + } else if cfg!(windows) { + "explorer" + } else { + "xdg-open" + }; + let _ = std::process::Command::new(cmd).arg(url).spawn(); + })), + download: None, + } +} + +fn icon() -> Option { + let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png"); + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) +} + +fn main() -> eframe::Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("SheetCraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + let mut control_port: Option = std::env::var("SHEETCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut sample: Option = None; + let mut files: Vec = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--control" => { + i += 1; + control_port = args.get(i).and_then(|p| p.parse().ok()); + } + "--sample" => { + i += 1; + sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into())); + } + a if a.starts_with("-psn_") => {} + a => files.push(a.to_string()), + } + i += 1; + } + let mut session = Session::new(); + if let Some(s) = &sample + && let Err(e) = session.execute("file.new", json!({"sample": s})) + { + eprintln!("sample: {e}"); + } + for f in &files { + if let Err(e) = session.execute("file.open", json!({"path": f})) { + eprintln!("{f}: {e}"); + } + } + if session.documents().is_empty() { + session.new_workbook(); + } + let mut app = SheetApp::new(session, services()); + let control_port = control_port; + load_prefs(&mut app); + let mut viewport = egui::ViewportBuilder::default() + .with_title("SheetCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([640.0, 420.0]) + .with_app_id("ai.storyteller.sheetcraft") + .with_drag_and_drop(true); + if cfg!(target_os = "macos") { + viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); + } + if let Some(i) = icon() { + viewport = viewport.with_icon(i); + } + let options = eframe::NativeOptions { viewport, ..Default::default() }; + eframe::run_native( + "SheetCraft", + options, + Box::new(move |cc| { + SheetApp::setup_context(&cc.egui_ctx, app.ui.dark); + 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}"), + } + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/assets/app-icon/LICENSE.txt b/assets/app-icon/LICENSE.txt new file mode 100644 index 000000000..15da9c41d --- /dev/null +++ b/assets/app-icon/LICENSE.txt @@ -0,0 +1,13 @@ +SheetCraft app icon (sheetcraft.svg and every file derived from it in this directory: +sheetcraft-1024.png, sheetcraft-macos-512.png, sheetcraft.icns, sheetcraft.ico, hicolor/). + +PLACEHOLDER artwork: original work written for this project by SheetCraft contributors (simple +hand-written SVG geometry). No third-party material. Licensed like the rest of SheetCraft, under +either of + + * Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * MIT license (https://opensource.org/licenses/MIT) + +at your option. + +Regenerate the derived files with packaging/icons.sh. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md new file mode 100644 index 000000000..75f313b7f --- /dev/null +++ b/assets/app-icon/README.md @@ -0,0 +1,52 @@ +# SheetCraft app icon (PLACEHOLDER) + +**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the +product owner makes (craftrules `standards/icon-design.md`); SheetCraft's hasn't been made yet. +Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number +column, a selected cell with its fill handle and a double rule under a total, on a full-bleed +SheetCraft-green tile. It is original geometry written by hand for this repository, deliberately +not modelled on any vendor's spreadsheet logo (no letters, no "X", no overlapping panels). + +When the owner's art arrives, replace `sheetcraft.svg`, run `packaging/icons.sh`, update this +README (provenance, creature) and the rows in `ATTRIBUTION.md`, and drop the word placeholder. + +## Palette + +Exactly three colours: + +| Colour | Hex | Used for | +|---|---|---| +| Ink | `#0b0b0c` | Line work and contour | +| Paper | `#efe9dc` | The page | +| SheetCraft green (app colour, **proposed**) | `#1f9d55` | The full-bleed field and the header cells | + +The app colour is a proposal pending the owner's approval: SheetCraft green `#1f9d55`, with ink +(darker) variant `#147a40` for text and accents on light backgrounds. It differs from +DesignCraft's `#7bb51c`. + +## Geometry + +A 512 × 512 viewBox, clipped to a rounded square with `rx=112`. Windows and Linux use the full +tile. The macOS icons (`.icns`, `sheetcraft-macos-512.png`) sit on Apple's grid: an 824/1024 +body with a transparent margin. + +## Files + +| File | Use | +|---|---| +| `sheetcraft.svg` | Canonical artwork; also `hicolor/scalable` | +| `sheetcraft-1024.png` | 1024 px render of the full tile | +| `sheetcraft-macos-512.png` | Runtime Dock icon on macOS (for `apps/sheetcraft` to embed) | +| `sheetcraft.icns` | macOS bundle icon (`CFBundleIconFile`) | +| `sheetcraft.ico` | Windows exe icon, 16–256 px (embedded by `apps/sheetcraft/build.rs`) | +| `hicolor//apps/ai.storyteller.sheetcraft.png` | Linux icon theme, 16–512 px; the 256 px one is also the runtime window icon on Windows and Linux | +| `hicolor/scalable/apps/ai.storyteller.sheetcraft.svg` | Linux scalable icon | +| `LICENSE.txt` | MIT OR Apache-2.0 | + +The app ID is `ai.storyteller.sheetcraft`. It's used for the hicolor icon names, the Wayland app +ID and `packaging/linux/ai.storyteller.sheetcraft.desktop`. + +## Regenerate + +Edit `sheetcraft.svg`, then run `packaging/icons.sh`. It needs `resvg`, and `iconutil` for the +`.icns` (macOS only). It packs the `.ico` with `cargo xtask ico`. diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..2951436a1 Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..cf5388439 Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..575677d3c Binary files /dev/null and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..be57e1d14 Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..69120d71a Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..3912b719d Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..54ed119e4 Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png new file mode 100644 index 000000000..7953230f0 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg new file mode 100644 index 000000000..548a27291 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/sheetcraft-1024.png b/assets/app-icon/sheetcraft-1024.png new file mode 100644 index 000000000..3ab35eafb Binary files /dev/null and b/assets/app-icon/sheetcraft-1024.png differ diff --git a/assets/app-icon/sheetcraft-macos-512.png b/assets/app-icon/sheetcraft-macos-512.png new file mode 100644 index 000000000..b6b38b9b0 Binary files /dev/null and b/assets/app-icon/sheetcraft-macos-512.png differ diff --git a/assets/app-icon/sheetcraft.icns b/assets/app-icon/sheetcraft.icns new file mode 100644 index 000000000..29ce001b6 Binary files /dev/null and b/assets/app-icon/sheetcraft.icns differ diff --git a/assets/app-icon/sheetcraft.ico b/assets/app-icon/sheetcraft.ico new file mode 100644 index 000000000..35e37b8d6 Binary files /dev/null and b/assets/app-icon/sheetcraft.ico differ diff --git a/assets/app-icon/sheetcraft.svg b/assets/app-icon/sheetcraft.svg new file mode 100644 index 000000000..548a27291 --- /dev/null +++ b/assets/app-icon/sheetcraft.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index c32d16d94..fa3401905 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -336,12 +336,16 @@ impl<'h> Evaluator<'h> { }; } let v = self.value(x); - Ev::V(map_unary(&v, &|n| match op { - UnOp::Neg => Value::number(-n), - UnOp::Plus => Value::number(n), - UnOp::Percent => Value::number(n / 100.0), - UnOp::At => Value::number(n), - }, op == UnOp::Plus)) + Ev::V(map_unary( + &v, + &|n| match op { + UnOp::Neg => Value::number(-n), + UnOp::Plus => Value::number(n), + UnOp::Percent => Value::number(n / 100.0), + UnOp::At => Value::number(n), + }, + op == UnOp::Plus, + )) } fn implicit_intersect(&self, a: &Area) -> Option { @@ -711,8 +715,7 @@ impl<'h> Evaluator<'h> { evaluated.push(Arg { value, from_ref }); } let mut ctx = FnCtx { ev: self }; - sheetcraft_functions::call(spec, &evaluated, &mut ctx) - .pipe(Ev::V) + sheetcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { @@ -862,7 +865,10 @@ impl<'h> Evaluator<'h> { let mut out = Vec::with_capacity(rows * cols); for r in 0..rows { for c in 0..cols { - let bound = vec![(l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0)))]; + let bound = vec![ + (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), + (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), + ]; let v = self.call_lambda_values(&l, bound); out.push(self.deref(v).scalar()); } @@ -1323,7 +1329,11 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { } } } - Expr::Call(n, _) if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => dynamic = true, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + dynamic = true + } Expr::Name(_) | Expr::Struct(_) => dynamic = true, Expr::Call(n, _) if sheetcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, _ => {} @@ -1370,8 +1380,38 @@ fn is_special(n: &str) -> bool { /// Names of functions the evaluator implements itself (merged into the function list for UI). pub const SPECIAL_FUNCTIONS: &[&str] = &[ - "IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH", "ROW", "COLUMN", "ROWS", "COLUMNS", "AREAS", "ISREF", "ISFORMULA", "FORMULATEXT", "SHEET", "SHEETS", "OFFSET", - "INDIRECT", "INDEX", "CELL", "SUBTOTAL", "AGGREGATE", "TEXT", "LET", "LAMBDA", "ISOMITTED", "MAP", "REDUCE", "SCAN", "BYROW", "BYCOL", "MAKEARRAY", + "IF", + "IFS", + "IFERROR", + "IFNA", + "CHOOSE", + "SWITCH", + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "AREAS", + "ISREF", + "ISFORMULA", + "FORMULATEXT", + "SHEET", + "SHEETS", + "OFFSET", + "INDIRECT", + "INDEX", + "CELL", + "SUBTOTAL", + "AGGREGATE", + "TEXT", + "LET", + "LAMBDA", + "ISOMITTED", + "MAP", + "REDUCE", + "SCAN", + "BYROW", + "BYCOL", + "MAKEARRAY", ]; pub fn is_known_function(n: &str) -> bool { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 4b28cd049..72724e8b6 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -17,7 +17,11 @@ impl T { T { wb: Workbook::new(), calc: Calc::new() } } fn set(&mut self, at: &str, input: &str) { - let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(sheetcraft_core::parse::parse_input(input, self.wb.date_system).value) }; + let cell = if input.starts_with('=') { + Cell::formula(Formula::new(input)) + } else { + Cell::value(sheetcraft_core::parse::parse_input(input, self.wb.date_system).value) + }; self.wb.sheet_mut(0).unwrap().cells.set(c(at), cell); self.calc.cells_changed(&mut self.wb, &[(0, c(at))]); } diff --git a/crates/chart/Cargo.toml b/crates/chart/Cargo.toml new file mode 100644 index 000000000..081820745 --- /dev/null +++ b/crates/chart/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sheetcraft-chart" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft charts: data resolution, layout into toolkit-free primitives, sparklines and a small rasteriser" + +[lints] +workspace = true + +[dependencies] +sheetcraft-core = { workspace = true } +sheetcraft-model = { workspace = true } +sheetcraft-calc = { workspace = true } +sheetcraft-numfmt = { workspace = true } +serde = { workspace = true } diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs new file mode 100644 index 000000000..8ca961089 --- /dev/null +++ b/crates/chart/src/axis.rs @@ -0,0 +1,164 @@ +//! Value-axis scaling ("nice numbers") and label formatting. + +use sheetcraft_core::{DateSystem, Value, number_to_text}; +use sheetcraft_numfmt::{NumberFormat, format_value}; + +/// Largest magnitude we plot; bigger values are clamped so ranges stay finite. +pub(crate) const MAX_ABS: f64 = 1e300; + +/// A linear axis scale: `min..=max` with gridlines every `step`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Scale { + pub min: f64, + pub max: f64, + pub step: f64, +} + +impl Scale { + /// Tick values from `min` to `max` (at most 51). + pub fn ticks(&self) -> Vec { + let span = self.max - self.min; + if !(span.is_finite() && self.step.is_finite() && self.step > 0.0 && span > 0.0) { + return vec![self.min]; + } + let n = ((span / self.step).round() as i64).clamp(1, 50); + (0..=n) + .map(|i| { + let v = self.min + i as f64 * self.step; + // Snap away accumulated float error. + let snapped = (v / self.step).round() * self.step; + if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped } + }) + .collect() + } + /// Position of `v` along the axis, 0 at `min`, 1 at `max` (clamped to a sane band). + pub fn frac(&self, v: f64) -> f32 { + let span = self.max - self.min; + if !(span.is_finite() && span > 0.0 && v.is_finite()) { + return 0.0; + } + (((v - self.min) / span).clamp(-0.5, 1.5)) as f32 + } +} + +pub(crate) fn clean(v: f64) -> f64 { + if v.is_finite() { v.clamp(-MAX_ABS, MAX_ABS) } else { 0.0 } +} + +/// Picks a "nice" scale (steps of 1, 2 or 5 × 10ⁿ, 4–9 gridlines) covering `lo..=hi`. +/// +/// `force_zero` always includes 0 (columns, bars, areas). Otherwise 0 is still included when the +/// data sits reasonably close to it (min below 5/6 of max), like common spreadsheet behaviour. +/// A little headroom is added past the data so the extreme value doesn't touch the frame. +pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale { + let (mut lo, mut hi) = (clean(lo), clean(hi)); + if lo > hi { + std::mem::swap(&mut lo, &mut hi); + } + if force_zero || (lo > 0.0 && lo < hi * 5.0 / 6.0) || lo == hi { + lo = lo.min(0.0); + } + if force_zero || (hi < 0.0 && hi > lo * 5.0 / 6.0) || lo == hi { + hi = hi.max(0.0); + } + if lo == hi { + // Both zero. + return Scale { min: 0.0, max: 1.0, step: 0.2 }; + } + let range = hi - lo; + if hi > 0.0 { + hi += range * 0.05; + } + if lo < 0.0 { + lo -= range * 0.05; + } + let range = hi - lo; + if !range.is_finite() || range <= 0.0 { + return Scale { min: 0.0, max: 1.0, step: 0.2 }; + } + let e = (range / 8.0).log10().floor(); + let mut chosen = None; + 'outer: for k in [e - 1.0, e, e + 1.0, e + 2.0] { + let p = 10f64.powf(k); + for m in [1.0, 2.0, 5.0] { + let s = m * p; + if !(s.is_finite() && s > 0.0) { + continue; + } + let intervals = (hi / s - 1e-9).ceil() - (lo / s + 1e-9).floor(); + if intervals <= 8.0 { + chosen = Some(s); + break 'outer; + } + } + } + let step = chosen.unwrap_or(range); + let mut min = (lo / step + 1e-9).floor() * step; + let mut max = (hi / step - 1e-9).ceil() * step; + if min.abs() < step * 1e-9 { + min = 0.0; + } + if max.abs() < step * 1e-9 { + max = 0.0; + } + if !(min.is_finite() && max.is_finite()) || max <= min { + return Scale { min: lo, max: hi, step: range }; + } + Scale { min, max, step } +} + +/// Exact fixed scale (100% stacked axes). +pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale { + Scale { min, max, step } +} + +/// A number format prepared once and reused for many labels. +pub(crate) struct Fmt { + general: bool, + fmt: NumberFormat, +} + +impl Fmt { + pub fn new(code: &str) -> Fmt { + let t = code.trim(); + let general = t.is_empty() || t.eq_ignore_ascii_case("general"); + Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) } + } + /// Formats `v`; `step` (axis labels) fixes the decimals for General. + pub fn format(&self, v: f64, step: Option) -> String { + let mut v = clean(v); + if let Some(s) = step + && s > 0.0 + && (v / s).abs() < 1e-9 + { + v = 0.0; + } + if self.general { + return general_text(v, step); + } + let mut t = format_value(&Value::Number(v), &self.fmt, DateSystem::D1900).text; + if t.chars().count() > 40 { + t = t.chars().take(40).collect(); + } + t + } +} + +fn general_text(v: f64, step: Option) -> String { + if v == 0.0 { + return "0".into(); + } + match step { + Some(s) if s.is_finite() && s > 0.0 && v.abs() < 1e11 && v.abs() >= 1e-9 => { + let d = (-s.log10().floor()).clamp(0.0, 10.0) as usize; + let t = format!("{:.*}", d, v); + if t.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { t } + } + _ => number_to_text(v), + } +} + +/// Formats a number with a format code (General shows up to the precision implied by `step`). +pub fn format_number(v: f64, code: &str, step: Option) -> String { + Fmt::new(code).format(v, step) +} diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs new file mode 100644 index 000000000..2b892d3cc --- /dev/null +++ b/crates/chart/src/lib.rs @@ -0,0 +1,158 @@ +//! SheetCraft charts (layer L3, toolkit-free). +//! +//! - [`resolve`] pulls series names, categories and values out of a workbook, +//! - [`render`] lays a chart out into a list of drawing primitives ([`Prim`]) that any UI +//! toolkit (or the built-in [`rasterize`]) can paint, +//! - [`render_sparkline`] draws in-cell sparklines, +//! - [`rasterize`] turns primitives into RGBA pixels for PNG export and tests. +//! +//! The look (palette, greys, spacing) is SheetCraft's own design. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod axis; +mod raster; +mod render; +mod resolve; +mod sparkline; + +#[cfg(test)] +mod tests; + +use serde::{Deserialize, Serialize}; +pub use sheetcraft_model::{Chart, ChartKind, LegendPos, SparklineKind}; + +pub use axis::{Scale, format_number, nice_scale}; +pub use raster::rasterize; +pub use render::{Swatch, legend_entries, render}; +pub use resolve::{default_palette, resolve, series_color}; +pub use sparkline::render_sparkline; + +/// Straight (non-premultiplied) RGBA colour. +pub type Rgba = [u8; 4]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum HAlign { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum VAlign { + #[default] + Top, + Middle, + Bottom, +} + +/// A drawing primitive in chart coordinates (origin top-left, y down, units = points/pixels). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Prim { + Rect { + x: f32, + y: f32, + w: f32, + h: f32, + fill: Option, + stroke: Option<(Rgba, f32)>, + radius: f32, + }, + Line { + pts: Vec<[f32; 2]>, + color: Rgba, + width: f32, + dash: bool, + }, + Polygon { + pts: Vec<[f32; 2]>, + fill: Rgba, + stroke: Option<(Rgba, f32)>, + }, + /// Pie/doughnut sector: centre, radii, angles in radians (0 = 12 o'clock, clockwise). + Wedge { + cx: f32, + cy: f32, + r_outer: f32, + r_inner: f32, + a0: f32, + a1: f32, + fill: Rgba, + stroke: Option<(Rgba, f32)>, + }, + Circle { + cx: f32, + cy: f32, + r: f32, + fill: Option, + stroke: Option<(Rgba, f32)>, + }, + /// Text anchored at `(x, y)` per `align`/`valign`, rotated by `rotation` radians about the anchor. + Text { + x: f32, + y: f32, + text: String, + size: f32, + color: Rgba, + bold: bool, + align: HAlign, + valign: VAlign, + rotation: f32, + }, +} + +/// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation). +pub trait Measure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32; +} + +/// Fixed-width approximation: `0.55 × size` per character (bold 10% wider). +#[derive(Clone, Copy, Debug, Default)] +pub struct ApproxMeasure; + +impl Measure for ApproxMeasure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + let n = text.chars().count() as f32; + n * 0.55 * size * if bold { 1.1 } else { 1.0 } + } +} + +/// Resolved data: series names, categories and values pulled from the workbook. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ChartData { + pub categories: Vec, + pub series: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SeriesData { + pub name: String, + pub values: Vec>, + /// Numeric X values (scatter, bubble). + pub x: Option>>, + /// Bubble sizes. + pub sizes: Option>>, + pub color: Rgba, + /// Per-series kind (combo charts). + pub kind: ChartKind, + /// Plotted against the secondary (right) value axis. + pub secondary: bool, + /// Number format code of the source values ("General" by default). + pub number_format: String, +} + +impl Default for SeriesData { + fn default() -> Self { + SeriesData { + name: String::new(), + values: vec![], + x: None, + sizes: None, + color: [0x15, 0x60, 0x82, 0xFF], + kind: ChartKind::ColumnClustered, + secondary: false, + number_format: "General".into(), + } + } +} diff --git a/crates/chart/src/raster.rs b/crates/chart/src/raster.rs new file mode 100644 index 000000000..5da9d3580 --- /dev/null +++ b/crates/chart/src/raster.rs @@ -0,0 +1,263 @@ +//! A small self-written rasteriser: every primitive becomes one or more even-odd filled paths, +//! scan-converted with 4 vertical sub-samples and exact horizontal coverage (anti-aliased). +//! Text is not drawn (tests check geometry, the UI draws real glyphs). + +use crate::{Prim, Rgba}; + +/// Largest image we allocate (pixels). +const MAX_PIXELS: u64 = 64 * 1024 * 1024; +const SUB: usize = 4; + +type Path = Vec>; + +struct Canvas { + w: usize, + h: usize, + buf: Vec, + acc: Vec, +} + +impl Canvas { + fn blend(&mut self, x: usize, y: usize, c: Rgba, cov: f32) { + let a = (cov.clamp(0.0, 1.0) * c[3] as f32 / 255.0).clamp(0.0, 1.0); + if a <= 0.0 { + return; + } + let i = (y * self.w + x) * 4; + if let Some(px) = self.buf.get_mut(i..i + 4) { + let da = px[3] as f32 / 255.0; + let oa = a + da * (1.0 - a); + if oa <= 0.0 { + return; + } + for k in 0..3 { + let s = c[k] as f32; + let d = px[k] as f32; + px[k] = ((s * a + d * da * (1.0 - a)) / oa).round().clamp(0.0, 255.0) as u8; + } + px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8; + } + } + + /// Fills a multi-contour path with the even-odd rule. + fn fill(&mut self, path: &Path, color: Rgba) { + let mut edges: Vec<[f32; 4]> = Vec::new(); + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for c in path { + if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) { + continue; + } + for (k, p) in c.iter().enumerate() { + let q = c.get((k + 1) % c.len()).copied().unwrap_or(*p); + minx = minx.min(p[0]); + maxx = maxx.max(p[0]); + miny = miny.min(p[1]); + maxy = maxy.max(p[1]); + if p[1] != q[1] { + edges.push([p[0], p[1], q[0], q[1]]); + } + } + } + if edges.is_empty() { + return; + } + let x0 = minx.floor().max(0.0) as usize; + let x1 = (maxx.ceil().max(0.0) as usize).min(self.w); + let y0 = miny.floor().max(0.0) as usize; + let y1 = (maxy.ceil().max(0.0) as usize).min(self.h); + if x0 >= x1 || y0 >= y1 { + return; + } + let bw = x1 - x0; + if self.acc.len() < bw + 1 { + self.acc.resize(bw + 1, 0.0); + } + let mut xs: Vec = Vec::new(); + for py in y0..y1 { + for v in self.acc.iter_mut().take(bw) { + *v = 0.0; + } + let mut any = false; + for s in 0..SUB { + let sy = py as f32 + (s as f32 + 0.5) / SUB as f32; + xs.clear(); + for e in &edges { + let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) }; + if sy >= ya && sy < yb { + xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1])); + } + } + xs.sort_by(f32::total_cmp); + for pair in xs.as_chunks::<2>().0 { + let (a, b) = (pair[0].max(x0 as f32), pair[1].min(x1 as f32)); + if b <= a { + continue; + } + any = true; + let ia = a.floor() as usize; + let ib = (b.ceil() as usize).min(x1); + for px in ia..ib { + let l = a.max(px as f32); + let r = b.min(px as f32 + 1.0); + if r > l + && let Some(v) = self.acc.get_mut(px - x0) + { + *v += (r - l) / SUB as f32; + } + } + } + } + if !any { + continue; + } + for px in x0..x1 { + let cov = self.acc.get(px - x0).copied().unwrap_or(0.0); + if cov > 0.0 { + self.blend(px, py, color, cov); + } + } + } + } +} + +fn arc(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> Vec<[f32; 2]> { + let sweep = a1 - a0; + let n = ((r.abs() * sweep.abs() / 2.0).ceil() as usize).clamp(4, 512); + (0..=n) + .map(|k| { + let a = a0 + sweep * k as f32 / n as f32; + [cx + r * a.sin(), cy - r * a.cos()] + }) + .collect() +} + +fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> { + let mut c = arc(cx, cy, r, 0.0, std::f32::consts::TAU); + c.pop(); + c +} + +fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> { + let r = r.clamp(0.0, w.min(h) / 2.0); + if r < 0.5 { + return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + } + use std::f32::consts::{FRAC_PI_2, PI}; + let mut v = Vec::new(); + v.extend(arc(x + w - r, y + r, r, 0.0, FRAC_PI_2)); + v.extend(arc(x + w - r, y + h - r, r, FRAC_PI_2, PI)); + v.extend(arc(x + r, y + h - r, r, PI, PI + FRAC_PI_2)); + v.extend(arc(x + r, y + r, r, PI + FRAC_PI_2, 2.0 * PI)); + v +} + +/// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel). +fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec { + let width = width.clamp(1.0, 50.0); + let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); + if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) { + segs.push((*a, *b)); + } + if dash { + let (on, off) = (width * 4.0, width * 3.0); + let mut out = Vec::new(); + for (a, b) in segs { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt(); + if !(len.is_finite() && len > 0.0) { + continue; + } + let mut t = 0.0; + let mut n = 0; + while t < len && n < 10_000 { + let e = (t + on).min(len); + out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len])); + t += on + off; + n += 1; + } + } + segs = out; + } + let hw = width / 2.0; + segs.into_iter() + .filter_map(|(a, b)| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt(); + if !(len.is_finite() && len > 1e-6) { + return None; + } + let (nx, ny) = (-dy / len * hw, dx / len * hw); + Some(vec![vec![[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny], [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]]]) + }) + .collect() +} + +/// Rasterises primitives to straight RGBA8 (`w*h*4` bytes) over `background`. Empty for a zero +/// or oversized image. Text primitives are skipped. +pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { + let px = (w as u64).saturating_mul(h as u64); + if w == 0 || h == 0 || px > MAX_PIXELS { + return vec![]; + } + let (w, h) = (w as usize, h as usize); + let mut buf = Vec::with_capacity(w * h * 4); + for _ in 0..w * h { + buf.extend_from_slice(&background); + } + let mut cv = Canvas { w, h, buf, acc: Vec::new() }; + for p in prims { + match p { + Prim::Rect { x, y, w, h, fill, stroke, radius } => { + if let Some(f) = fill { + cv.fill(&vec![rounded_rect(*x, *y, *w, *h, *radius)], *f); + } + if let Some((c, sw)) = stroke { + let sw = sw.clamp(0.5, 50.0); + let outer = rounded_rect(x - sw / 2.0, y - sw / 2.0, w + sw, h + sw, radius + sw / 2.0); + let inner = rounded_rect(x + sw / 2.0, y + sw / 2.0, (w - sw).max(0.0), (h - sw).max(0.0), (radius - sw / 2.0).max(0.0)); + cv.fill(&vec![outer, inner], *c); + } + } + Prim::Line { pts, color, width, dash } => { + for path in stroke_paths(pts, *width, *dash, false) { + cv.fill(&path, *color); + } + } + Prim::Polygon { pts, fill, stroke } => { + cv.fill(&vec![pts.clone()], *fill); + if let Some((c, sw)) = stroke { + for path in stroke_paths(pts, *sw, false, true) { + cv.fill(&path, *c); + } + } + } + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { + let mut c = arc(*cx, *cy, *r_outer, *a0, *a1); + if *r_inner > 0.0 { + let mut inner = arc(*cx, *cy, *r_inner, *a0, *a1); + inner.reverse(); + c.extend(inner); + } else { + c.push([*cx, *cy]); + } + cv.fill(&vec![c.clone()], *fill); + if let Some((sc, sw)) = stroke { + for path in stroke_paths(&c, *sw, false, true) { + cv.fill(&path, *sc); + } + } + } + Prim::Circle { cx, cy, r, fill, stroke } => { + if let Some(f) = fill { + cv.fill(&vec![circle(*cx, *cy, *r)], *f); + } + if let Some((c, sw)) = stroke { + let sw = sw.clamp(0.5, 50.0); + cv.fill(&vec![circle(*cx, *cy, r + sw / 2.0), circle(*cx, *cy, (r - sw / 2.0).max(0.0))], *c); + } + } + Prim::Text { .. } => {} + } + } + cv.buf +} diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs new file mode 100644 index 000000000..f584214f2 --- /dev/null +++ b/crates/chart/src/render.rs @@ -0,0 +1,1470 @@ +//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. + +use std::collections::HashSet; +use std::f32::consts::{PI, TAU}; + +use sheetcraft_model::{Chart, ChartKind, LegendPos, Theme}; + +use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; +use crate::resolve::{MAX_POINTS, MAX_SERIES, series_color}; +use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; + +pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; +const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF]; +const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF]; +const AXIS: Rgba = [0xBF, 0xBF, 0xBF, 0xFF]; +const WHITE: Rgba = [0xFF, 0xFF, 0xFF, 0xFF]; +const STOCK: Rgba = [0x40, 0x40, 0x40, 0xFF]; +pub(crate) const UP: Rgba = [0x3E, 0x9B, 0x5F, 0xFF]; +pub(crate) const DOWN: Rgba = [0xD6, 0x4F, 0x45, 0xFF]; + +const TITLE_SIZE: f32 = 14.0; +const LABEL_SIZE: f32 = 9.0; +const AXIS_TITLE_SIZE: f32 = 10.0; +/// Most categories drawn by category-axis charts. +const MAX_CATS: usize = 20_000; +/// Lines with more points than this are decimated (min/max per pixel column). +const MAX_LINE_PTS: usize = 2000; +/// Most markers per series (after de-duplicating by pixel). +const MAX_MARKERS: usize = 5000; +/// Data labels are skipped when a series has more points than this. +const MAX_LABELS: usize = 200; + +#[derive(Clone, Copy, Debug)] +struct Bx { + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl Bx { + fn right(&self) -> f32 { + self.x + self.w + } + fn bottom(&self) -> f32 { + self.y + self.h + } + fn ok(&self) -> bool { + self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 + } + fn cx(&self) -> f32 { + self.x + self.w / 2.0 + } + fn cy(&self) -> f32 { + self.y + self.h / 2.0 + } +} + +struct Ctx<'a> { + out: Vec, + m: &'a dyn Measure, +} + +impl Ctx<'_> { + fn tw(&self, s: &str, size: f32) -> f32 { + let w = self.m.text_width(s, size, false); + if w.is_finite() { w.max(0.0) } else { 0.0 } + } + fn fit(&self, s: &str, size: f32, maxw: f32) -> String { + if self.tw(s, size) <= maxw { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut n = chars.len().min(400); + while n > 0 { + n -= 1; + let t: String = chars.iter().take(n).collect::() + "…"; + if self.tw(&t, size) <= maxw { + return if n == 0 { String::new() } else { t }; + } + } + String::new() + } + #[allow(clippy::too_many_arguments)] + fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { + if s.is_empty() { + return; + } + self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 }); + } + fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) { + self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 }); + } + fn line(&mut self, pts: Vec<[f32; 2]>, color: Rgba, width: f32) { + if pts.len() >= 2 { + self.out.push(Prim::Line { pts, color, width, dash: false }); + } + } + fn hline(&mut self, x0: f32, x1: f32, y: f32, color: Rgba, width: f32) { + self.line(vec![[x0, y], [x1, y]], color, width); + } + fn vline(&mut self, x: f32, y0: f32, y1: f32, color: Rgba, width: f32) { + self.line(vec![[x, y0], [x, y1]], color, width); + } +} + +/// Text colour readable on `fill`. +fn on(fill: Rgba) -> Rgba { + let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; + if l < 150.0 { WHITE } else { DARK_TEXT } +} + +/// How a legend entry's swatch is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Swatch { + Box, + Line, + LineMarker, + Marker, +} + +/// The kind a series is drawn as (per-series kinds only apply to combo charts). +fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { + if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +} + +/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). +fn point_color(data: &ChartData, j: usize) -> Rgba { + match (j, data.series.first()) { + (0, Some(s)) => s.color, + _ => series_color(&Theme::default(), j), + } +} + +fn first_len(data: &ChartData) -> usize { + data.series.first().map(|s| s.values.len()).unwrap_or(0) +} + +fn cat_label(data: &ChartData, j: usize) -> String { + data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) +} + +/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. +pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { + if chart.legend == LegendPos::None { + return vec![]; + } + match chart.kind { + ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { + let n = first_len(data).min(MAX_CATS); + (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() + } + ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], + ChartKind::Waterfall => { + if data.series.is_empty() { + return vec![]; + } + let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); + vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] + } + _ => data + .series + .iter() + .take(MAX_SERIES) + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(), + } +} + +fn sanitize_vals(v: &[Option]) -> Vec> { + v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() +} + +fn sanitize(d: &ChartData) -> ChartData { + ChartData { + categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), + series: d + .series + .iter() + .take(MAX_SERIES) + .map(|s| SeriesData { + name: s.name.clone(), + values: sanitize_vals(&s.values), + x: s.x.as_deref().map(sanitize_vals), + sizes: s.sizes.as_deref().map(sanitize_vals), + color: s.color, + kind: s.kind, + secondary: s.secondary, + number_format: s.number_format.clone(), + }) + .collect(), + } +} + +fn chart_title(chart: &Chart, data: &ChartData) -> Option { + match &chart.title { + Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), + None => None, + } +} + +/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. +pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { + return vec![]; + } + let data = sanitize(data); + let mut ctx = Ctx { out: Vec::new(), m }; + ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 }); + let pad = (w.min(h) * 0.04).clamp(2.0, 10.0); + let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad }; + if let Some(t) = chart_title(chart, &data) { + let size = TITLE_SIZE.min(h * 0.12).max(4.0); + let t = ctx.fit(&t, size, area.w); + ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top); + let used = size * 1.3 + 4.0; + area.y += used; + area.h -= used; + } + let entries = legend_entries(chart, &data); + if !entries.is_empty() && area.ok() { + area = draw_legend(&mut ctx, &entries, chart.legend, area); + } + if area.ok() && !data.series.is_empty() { + draw_kind(&mut ctx, chart, &data, area); + } + finalize(ctx.out, w, h) +} + +fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + use ChartKind::*; + match chart.kind { + Pie => pie(ctx, chart, data, area, 0.0, false), + Doughnut => pie(ctx, chart, data, area, 0.5, false), + Sunburst => pie(ctx, chart, data, area, 0.2, true), + Treemap => treemap(ctx, chart, data, area), + Funnel => funnel(ctx, data, area), + Radar => radar(ctx, chart, data, area), + _ => { + let area = axis_titles(ctx, chart, area); + if !area.ok() { + return; + } + match chart.kind { + BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true), + Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area), + Histogram => histogram(ctx, chart, data, area), + Waterfall => waterfall(ctx, chart, data, area), + BoxWhisker => box_whisker(ctx, data, area), + Stock => stock(ctx, data, area), + _ => cartesian(ctx, chart, data, area, false), + } + } + } +} + +fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { + let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; + if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + area.h -= lh; + } + if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); + if !t.is_empty() { + ctx.out.push(Prim::Text { + x: area.x, + y: area.cy(), + text: t, + size: AXIS_TITLE_SIZE, + color: TEXT, + bold: false, + align: HAlign::Center, + valign: VAlign::Top, + rotation: -PI / 2.0, + }); + } + area.x += lh; + area.w -= lh; + } + area +} + +// ---------------------------------------------------------------- legend + +fn swatch_w(sw: Swatch, s: f32) -> f32 { + if sw == Swatch::Box { s } else { s * 2.0 } +} + +fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { + match sw { + Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), + Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), + Swatch::LineMarker => { + ctx.hline(x, x + 2.0 * s, yc, color, 2.0); + ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); + } + Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), + } +} + +fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { + let size = LABEL_SIZE; + let row_h = size * 1.6; + let s = size * 0.8; + let gap = 4.0; + let spacing = size * 1.3; + let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); + match pos { + LegendPos::None => area, + LegendPos::Top | LegendPos::Bottom => { + let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); + let mut rows: Vec> = vec![vec![]]; + let mut cur = 0.0f32; + for (i, e) in entries.iter().enumerate() { + let iw = item_w(ctx, e).min(area.w); + let need = if cur > 0.0 { spacing + iw } else { iw }; + if cur > 0.0 && cur + need > area.w { + if rows.len() >= max_rows { + break; + } + rows.push(vec![]); + cur = 0.0; + } + cur += if cur > 0.0 { spacing + iw } else { iw }; + if let Some(r) = rows.last_mut() { + r.push((i, iw)); + } + } + let lh = rows.len() as f32 * row_h; + let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; + for (ri, row) in rows.iter().enumerate() { + let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; + let mut x = area.x + (area.w - total).max(0.0) / 2.0; + let yc = y0 + ri as f32 * row_h + row_h / 2.0; + for &(i, iw) in row { + if let Some(e) = entries.get(i) { + draw_swatch(ctx, e.2, x, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); + ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + x += iw + spacing; + } + } + let used = lh + 4.0; + if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } + } + LegendPos::Left | LegendPos::Right => { + let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + let max_rows = ((area.h / row_h).floor() as usize).max(1); + let n = entries.len().min(max_rows); + let lh = n as f32 * row_h; + let y0 = area.y + (area.h - lh).max(0.0) / 2.0; + let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; + for (i, e) in entries.iter().take(n).enumerate() { + let yc = y0 + i as f32 * row_h + row_h / 2.0; + draw_swatch(ctx, e.2, x0, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); + ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + let used = colw + 8.0; + if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } + } + } +} + +// ---------------------------------------------------------------- axes frame + +enum CatAxis<'a> { + Labels(&'a [String]), + Numeric(Scale, &'a Fmt), +} + +struct ValAxis<'a> { + scale: Scale, + fmt: &'a Fmt, +} + +impl ValAxis<'_> { + fn labels(&self) -> Vec<(f64, String)> { + self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() + } +} + +/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. +fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { + let ls = LABEL_SIZE; + let lh = ls * 1.4; + let ylabels = y.labels(); + let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); + if !horizontal { + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let y2labels = y2.map(|a| a.labels()); + let right = match (&y2labels, &cat) { + (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), + (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), + _ => 4.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 }; + if !plot.ok() { + return plot; + } + for (v, t) in &ylabels { + let yy = plot.bottom() - y.scale.frac(*v) * plot.h; + if grid { + ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); + } + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + if let (Some(a), Some(l)) = (y2, &y2labels) { + for (v, t) in l { + let yy = plot.bottom() - a.scale.frac(*v) * plot.h; + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; + ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); + match cat { + CatAxis::Labels(l) => { + let n = l.len().max(1); + let cw = plot.w / n as f32; + let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in l.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, cw * k as f32 - 2.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + CatAxis::Numeric(s, f) => { + let ticks = s.ticks(); + let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let slot = plot.w / ticks.len().max(1) as f32; + let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (v, t) in ticks.iter().zip(&labels).step_by(k) { + let xx = plot.x + s.frac(*v) * plot.w; + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if s.min <= 0.0 && s.max >= 0.0 { + let xx = plot.x + s.frac(0.0) * plot.w; + ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); + } + } + } + plot + } else { + let labels: Vec = match cat { + CatAxis::Labels(l) => l.to_vec(), + CatAxis::Numeric(..) => vec![], + }; + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; + let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 }; + if !plot.ok() { + return plot; + } + let vw = maxw(ctx, &ylabels); + let slot = plot.w / ylabels.len().max(1) as f32; + let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (i, (v, t)) in ylabels.iter().enumerate() { + let xx = plot.x + y.scale.frac(*v) * plot.w; + if grid { + ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); + } + if i % k == 0 { + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let x0 = plot.x + y.scale.frac(zero) * plot.w; + ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); + let n = labels.len().max(1); + let ch = plot.h / n as f32; + let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in labels.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, lw - 6.0); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + plot + } +} + +// ---------------------------------------------------------------- helpers + +/// Reduces a long polyline to first/min/max/last per pixel column. +fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + if pts.len() <= max { + return pts; + } + let mut out = Vec::with_capacity(max * 2); + let mut i = 0; + while let Some(p) = pts.get(i) { + let col = p[0].floor(); + let (start, mut mn, mut mx) = (i, i, i); + let mut k = i + 1; + while let Some(q) = pts.get(k) { + if q[0].floor() != col { + break; + } + if pts.get(mn).is_some_and(|m| q[1] < m[1]) { + mn = k; + } + if pts.get(mx).is_some_and(|m| q[1] > m[1]) { + mx = k; + } + k += 1; + } + let mut idx = [start, mn, mx, k - 1]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last + && let Some(q) = pts.get(ix) + { + out.push(*q); + last = ix; + } + } + i = k; + } + if out.len() > max * 4 { + let step = out.len() / (max * 2) + 1; + let last = out.last().copied(); + let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); + if let Some(l) = last + && s.last() != Some(&l) + { + s.push(l); + } + return s; + } + out +} + +/// Draws a polyline split at gaps (`None`), decimated when long. +fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) { + let mut run: Vec<[f32; 2]> = Vec::new(); + for p in pts.iter().chain(std::iter::once(&None)) { + match p { + Some(p) => run.push(*p), + None => { + if run.len() >= 2 { + let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); + ctx.line(r, color, width); + } else { + run.clear(); + } + } + } + } +} + +fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { + let mut seen = HashSet::new(); + for p in pts { + if seen.len() >= MAX_MARKERS { + break; + } + if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); + } + } +} + +fn value_range(it: impl Iterator) -> Option<(f64, f64)> { + it.fold(None, |acc, v| match acc { + None => Some((v, v)), + Some((a, b)) => Some((a.min(v), b.max(v))), + }) +} + +// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fam { + Col, + ColStack, + Col100, + Line, + LineStack, + Area, + AreaStack, +} + +fn family(k: ChartKind) -> Fam { + use ChartKind::*; + match k { + ColumnStacked | BarStacked => Fam::ColStack, + ColumnStacked100 | BarStacked100 => Fam::Col100, + Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, + LineStacked => Fam::LineStack, + Area => Fam::Area, + AreaStacked => Fam::AreaStack, + _ => Fam::Col, + } +} + +fn is_col(f: Fam) -> bool { + matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) +} +fn is_line(f: Fam) -> bool { + matches!(f, Fam::Line | Fam::LineStack) +} + +fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + // Totals for 100% stacking, per axis. + let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]]; + for (i, s) in data.series.iter().enumerate() { + if fams.get(i) == Some(&Fam::Col100) { + let a = sec.get(i).copied().unwrap_or(false) as usize; + for (j, v) in s.values.iter().take(ncat).enumerate() { + if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) { + *t += v.abs(); + } + } + } + } + // Stack accumulators keyed by (axis, family): positive and negative running sums. + let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); + let mut spans: Vec>> = Vec::with_capacity(data.series.len()); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let a = sec.get(i).copied().unwrap_or(false); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack); + let key = (a, fam); + let si = if stacked { + match stacks.iter().position(|(k, _, _)| *k == key) { + Some(p) => Some(p), + None => { + stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); + Some(stacks.len() - 1) + } + } + } else { + None + }; + let mut row = Vec::with_capacity(ncat); + for j in 0..ncat { + let v = s.values.get(j).copied().flatten(); + let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { + (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { + let v = if fam == Fam::Col100 { + let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + } else { + v + }; + let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; + let base = *acc; + *acc += v; + Some((base, *acc)) + }), + (Some((_, pos, _)), _) => { + let v = v.unwrap_or(0.0); + pos.get_mut(j).map(|acc| { + let base = *acc; + *acc = clean(*acc + v); + (base, *acc) + }) + } + (None, _) => v.map(|v| (0.0, v)), + }; + row.push(span); + } + spans.push(row); + } + + // Axis scales. + let mut axes: Vec<(Scale, Fmt)> = Vec::new(); + for axis in [false, true] { + let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); + if members.is_empty() { + continue; + } + let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); + let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); + let range = value_range(members.iter().flat_map(|i| { + let line = fams.get(*i).is_some_and(|f| is_line(*f)); + spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) + })); + let (lo, hi) = range.unwrap_or((0.0, 0.0)); + let (scale, fmt) = if all100 { + (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + } else { + let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); + (nice_scale(lo, hi, force_zero), Fmt::new(code)) + }; + axes.push((scale, fmt)); + if !axis && sec.iter().all(|s| !s) { + break; + } + } + let Some((s1, f1)) = axes.first() else { return }; + let y1 = ValAxis { scale: *s1, fmt: f1 }; + let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); + if !plot.ok() { + return; + } + let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; + let cat_len = if horizontal { plot.h } else { plot.w }; + let cw = cat_len / ncat as f32; + let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; + let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; + + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; data.series.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + let nslots = slot_keys.len().max(1) as f32; + let bw = cw / (nslots + 1.5); + let labels_on = chart.data_labels && ncat <= MAX_LABELS; + + // Areas first, then columns, then lines on top. + for pass in 0..3 { + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let want = match pass { + 0 => matches!(fam, Fam::Area | Fam::AreaStack), + 1 => is_col(fam), + _ => is_line(fam), + }; + if !want { + continue; + } + let sc = scale_of(i); + let fmt = Fmt::new(&s.number_format); + let row = match spans.get(i) { + Some(r) => r, + None => continue, + }; + match pass { + 0 => { + let mut top = Vec::with_capacity(ncat * 2); + let mut base = Vec::with_capacity(ncat); + for (j, sp) in row.iter().enumerate() { + let (b, t) = sp.unwrap_or((0.0, 0.0)); + let c = center(j); + top.push([c, vpos(t, &sc)]); + base.push([c, vpos(b, &sc)]); + } + if top.len() == 1 { + // A single point: give it some width. + if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { + top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; + base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; + } + } + let top = decimate(top, MAX_LINE_PTS * 2); + let base = decimate(base, MAX_LINE_PTS * 2); + let mut poly = top; + poly.extend(base.into_iter().rev()); + if poly.len() >= 3 { + ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); + } + } + 1 => { + let k = slot_of.get(i).copied().unwrap_or(0) as f32; + for (j, sp) in row.iter().enumerate() { + let Some((b, t)) = sp else { continue }; + let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); + let c = center(j); + let lo = pb.min(pt); + let len = (pb - pt).abs(); + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; + ctx.rect(lo, y, len, bw, s.color, None); + } else { + let x = c - nslots * bw / 2.0 + k * bw; + ctx.rect(x, lo, bw, len, s.color, None); + } + if labels_on && let Some(v) = s.values.get(j).copied().flatten() { + let t = fmt.format(v, None); + let stacked = fam != Fam::Col; + let mid = (pb + pt) / 2.0; + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; + if stacked { + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); + } else { + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } else { + let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; + if stacked { + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } else { + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); + } + } + } + } + } + _ => { + let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); + draw_runs(ctx, &pts, s.color, 2.25); + let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); + if k == ChartKind::LineMarkers && cw >= 3.0 { + draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); + } + if labels_on { + for (j, p) in pts.iter().enumerate() { + if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- scatter & bubble + +fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let pts_of = |s: &SeriesData| -> Vec> { + s.values + .iter() + .enumerate() + .map(|(j, v)| { + let x = match &s.x { + Some(xs) => xs.get(j).copied().flatten(), + None => Some((j + 1) as f64), + }; + match (x, v) { + (Some(x), Some(y)) => Some((x, *y)), + _ => None, + } + }) + .collect() + }; + let all: Vec>> = data.series.iter().map(pts_of).collect(); + let Some((xlo, xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; + let xs = nice_scale(xlo, xhi, false); + let ys = nice_scale(ylo, yhi, false); + let fx = Fmt::new("General"); + let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; + let bubble = chart.kind == ChartKind::Bubble; + let rmax = plot.w.min(plot.h) * 0.12; + for (s, pts) in data.series.iter().zip(&all) { + let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); + if bubble { + let sizes = s.sizes.as_deref().unwrap_or(&[]); + let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); + let mut bubbles: Vec<([f32; 2], f64)> = px + .iter() + .enumerate() + .filter_map(|(j, p)| { + let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; + match (p, sz) { + (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), + _ => None, + } + }) + .take(MAX_MARKERS) + .collect(); + bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); + let smax = if sizes.is_empty() { 1.0 } else { smax }; + let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; + for (p, sz) in bubbles { + let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); + } + } else { + let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; + if lines { + draw_runs(ctx, &px, s.color, 2.0); + } + if !lines || px.len() <= 500 { + draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); + } + } + if chart.data_labels && pts.len() <= MAX_LABELS { + for (p, raw) in px.iter().zip(pts) { + if let (Some(p), Some((_, y))) = (p, raw) { + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } +} + +// ---------------------------------------------------------------- histogram + +fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().flatten().copied().collect(); + let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; + let n = vals.len(); + let span = hi - lo; + let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); + if !(span.is_finite() && span > 0.0) { + bins = 1; + } + let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + let mut counts = vec![0usize; bins]; + for v in &vals { + let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 }; + let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 }; + if let Some(c) = counts.get_mut(b) { + *c += 1; + } + } + let f = Fmt::new(&s.number_format); + let labels: Vec = (0..bins) + .map(|b| { + let a = lo + b as f64 * width; + let e = if b + 1 == bins { hi.max(a) } else { a + width }; + let open = if b == 0 { '[' } else { '(' }; + format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) + }) + .collect(); + let maxc = counts.iter().copied().max().unwrap_or(0) as f64; + let ys = nice_scale(0.0, maxc, true); + let fc = Fmt::new("General"); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / bins as f32; + for (b, c) in counts.iter().enumerate() { + let top = plot.bottom() - ys.frac(*c as f64) * plot.h; + let x = plot.x + b as f32 * cw; + ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); + if chart.data_labels { + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- waterfall + +fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); + if ncat == 0 { + return; + } + let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); + let mut is_total = vec![false; ncat]; + let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); + if let Some(t) = is_total.last_mut() { + *t = last_total; + } + if !last_total { + labels.push("Total".into()); + vals.push(0.0); + is_total.push(true); + ncat += 1; + } + let mut bars = Vec::with_capacity(ncat); + let mut cum = 0.0f64; + for (j, v) in vals.iter().enumerate() { + if is_total.get(j).copied().unwrap_or(false) { + let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; + bars.push((0.0, total, 0u8)); + cum = total; + } else { + let top = clean(cum + v); + bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); + cum = top; + } + } + let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); + let ys = nice_scale(lo, hi, true); + let f = Fmt::new(&s.number_format); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / ncat as f32; + let bw = cw / 1.5; + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (j, (b, t, kind)) in bars.iter().enumerate() { + let color = match kind { + 1 => UP, + 2 => DOWN, + _ => s.color, + }; + let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; + let (yb, yt) = (vy(*b), vy(*t)); + ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); + if j + 1 < bars.len() && ncat <= 500 { + ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); + } + if chart.data_labels && ncat <= MAX_LABELS { + let v = if *kind == 0 { *t } else { t - b }; + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- funnel + +fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let n = data.categories.len().max(s.values.len()).min(MAX_CATS); + if n == 0 { + return; + } + let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); + let max = vals.iter().copied().fold(0.0f64, f64::max); + if max.is_nan() || max <= 0.0 { + return; + } + let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); + let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; + if !plot.ok() { + return; + } + let slot = plot.h / n as f32; + let bh = slot * 0.85; + let f = Fmt::new(&s.number_format); + let show = slot >= LABEL_SIZE * 0.9; + for (j, v) in vals.iter().enumerate() { + let bw = (*v / max) as f32 * plot.w; + let x = plot.x + (plot.w - bw) / 2.0; + let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; + if bw > 0.0 { + ctx.rect(x, y, bw, bh, s.color, None); + } + if show { + let yc = y + bh / 2.0; + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } +} + +// ---------------------------------------------------------------- pie, doughnut, sunburst + +fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let ri = r * inner; + let f = Fmt::new(&s.number_format); + let mut a = 0.0f32; + let mut labels = Vec::new(); + for (j, v) in vals.iter().enumerate() { + let sweep = (*v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + if (chart.data_labels || names) && vals.len() <= MAX_LABELS { + let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; + let mid = a + sweep / 2.0; + let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; + let room = sweep * rr; + if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + } +} + +// ---------------------------------------------------------------- treemap + +fn worst(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut items: Vec<(usize, f64)> = + s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); + items.sort_by(|a, b| b.1.total_cmp(&a.1)); + let f = Fmt::new(&s.number_format); + for (j, b) in squarify(&items, area) { + let fill = point_color(data, j); + ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); + if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { + let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + if chart.data_labels && b.h > LABEL_SIZE * 3.2 { + let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + } + } + } +} + +// ---------------------------------------------------------------- radar + +fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); + let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); + if r < 5.0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); + let sc = nice_scale(lo, hi, true); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let ang = |j: usize| j as f32 / ncat as f32 * TAU; + let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; + let ticks = sc.ticks(); + let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; + let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); + for (ti, t) in ticks.into_iter().enumerate() { + let rr = sc.frac(t) * r; + if rr > 0.5 && chart.gridlines { + let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); + if ncat < 3 { + ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); + } else if let Some(p) = ring.first().copied() { + ring.push(p); + } + ctx.line(ring, GRID, 0.75); + } + if ti % every == 0 { + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); + } + } + let step = ncat.div_ceil(72).max(1); + for j in (0..ncat).step_by(step) { + let a = ang(j); + ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); + let p = pt(a, r + 6.0); + let (sa, ca) = (a.sin(), a.cos()); + let align = if sa > 0.1 { + HAlign::Left + } else if sa < -0.1 { + HAlign::Right + } else { + HAlign::Center + }; + let valign = if ca > 0.1 { + VAlign::Bottom + } else if ca < -0.1 { + VAlign::Top + } else { + VAlign::Middle + }; + let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); + } + for s in &data.series { + let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); + if let Some(p) = pts.first().copied() { + pts.push(p); + } + ctx.line(pts, s.color, 2.0); + } +} + +// ---------------------------------------------------------------- box & whisker + +fn quantile(sorted: &[f64], q: f64) -> f64 { + let n = sorted.len(); + if n == 0 { + return 0.0; + } + let pos = q * (n - 1) as f64; + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = sorted.get(i).copied().unwrap_or(0.0); + let b = sorted.get(i + 1).copied().unwrap_or(a); + a + (b - a) * frac +} + +fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + if !plot.ok() { + return; + } + let n = data.series.len().max(1); + let cw = plot.w / n as f32; + let bw = (cw * 0.4).min(80.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (i, s) in data.series.iter().enumerate() { + let mut v: Vec = s.values.iter().flatten().copied().collect(); + if v.is_empty() { + continue; + } + v.sort_by(f64::total_cmp); + let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75)); + let iqr = q3 - q1; + let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); + let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); + let mean = v.iter().sum::() / v.len() as f64; + let c = plot.x + (i as f32 + 0.5) * cw; + ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); + ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); + let (yt, yb) = (vy(q3), vy(q1)); + ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); + ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); + let (mx, my, d) = (c, vy(mean), 3.0); + ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); + ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); + for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { + ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); + } + } +} + +// ---------------------------------------------------------------- stock + +fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true); + if !plot.ok() { + return; + } + let ser = |k: usize| data.series.get(k); + let (open, high, low, close) = match data.series.len() { + 0 => return, + 1 => (None, None, None, ser(0)), + 2 => (None, ser(0), ser(1), None), + 3 => (None, ser(0), ser(1), ser(2)), + _ => (ser(0), ser(1), ser(2), ser(3)), + }; + let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); + let cw = plot.w / ncat as f32; + let tick = (cw * 0.25).max(1.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for j in 0..ncat { + let c = plot.x + (j as f32 + 0.5) * cw; + if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { + ctx.vline(c, vy(h), vy(l), STOCK, 1.25); + } + match (get(open, j), get(close, j)) { + (Some(o), Some(cl)) => { + let (yo, yc) = (vy(o), vy(cl)); + let bw = (cw * 0.5).max(1.0); + let fill = if cl >= o { WHITE } else { STOCK }; + ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); + } + (None, Some(cl)) => { + if high.is_some() { + ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); + } else { + ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); + } + } + _ => {} + } + } +} + +// ---------------------------------------------------------------- final safety pass + +fn fin(v: f32) -> bool { + v.is_finite() +} + +fn clamp_stroke(s: Option<(Rgba, f32)>) -> Option<(Rgba, f32)> { + s.filter(|(_, w)| fin(*w) && *w > 0.0).map(|(c, w)| (c, w.min(50.0))) +} + +/// Drops non-finite primitives and clamps every coordinate into the `w`×`h` box. +pub(crate) fn finalize(prims: Vec, w: f32, h: f32) -> Vec { + let cx = |v: f32| v.clamp(0.0, w); + let cy = |v: f32| v.clamp(0.0, h); + let pts_ok = |pts: &[[f32; 2]]| pts.iter().all(|p| fin(p[0]) && fin(p[1])); + prims + .into_iter() + .filter_map(|p| match p { + Prim::Rect { x, y, w: rw, h: rh, fill, stroke, radius } => { + if !(fin(x) && fin(y) && fin(rw) && fin(rh)) { + return None; + } + let (x0, x1) = (cx(x.min(x + rw)), cx(x.max(x + rw))); + let (y0, y1) = (cy(y.min(y + rh)), cy(y.max(y + rh))); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + let radius = if fin(radius) { radius.clamp(0.0, (x1 - x0).min(y1 - y0) / 2.0) } else { 0.0 }; + Some(Prim::Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0, fill, stroke: clamp_stroke(stroke), radius }) + } + Prim::Line { pts, color, width, dash } => { + if !pts_ok(&pts) || pts.len() < 2 || !fin(width) { + return None; + } + let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect(); + Some(Prim::Line { pts, color, width: width.clamp(0.1, 50.0), dash }) + } + Prim::Polygon { pts, fill, stroke } => { + if !pts_ok(&pts) || pts.len() < 3 { + return None; + } + let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect(); + Some(Prim::Polygon { pts, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Wedge { cx: x, cy: y, r_outer, r_inner, a0, a1, fill, stroke } => { + if ![x, y, r_outer, r_inner, a0, a1].iter().all(|v| fin(*v)) { + return None; + } + let (x, y) = (cx(x), cy(y)); + let maxr = x.min(w - x).min(y).min(h - y); + let ro = r_outer.min(maxr); + if ro <= 0.0 { + return None; + } + Some(Prim::Wedge { cx: x, cy: y, r_outer: ro, r_inner: r_inner.clamp(0.0, ro), a0, a1, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Circle { cx: x, cy: y, r, fill, stroke } => { + if !(fin(x) && fin(y) && fin(r)) { + return None; + } + let (x, y) = (cx(x), cy(y)); + let r = r.min(x.min(w - x).min(y).min(h - y)); + if r <= 0.0 { + return None; + } + Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) }) + } + Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { + if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() { + return None; + } + Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation }) + } + }) + .collect() +} diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs new file mode 100644 index 000000000..5b4049758 --- /dev/null +++ b/crates/chart/src/resolve.rs @@ -0,0 +1,173 @@ +//! Pulls chart data (names, categories, values, formats, colours) out of a workbook. + +use sheetcraft_calc::evaluate; +use sheetcraft_core::{CellRef, RangeRef, Value, number_to_text}; +use sheetcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint}; +use sheetcraft_numfmt::{NumberFormat, format_value}; + +use crate::{ChartData, Rgba, SeriesData}; + +/// Cap on points per series (keeps whole-column references from exploding). +pub(crate) const MAX_POINTS: usize = 100_000; +/// Cap on series per chart. +pub(crate) const MAX_SERIES: usize = 255; + +/// Tints used for series beyond the six theme accents: lighter, darker, lighter still… +const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; + +/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them. +pub fn series_color(theme: &Theme, i: usize) -> Rgba { + let accent = i % 6; + let cycle = (i / 6) % CYCLE_TINTS.len(); + let base = theme.colors.get(4 + accent).copied().unwrap_or(0x156082); + let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8]; + let tint = CYCLE_TINTS.get(cycle).copied().unwrap_or(0.0); + let [r, g, b] = apply_tint(rgb, tint); + [r, g, b, 0xFF] +} + +/// The first `n` series colours for `theme`. +pub fn default_palette(theme: &Theme, n: usize) -> Vec { + (0..n.min(4096)).map(|i| series_color(theme, i)).collect() +} + +/// Splits `Sheet!A1:B2` / `'My Sheet'!$A$1` / `A1:B2` into a sheet index and range. +fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, RangeRef)> { + let f = formula.trim().trim_start_matches('=').trim(); + let (sh, addr) = match f.rfind('!') { + Some(i) => { + let name = f.get(..i)?.trim(); + let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string()); + (wb.sheet_index(&name)?, f.get(i + 1..)?) + } + None => (sheet, f), + }; + let r = RangeRef::parse(addr)?; + Some((sh, r)) +} + +/// Evaluates a series formula to a flat list of values (row-major), capped. +fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec { + let f = formula.trim(); + if f.is_empty() { + return vec![]; + } + match evaluate(wb, sheet, CellRef::default(), f) { + Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(), + v => vec![v], + } +} + +fn to_num(v: &Value) -> Option { + match v { + Value::Number(n) if n.is_finite() => Some(*n), + _ => None, + } +} + +fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec> { + eval_list(wb, sheet, formula).iter().map(to_num).collect() +} + +/// Number format code of the first cell of a reference ("General" when not a reference). +fn ref_format(wb: &Workbook, sheet: usize, formula: &str) -> String { + parse_ref(wb, sheet, formula) + .and_then(|(sh, r)| wb.sheet(sh).map(|s| wb.styles.get(s.style_id(r.start)).num_fmt.0.to_string())) + .unwrap_or_else(|| "General".into()) +} + +fn value_text(v: &Value) -> String { + match v { + Value::Empty => String::new(), + Value::Number(n) => number_to_text(*n), + Value::Text(t) => t.to_string(), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(), + Value::Error(e) => e.as_str().into(), + Value::Array(a) => a.get(0, 0).map(value_text).unwrap_or_default(), + } +} + +/// Category labels, formatted with each source cell's number format (dates, currency…). +fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { + let vals = eval_list(wb, sheet, formula); + let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula); + let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); + vals.iter() + .enumerate() + .map(|(i, v)| { + if let (Some((s, r)), Value::Number(_)) = (src, v) { + let w = r.width().max(1) as usize; + let at = r.start.offset((i / w) as i64, (i % w) as i64); + if let Some(c) = at { + let code = &wb.styles.get(s.style_id(c)).num_fmt.0; + if !code.eq_ignore_ascii_case("general") { + return format_value(v, &NumberFormat::parse(code), wb.date_system).text; + } + } + } + value_text(v) + }) + .collect() +} + +/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. +fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { + let t = name.trim(); + if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { + return q.replace("\"\"", "\""); + } + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') { + let vals = eval_list(wb, sheet, t); + let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); + return parts.join(" "); + } + t.to_string() +} + +fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { + match (chart_kind, series_kind) { + (ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k, + (ChartKind::Combo, _) => { + if i == 0 { + ChartKind::ColumnClustered + } else { + ChartKind::Line + } + } + (_, Some(k)) if k != ChartKind::Combo => k, + (k, _) => k, + } +} + +/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X +/// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the +/// default palette derived from the workbook theme accents). +pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { + let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble); + let mut series = Vec::new(); + let mut categories: Option> = None; + for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() { + let values = nums(wb, sheet, &s.values); + let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1)); + let color = + s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i)); + let kind = effective_kind(chart.kind, s.kind, i); + let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) { + (Some(f), true) => Some(nums(wb, sheet, f)), + _ => None, + }; + if categories.is_none() + && let Some(f) = &s.categories + { + let c = category_texts(wb, sheet, f); + if !c.is_empty() { + categories = Some(c); + } + } + let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f)); + series.push(SeriesData { name, values, x, sizes, color, kind, secondary: s.secondary, number_format: ref_format(wb, sheet, &s.values) }); + } + let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0); + let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect()); + ChartData { categories, series } +} diff --git a/crates/chart/src/sparkline.rs b/crates/chart/src/sparkline.rs new file mode 100644 index 000000000..5a151ada8 --- /dev/null +++ b/crates/chart/src/sparkline.rs @@ -0,0 +1,82 @@ +//! In-cell sparklines. + +use sheetcraft_model::SparklineKind; + +use crate::render::{DOWN, finalize}; +use crate::resolve::MAX_POINTS; +use crate::{Prim, Rgba}; + +/// Draws a sparkline into a `w`×`h` box at the origin. Gaps (`None`) break lines; column and +/// win/loss sparklines draw negatives in our "down" red. +pub fn render_sparkline(kind: SparklineKind, values: &[Option], color: Rgba, markers: bool, w: f32, h: f32) -> Vec { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e6 || h > 1e6 { + return vec![]; + } + let vals: Vec> = values.iter().take(MAX_POINTS).map(|v| v.filter(|x| x.is_finite()).map(|x| x.clamp(-1e300, 1e300))).collect(); + let n = vals.len(); + if n == 0 { + return vec![]; + } + let pad = (h * 0.1).clamp(0.5, 2.0); + let (x0, y0, iw, ih) = (pad, pad, (w - 2.0 * pad).max(0.5), (h - 2.0 * pad).max(0.5)); + let mut out = Vec::new(); + let (lo, hi) = vals.iter().flatten().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v))); + if !lo.is_finite() { + return out; + } + match kind { + SparklineKind::Line => { + let span = hi - lo; + let fy = |v: f64| { + let f = if span > 0.0 { ((v - lo) / span) as f32 } else { 0.5 }; + y0 + ih - f * ih + }; + let fx = |j: usize| if n > 1 { x0 + j as f32 / (n - 1) as f32 * iw } else { x0 + iw / 2.0 }; + let mut run: Vec<[f32; 2]> = Vec::new(); + let flush = |run: &mut Vec<[f32; 2]>, out: &mut Vec| { + if run.len() >= 2 { + let pts = if run.len() > 4000 { run.iter().step_by(run.len() / 2000 + 1).copied().collect() } else { std::mem::take(run) }; + out.push(Prim::Line { pts, color, width: 1.25, dash: false }); + } + run.clear(); + }; + for (j, v) in vals.iter().enumerate() { + match v { + Some(v) => run.push([fx(j), fy(*v)]), + None => flush(&mut run, &mut out), + } + } + flush(&mut run, &mut out); + if markers || n == 1 { + let r = (h * 0.08).clamp(1.0, 3.0); + let step = (n / 500).max(1); + for (j, v) in vals.iter().enumerate().step_by(step) { + if let Some(v) = v { + out.push(Prim::Circle { cx: fx(j), cy: fy(*v), r, fill: Some(color), stroke: None }); + } + } + } + } + SparklineKind::Column | SparklineKind::WinLoss => { + let slot = iw / n as f32; + let bw = (slot * 0.8).max(0.25); + let win = kind == SparklineKind::WinLoss; + let (lo0, hi0) = if win { (-1.0, 1.0) } else { (lo.min(0.0), hi.max(0.0)) }; + let span = hi0 - lo0; + let fy = |v: f64| if span > 0.0 { y0 + ih - ((v - lo0) / span) as f32 * ih } else { y0 + ih }; + let base = fy(0.0); + for (j, v) in vals.iter().enumerate() { + let Some(v) = v else { continue }; + let v = if win { v.signum() * (*v != 0.0) as i32 as f64 } else { *v }; + if v == 0.0 && win { + continue; + } + let top = fy(v); + let (y, hh) = (top.min(base), (top - base).abs().max(0.5)); + let fill = if v < 0.0 { DOWN } else { color }; + out.push(Prim::Rect { x: x0 + j as f32 * slot + (slot - bw) / 2.0, y, w: bw, h: hh, fill: Some(fill), stroke: None, radius: 0.0 }); + } + } + } + finalize(out, w, h) +} diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs new file mode 100644 index 000000000..1f05fbe7b --- /dev/null +++ b/crates/chart/src/tests.rs @@ -0,0 +1,569 @@ +use std::sync::Arc; + +use sheetcraft_core::{CellRef, Value}; +use sheetcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, SparklineKind, Style, Workbook}; + +use crate::*; + +const ALL_KINDS: [ChartKind; 25] = [ + ChartKind::ColumnClustered, + ChartKind::ColumnStacked, + ChartKind::ColumnStacked100, + ChartKind::BarClustered, + ChartKind::BarStacked, + ChartKind::BarStacked100, + ChartKind::Line, + ChartKind::LineMarkers, + ChartKind::LineStacked, + ChartKind::Pie, + ChartKind::Doughnut, + ChartKind::Area, + ChartKind::AreaStacked, + ChartKind::Scatter, + ChartKind::ScatterLines, + ChartKind::Bubble, + ChartKind::Radar, + ChartKind::Histogram, + ChartKind::Waterfall, + ChartKind::Funnel, + ChartKind::Treemap, + ChartKind::Sunburst, + ChartKind::BoxWhisker, + ChartKind::Stock, + ChartKind::Combo, +]; + +fn chart(kind: ChartKind) -> Chart { + Chart { + id: 1, + kind, + anchor: Anchor::default(), + title: Some("Quarterly Sales".into()), + series: vec![], + legend: LegendPos::Bottom, + data_labels: true, + gridlines: true, + style: 0, + x_title: Some("Quarter".into()), + y_title: Some("Revenue".into()), + source: None, + by_rows: false, + } +} + +fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesData { + SeriesData { + name: name.into(), + values: values.iter().map(|v| Some(*v)).collect(), + x: Some(values.iter().enumerate().map(|(i, _)| Some(i as f64 * 1.5 + 2.0)).collect()), + sizes: Some(values.iter().map(|v| Some(v.abs() + 1.0)).collect()), + color, + kind, + secondary: false, + number_format: "$#,##0".into(), + } +} + +fn sample(kind: ChartKind) -> ChartData { + let cats = ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6"]; + let k2 = if kind == ChartKind::Combo { ChartKind::Line } else { kind }; + let k1 = if kind == ChartKind::Combo { ChartKind::ColumnClustered } else { kind }; + let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2); + b.secondary = kind == ChartKind::Combo; + ChartData { + categories: cats.iter().map(|s| s.to_string()).collect(), + series: vec![ + series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), + b, + series("South", &[9000.0, 11000.0, 8000.0, 15000.0, 21000.0, 17000.0], [25, 107, 36, 255], k2), + ], + } +} + +fn coords(p: &Prim) -> Vec { + match p { + Prim::Rect { x, y, w, h, .. } => vec![*x, *y, x + w, y + h], + Prim::Line { pts, width, .. } => pts.iter().flat_map(|p| [p[0], p[1]]).chain([*width]).collect(), + Prim::Polygon { pts, .. } => pts.iter().flat_map(|p| [p[0], p[1]]).collect(), + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, .. } => vec![*cx, *cy, *r_outer, *r_inner, *a0, *a1], + Prim::Circle { cx, cy, r, .. } => vec![*cx, *cy, *r], + Prim::Text { x, y, size, rotation, .. } => vec![*x, *y, *size, *rotation], + } +} + +fn assert_sane(prims: &[Prim], w: f32, h: f32) { + let eps = 0.01; + for p in prims { + assert!(coords(p).iter().all(|v| v.is_finite()), "non-finite: {p:?}"); + let inside = |x: f32, y: f32| x >= -eps && x <= w + eps && y >= -eps && y <= h + eps; + match p { + Prim::Rect { x, y, w: rw, h: rh, .. } => assert!(inside(*x, *y) && inside(x + rw, y + rh), "{p:?}"), + Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => assert!(pts.iter().all(|q| inside(q[0], q[1])), "{p:?}"), + Prim::Wedge { cx, cy, r_outer, .. } => assert!(inside(cx - r_outer, cy - r_outer) && inside(cx + r_outer, cy + r_outer), "{p:?}"), + Prim::Circle { cx, cy, r, .. } => assert!(inside(cx - r, cy - r) && inside(cx + r, cy + r), "{p:?}"), + Prim::Text { x, y, .. } => assert!(inside(*x, *y), "{p:?}"), + } + } +} + +fn texts(prims: &[Prim]) -> Vec<&str> { + prims + .iter() + .filter_map(|p| match p { + Prim::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +#[test] +fn every_kind_renders_sanely() { + let m = ApproxMeasure; + for kind in ALL_KINDS { + for legend in [LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right] { + let mut c = chart(kind); + c.legend = legend; + let d = sample(kind); + let (w, h) = (640.0, 400.0); + let prims = render(&c, &d, w, h, &m); + assert!(prims.len() > 3, "{kind:?}: too few prims"); + assert_sane(&prims, w, h); + let t = texts(&prims); + assert!(t.contains(&"Quarterly Sales"), "{kind:?}: no title"); + let entries = legend_entries(&c, &d); + for (name, _, _) in &entries { + assert!(t.contains(&name.as_str()), "{kind:?} {legend:?}: legend entry {name} missing"); + } + let n_shapes = prims.iter().filter(|p| !matches!(p, Prim::Text { .. })).count(); + assert!(n_shapes > 2, "{kind:?}: nothing drawn"); + } + } +} + +#[test] +fn legend_counts() { + let d = sample(ChartKind::ColumnClustered); + assert_eq!(legend_entries(&chart(ChartKind::ColumnClustered), &d).len(), 3); + assert_eq!(legend_entries(&chart(ChartKind::Pie), &d).len(), 6); + assert_eq!(legend_entries(&chart(ChartKind::Waterfall), &d).len(), 3); + assert_eq!(legend_entries(&chart(ChartKind::Histogram), &d).len(), 0); + let mut c = chart(ChartKind::Line); + c.legend = LegendPos::None; + assert!(legend_entries(&c, &d).is_empty()); +} + +#[test] +fn nice_axis() { + let s = nice_scale(0.0, 61870.0, true); + assert_eq!((s.min, s.max, s.step), (0.0, 70000.0, 10000.0)); + let s = nice_scale(0.0, 100.0, true); + assert_eq!((s.min, s.max, s.step), (0.0, 120.0, 20.0)); + let s = nice_scale(-4000.0, 31000.0, true); + assert!(s.min < -4000.0 && s.min >= -10000.0 && s.max >= 31000.0); + // All zero. + let s = nice_scale(0.0, 0.0, true); + assert_eq!((s.min, s.max), (0.0, 1.0)); + // Far from zero: no forced zero. + let s = nice_scale(50.0, 55.0, false); + assert!(s.min >= 45.0 && s.max <= 60.0, "{s:?}"); + // Close to zero: zero included. + assert_eq!(nice_scale(10.0, 90.0, false).min, 0.0); + // Negative only. + let s = nice_scale(-80.0, -10.0, false); + assert_eq!(s.max, 0.0); + assert!(s.min <= -80.0); + for (lo, hi) in [(0.0, 1e-300), (-1e308, 1e308), (f64::NAN, 5.0), (0.001, 0.0013), (3.0, 3.0), (-7.0, -7.0), (1e15, 1e15 + 1.0)] { + let s = nice_scale(lo, hi, false); + assert!(s.min.is_finite() && s.max.is_finite() && s.step.is_finite() && s.max > s.min && s.step > 0.0, "{lo} {hi} {s:?}"); + let n = s.ticks().len(); + assert!((2..=51).contains(&n)); + } + // Ticks count in 4..=10 for typical data. + for hi in [1.0, 7.0, 13.0, 99.0, 250.0, 1234.0, 61870.0, 999999.0] { + let n = nice_scale(0.0, hi, true).ticks().len(); + assert!((4..=10).contains(&n), "{hi}: {n}"); + } +} + +#[test] +fn axis_labels_use_number_format() { + assert_eq!(format_number(40000.0, "$#,##0", Some(10000.0)), "$40,000"); + assert_eq!(format_number(0.30000000000000004, "General", Some(0.1)), "0.3"); + assert_eq!(format_number(1e-17, "General", Some(0.2)), "0"); + let c = chart(ChartKind::ColumnClustered); + let prims = render(&c, &sample(ChartKind::ColumnClustered), 640.0, 400.0, &ApproxMeasure); + let t = texts(&prims); + assert!(t.contains(&"$70,000"), "{t:?}"); + assert!(t.contains(&"$40,000")); +} + +#[test] +fn resolve_from_workbook() { + let mut wb = Workbook::new(); + let cur = wb.styles.intern(Style { num_fmt: sheetcraft_model::NumFmt(Arc::from("$#,##0")), ..Style::default() }); + { + let s = wb.sheet_mut(0).unwrap(); + s.name = "Sales".into(); + s.set_value(CellRef::new(0, 1), Value::from("Revenue")); + for (i, (q, v)) in [("Q1", 100.0), ("Q2", 250.0), ("Q3", 175.0)].iter().enumerate() { + let r = i as u32 + 1; + s.set_value(CellRef::new(r, 0), Value::from(*q)); + s.set_value(CellRef::new(r, 1), Value::Number(*v)); + s.set_style(CellRef::new(r, 1), cur); + } + s.set_value(CellRef::new(2, 2), Value::Number(5.0)); + } + let mut c = chart(ChartKind::ColumnClustered); + c.series = vec![ + Series { + name: Some("Sales!$B$1".into()), + categories: Some("Sales!$A$2:$A$4".into()), + values: "Sales!$B$2:$B$4".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }, + Series { + name: None, + categories: None, + values: "=Sales!$C$2:$C$4".into(), + bubble_sizes: None, + color: Some(Color::rgb(1, 2, 3)), + secondary: true, + kind: None, + }, + Series { + name: Some("Literal".into()), + categories: None, + values: "{1,2,3,4}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }, + ]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, vec!["Q1", "Q2", "Q3"]); + assert_eq!(d.series.len(), 3); + let s0 = &d.series[0]; + assert_eq!(s0.name, "Revenue"); + assert_eq!(s0.values, vec![Some(100.0), Some(250.0), Some(175.0)]); + assert_eq!(s0.number_format, "$#,##0"); + assert_eq!(s0.color, series_color(&wb.theme, 0)); + let t = wb.theme.colors[4]; + assert_eq!(s0.color, [(t >> 16) as u8, (t >> 8) as u8, t as u8, 255]); + let s1 = &d.series[1]; + assert_eq!(s1.name, "Series2"); + assert_eq!(s1.values, vec![None, Some(5.0), None]); + assert_eq!(s1.color, [1, 2, 3, 255]); + assert!(s1.secondary); + assert_eq!(d.series[2].name, "Literal"); + assert_eq!(d.series[2].values.len(), 4); + + // Default categories. + c.series.remove(0); + let d = resolve(&wb, 0, &c); + assert_eq!(d.categories, vec!["1", "2", "3", "4"]); + + // Combo kinds and scatter X values. + let mut c = chart(ChartKind::Combo); + c.series = vec![ + Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: false, kind: None }, + Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: true, kind: None }, + ]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series[0].kind, ChartKind::ColumnClustered); + assert_eq!(d.series[1].kind, ChartKind::Line); + let mut c = chart(ChartKind::Bubble); + c.series = vec![Series { + name: None, + categories: Some("Sales!B2:B4".into()), + values: "Sales!B2:B4".into(), + bubble_sizes: Some("{1,2,3}".into()), + color: None, + secondary: false, + kind: None, + }]; + let d = resolve(&wb, 0, &c); + assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3)); + assert_eq!(d.series[0].sizes, Some(vec![Some(1.0), Some(2.0), Some(3.0)])); + + // Bad formulas never panic. + let mut c = chart(ChartKind::Line); + c.series = vec![Series { + name: Some("=Nope!A1".into()), + categories: Some("((".into()), + values: "Missing!A1:A3".into(), + bubble_sizes: Some("".into()), + color: None, + secondary: false, + kind: None, + }]; + let d = resolve(&wb, 7, &c); + assert_eq!(d.series.len(), 1); + let _ = render(&c, &d, 300.0, 200.0, &ApproxMeasure); +} + +#[test] +fn palette_beyond_six_series() { + let theme = sheetcraft_model::Theme::default(); + let p = default_palette(&theme, 14); + assert_eq!(p.len(), 14); + assert_ne!(p[0], p[6]); + assert_ne!(p[6], p[12]); +} + +fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * w + x) * 4) as usize; + [img[i], img[i + 1], img[i + 2], img[i + 3]] +} + +#[test] +fn rasterize_bar_center_has_bar_color() { + let mut c = chart(ChartKind::ColumnClustered); + c.data_labels = false; + let d = sample(ChartKind::ColumnClustered); + let (w, h) = (640u32, 400u32); + let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure); + let img = rasterize(&prims, w, h, [255, 255, 255, 255]); + assert_eq!(img.len(), (w * h * 4) as usize); + // Find the bars of the East series (tallest) and check their centres. + let east = d.series[1].color; + let bars: Vec<_> = prims + .iter() + .filter_map(|p| match p { + Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == east && *w > 10.0 => Some((*x, *y, *w, *h)), + _ => None, + }) + .collect(); + assert_eq!(bars.len(), 6); + for (x, y, bw, bh) in bars { + let px = pixel(&img, w, (x + bw / 2.0) as u32, (y + bh / 2.0) as u32); + assert_eq!(px, east); + } + // Primitive-level checks. + let img = rasterize(&[Prim::Circle { cx: 10.0, cy: 10.0, r: 5.0, fill: Some([255, 0, 0, 255]), stroke: None }], 20, 20, [0, 0, 0, 0]); + assert_eq!(pixel(&img, 20, 10, 10), [255, 0, 0, 255]); + assert_eq!(pixel(&img, 20, 0, 0)[3], 0); + let wedge = Prim::Wedge { + cx: 50.0, + cy: 50.0, + r_outer: 40.0, + r_inner: 20.0, + a0: 0.0, + a1: std::f32::consts::FRAC_PI_2, + fill: [0, 0, 255, 255], + stroke: None, + }; + let img = rasterize(&[wedge], 100, 100, [255, 255, 255, 255]); + assert_eq!(pixel(&img, 100, 79, 30), [0, 0, 255, 255]); // upper-right quadrant ring + assert_eq!(pixel(&img, 100, 50, 50), [255, 255, 255, 255]); // hole + assert_eq!(pixel(&img, 100, 25, 70), [255, 255, 255, 255]); // other quadrant + assert!(rasterize(&[], 0, 10, [0; 4]).is_empty()); + assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty()); +} + +#[test] +fn pie_starts_at_twelve_clockwise() { + let c = chart(ChartKind::Pie); + let d = sample(ChartKind::Pie); + let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure); + let wedges: Vec<_> = prims + .iter() + .filter_map(|p| match p { + Prim::Wedge { a0, a1, r_inner, stroke, .. } => Some((*a0, *a1, *r_inner, *stroke)), + _ => None, + }) + .collect(); + assert_eq!(wedges.len(), 6); + assert_eq!(wedges[0].0, 0.0); + assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4)); + assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3); + assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4]))); + let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure); + let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3)); + assert!(ok); +} + +#[test] +fn negative_values_put_axis_below_zero() { + let c = chart(ChartKind::ColumnClustered); + let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] }; + let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); + let t = texts(&prims); + assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}"); + assert!(t.contains(&"1") && t.contains(&"3"), "default categories: {t:?}"); +} + +#[test] +fn edge_cases_never_panic() { + let m = ApproxMeasure; + let empty = ChartData::default(); + let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] }; + let weird = ChartData { + categories: vec![String::new(); 3], + series: vec![SeriesData { + name: "w".repeat(500), + values: vec![Some(f64::NAN), Some(f64::INFINITY), None, Some(-1e308), Some(1e308)], + x: Some(vec![Some(f64::NAN)]), + sizes: Some(vec![Some(-1.0), Some(0.0)]), + ..SeriesData::default() + }], + }; + for kind in ALL_KINDS { + let c = chart(kind); + for d in [&empty, &zeros, &weird] { + for (w, h) in [(0.0, 0.0), (-5.0, 100.0), (f32::NAN, 10.0), (1.0, 1.0), (20.0, 15.0), (640.0, 400.0), (5000.0, 30.0), (1e6, 1e6)] { + let p = render(&c, d, w, h, &m); + if !(w > 0.0 && h > 0.0) || w.is_nan() { + assert!(p.is_empty()); + } else { + assert_sane(&p, w, h); + } + } + } + } +} + +#[test] +fn thousands_of_points_are_decimated() { + let n = 50_000; + let vals: Vec = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect(); + let mut c = chart(ChartKind::Line); + c.data_labels = true; + let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] }; + let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure); + let max_line = prims + .iter() + .filter_map(|p| match p { + Prim::Line { pts, .. } => Some(pts.len()), + _ => None, + }) + .max() + .unwrap_or(0); + assert!(max_line > 10 && max_line <= 4 * 2000, "{max_line}"); + assert!(prims.len() < 10_000); + for kind in [ChartKind::Scatter, ChartKind::Histogram, ChartKind::AreaStacked, ChartKind::BoxWhisker] { + let p = render(&chart(kind), &d, 800.0, 400.0, &ApproxMeasure); + assert_sane(&p, 800.0, 400.0); + } +} + +/// Small deterministic PRNG (xorshift) for the fuzz test. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn f(&mut self) -> f64 { + (self.next() % 1_000_000) as f64 / 1_000_000.0 + } +} + +#[test] +fn fuzz_random_data_and_sizes() { + let mut r = Rng(0x9E37_79B9_7F4A_7C15); + let m = ApproxMeasure; + for iter in 0..400 { + let kind = ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize]; + let nser = (r.next() % 6) as usize; + let npts = (r.next() % 40) as usize; + let mag = 10f64.powi((r.next() % 30) as i32 - 10); + let val = |r: &mut Rng| -> Option { + match r.next() % 12 { + 0 => None, + 1 => Some(f64::NAN), + 2 => Some(0.0), + 3 => Some(-r.f() * mag), + _ => Some(r.f() * mag), + } + }; + let series: Vec = (0..nser) + .map(|i| SeriesData { + name: format!("S{i}"), + values: (0..npts).map(|_| val(&mut r)).collect(), + x: if r.next().is_multiple_of(2) { Some((0..npts).map(|_| val(&mut r)).collect()) } else { None }, + sizes: if r.next().is_multiple_of(2) { Some((0..npts).map(|_| val(&mut r)).collect()) } else { None }, + color: [(r.next() % 256) as u8, 100, 50, 255], + kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize], + secondary: r.next().is_multiple_of(3), + number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(), + }) + .collect(); + let ncat = (r.next() % 50) as usize; + let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series }; + let mut c = chart(kind); + c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize]; + c.data_labels = r.next().is_multiple_of(2); + c.gridlines = r.next().is_multiple_of(2); + if r.next().is_multiple_of(3) { + c.title = None; + } + let w = [0.0, -3.0, 1.0, 12.0, 80.0, 320.0, 1200.0][(r.next() % 7) as usize] as f32 + r.f() as f32 * 10.0; + let h = [0.0, 2.0, 30.0, 200.0, 900.0][(r.next() % 5) as usize] as f32 + r.f() as f32 * 10.0; + let p = render(&c, &d, w, h, &m); + if w >= 1.0 && h >= 1.0 { + assert_sane(&p, w, h); + } + if iter % 20 == 0 && w >= 1.0 && h >= 1.0 && w < 2000.0 && h < 2000.0 { + let img = rasterize(&p, w as u32, h as u32, [255; 4]); + assert_eq!(img.len(), (w as u32 * h as u32 * 4) as usize); + } + let vals: Vec> = (0..npts).map(|_| val(&mut r)).collect(); + for k in [SparklineKind::Line, SparklineKind::Column, SparklineKind::WinLoss] { + let sp = render_sparkline(k, &vals, [0, 0, 0, 255], r.next().is_multiple_of(2), w, h); + if w >= 1.0 && h >= 1.0 { + assert_sane(&sp, w.max(1.0), h.max(1.0)); + } + } + } +} + +#[test] +fn sparklines() { + let v = [Some(1.0), Some(-2.0), None, Some(4.0), Some(0.0), Some(3.0)]; + let line = render_sparkline(SparklineKind::Line, &v, [1, 2, 3, 255], true, 60.0, 16.0); + assert_eq!(line.iter().filter(|p| matches!(p, Prim::Line { .. })).count(), 2); + assert_eq!(line.iter().filter(|p| matches!(p, Prim::Circle { .. })).count(), 5); + assert_sane(&line, 60.0, 16.0); + let col = render_sparkline(SparklineKind::Column, &v, [1, 2, 3, 255], false, 60.0, 16.0); + assert_eq!(col.len(), 5); + assert_sane(&col, 60.0, 16.0); + let wl = render_sparkline(SparklineKind::WinLoss, &v, [1, 2, 3, 255], false, 60.0, 16.0); + assert_eq!(wl.len(), 4); + assert!(render_sparkline(SparklineKind::Line, &v, [0; 4], false, 0.0, 10.0).is_empty()); + assert!(render_sparkline(SparklineKind::Line, &[], [0; 4], false, 10.0, 10.0).is_empty()); +} + +#[test] +fn treemap_and_histogram_shapes() { + let d = ChartData { + categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], + series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)], + }; + let mut c = chart(ChartKind::Treemap); + c.title = None; + c.legend = LegendPos::None; + let prims = render(&c, &d, 600.0, 400.0, &ApproxMeasure); + let rects: Vec = prims + .iter() + .skip(1) + .filter_map(|p| match p { + Prim::Rect { w, h, .. } => Some(w * h), + _ => None, + }) + .collect(); + assert_eq!(rects.len(), 4); + // Areas proportional to values. + assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); + + let vals: Vec = (0..100).map(|i| i as f64).collect(); + let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] }; + let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure); + let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count(); + assert_eq!(bins, 10); +} diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 09540b620..7fba23fe5 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -144,7 +144,8 @@ pub fn time_fraction(h: f64, m: f64, s: f64) -> f64 { (h * 3600.0 + m * 60.0 + s) / 86400.0 } -pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +pub const MONTHS: [&str; 12] = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; #[cfg(test)] diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index babefb077..b08cf2aab 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -212,7 +212,9 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { let parts: Vec<&str> = t.split(seps).collect(); if parts.len() == 3 { let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim()); - if a.len() == 4 && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) { + if a.len() == 4 + && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) + { return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd")); } if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) { diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index b5bd430b4..bf8ae98b8 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -83,7 +83,11 @@ fn threshold(wb: &Workbook, sheet: usize, at: CellRef, st: &RuleStats, k: &CfVal fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { let t = t.clamp(0.0, 1.0); - [(a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as u8, (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as u8, (a[2] as f64 + (b[2] as f64 - a[2] as f64) * t).round() as u8] + [ + (a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as u8, + (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as u8, + (a[2] as f64 + (b[2] as f64 - a[2] as f64) * t).round() as u8, + ] } /// Number of icons in a set name like `3TrafficLights1`, `5Quarters`. @@ -104,7 +108,8 @@ impl CfCache { } let mut out = CfLook::default(); let mut any = false; - let mut rules: Vec<(usize, &CondFormat)> = sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect(); + let mut rules: Vec<(usize, &CondFormat)> = + sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect(); rules.sort_by_key(|(_, cf)| cf.priority); let v = sh.value(c); for (idx, cf) in rules { @@ -141,7 +146,9 @@ impl CfCache { }; (ok && !v.is_empty()).then_some(style.as_ref()) } - CfRule::Expression { formula, style } => sheetcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()), + CfRule::Expression { formula, style } => { + sheetcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) + } CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()), CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()), CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), @@ -150,13 +157,21 @@ impl CfCache { CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()), CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), - CfRule::Duplicate { style } => (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()), - CfRule::Unique { style } => (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()), + CfRule::Duplicate { style } => { + (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) + } + CfRule::Unique { style } => { + (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) + } CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() { Some(n) if !st.sorted.is_empty() => { let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize }; let k = k.clamp(1, st.sorted.len()); - let ok = if *bottom { n <= st.sorted.get(k - 1).copied().unwrap_or(n) } else { n >= st.sorted.get(st.sorted.len() - k).copied().unwrap_or(n) }; + let ok = if *bottom { + n <= st.sorted.get(k - 1).copied().unwrap_or(n) + } else { + n >= st.sorted.get(st.sorted.len() - k).copied().unwrap_or(n) + }; ok.then_some(style.as_ref()) } _ => None, @@ -175,7 +190,8 @@ impl CfCache { let d = n.floor(); let wd = sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); let week_start = today - wd; - let (ty, tm) = sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); + let (ty, tm) = + sheetcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); let ym = sheetcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month)); let month_off = |k: i32| { let m = tm as i32 + k; @@ -202,7 +218,10 @@ impl CfCache { }, CfRule::ColorScale { stops } => { if let Some(n) = v.as_f64() { - let pts: Vec<(f64, [u8; 3])> = stops.iter().map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))).collect(); + let pts: Vec<(f64, [u8; 3])> = stops + .iter() + .map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255]))) + .collect(); let rgb = match pts.as_slice() { [(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }), [(a, ca), (m, cm), (b, cb)] => { @@ -228,7 +247,11 @@ impl CfCache { let lo = if lo > 0.0 { 0.0 } else { lo }; let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 }; if out.bar.is_none() { - out.bar = Some(((frac as f32) * 0.9 + 0.1 * (frac > 0.0) as u8 as f32, color.resolve(&wb.theme).unwrap_or([0x63, 0x8E, 0xC6]), *gradient)); + out.bar = Some(( + (frac as f32) * 0.9 + 0.1 * (frac > 0.0) as u8 as f32, + color.resolve(&wb.theme).unwrap_or([0x63, 0x8E, 0xC6]), + *gradient, + )); out.hide_value |= !show_value; any = true; } @@ -238,7 +261,11 @@ impl CfCache { CfRule::IconSet { set, thresholds, reverse, show_value } => { if let Some(n) = v.as_f64() { let k = icon_count(set); - let cuts: Vec = if thresholds.len() + 1 == k { thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect() } else { (1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect() }; + let cuts: Vec = if thresholds.len() + 1 == k { + thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect() + } else { + (1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect() + }; let mut idx = cuts.iter().filter(|cut| n >= **cut).count(); if *reverse { idx = k - 1 - idx.min(k - 1); @@ -273,7 +300,11 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { return f.to_string(); } match sheetcraft_formula::parse(f.trim_start_matches('=')) { - Ok(e) => sheetcraft_formula::print(&sheetcraft_formula::adjust::shift_relative(e, c.row as i64 - first.row as i64, c.col as i64 - first.col as i64)), + Ok(e) => sheetcraft_formula::print(&sheetcraft_formula::adjust::shift_relative( + e, + c.row as i64 - first.row as i64, + c.col as i64 - first.col as i64, + )), Err(_) => f.to_string(), } } diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 5b602bd47..2182124b2 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -12,23 +12,81 @@ use crate::selection::current_region; pub fn specs() -> Vec { vec![ - cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort(s, p, true)), + cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort( + s, p, true + )), cmd!("data.sortDescending", "Sort Z to A", ["Data", "Sort & Filter"], None, "{range?, column?}", has_doc, |s, p| quick_sort(s, p, false)), - cmd!("data.sort", "Sort…", ["Data", "Sort & Filter"], None, "{range?, header?: bool, keys: [{column: \"B\", order: asc|desc, by?: values|cellColor|fontColor, customList?: [..]}], orientation?: rows|columns, matchCase?}", has_doc, sort), - cmd!("data.filter", "Filter", ["Data", "Sort & Filter"], Some("Cmd+Shift+F"), "{range?} toggles AutoFilter on the current region", has_doc, toggle_filter), - cmd!("data.filterBy", "Filter Column", [], None, "{column: \"B\" (or 0-based offset), values?: [..], blanks?: bool, custom?: {op: \">\", value: \"10\", op2?, value2?, and?}, top?: {count, bottom?, percent?}, aboveAverage?: bool, clear?: bool}", has_doc, filter_by), + cmd!( + "data.sort", + "Sort…", + ["Data", "Sort & Filter"], + None, + "{range?, header?: bool, keys: [{column: \"B\", order: asc|desc, by?: values|cellColor|fontColor, customList?: [..]}], orientation?: rows|columns, matchCase?}", + has_doc, + sort + ), + cmd!( + "data.filter", + "Filter", + ["Data", "Sort & Filter"], + Some("Cmd+Shift+F"), + "{range?} toggles AutoFilter on the current region", + has_doc, + toggle_filter + ), + cmd!( + "data.filterBy", + "Filter Column", + [], + None, + "{column: \"B\" (or 0-based offset), values?: [..], blanks?: bool, custom?: {op: \">\", value: \"10\", op2?, value2?, and?}, top?: {count, bottom?, percent?}, aboveAverage?: bool, clear?: bool}", + has_doc, + filter_by + ), cmd!("data.clearFilter", "Clear", ["Data", "Sort & Filter"], None, "{}", has_doc, clear_filter), cmd!("data.reapply", "Reapply", ["Data", "Sort & Filter"], None, "{}", has_doc, reapply), - cmd!("data.removeDuplicates", "Remove Duplicates", ["Data", "Data Tools"], None, "{range?, columns?: [\"A\",\"C\"], header?: bool}", has_doc, remove_duplicates), - cmd!("data.textToColumns", "Text to Columns", ["Data", "Data Tools"], None, "{range?, delimiters?: [\",\", \"\\t\", \" \", \";\"], other?: \"|\", fixedWidths?: [5, 10], treatConsecutive?: bool, textQualifier?: \"\\\"\", destination?: \"B1\"}", has_doc, text_to_columns), - cmd!("data.validation", "Data Validation…", ["Data", "Data Tools"], None, "{range?, type: any|whole|decimal|list|date|time|textLength|custom, operator?: between|notBetween|equal|notEqual|greater|less|greaterOrEqual|lessOrEqual, formula1, formula2?, ignoreBlank?, dropdown?, inputTitle?, inputMessage?, errorStyle?: stop|warning|information, errorTitle?, errorMessage?, clear?: bool}", has_doc, validation), + cmd!( + "data.removeDuplicates", + "Remove Duplicates", + ["Data", "Data Tools"], + None, + "{range?, columns?: [\"A\",\"C\"], header?: bool}", + has_doc, + remove_duplicates + ), + cmd!( + "data.textToColumns", + "Text to Columns", + ["Data", "Data Tools"], + None, + "{range?, delimiters?: [\",\", \"\\t\", \" \", \";\"], other?: \"|\", fixedWidths?: [5, 10], treatConsecutive?: bool, textQualifier?: \"\\\"\", destination?: \"B1\"}", + has_doc, + text_to_columns + ), + cmd!( + "data.validation", + "Data Validation…", + ["Data", "Data Tools"], + None, + "{range?, type: any|whole|decimal|list|date|time|textLength|custom, operator?: between|notBetween|equal|notEqual|greater|less|greaterOrEqual|lessOrEqual, formula1, formula2?, ignoreBlank?, dropdown?, inputTitle?, inputMessage?, errorStyle?: stop|warning|information, errorTitle?, errorMessage?, clear?: bool}", + has_doc, + validation + ), cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd), cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid), cmd!("data.group", "Group", ["Data", "Outline"], Some("Cmd+Shift+K"), "{rows?: \"2:5\" | cols?: \"B:C\"}", has_doc, |s, p| group(s, p, true)), cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Cmd+Shift+J"), "{rows? | cols?}", has_doc, |s, p| group(s, p, false)), cmd!("data.hideDetail", "Hide Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, true)), cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols?}", has_doc, |s, p| detail(s, p, false)), - cmd!("data.subtotal", "Subtotal", ["Data", "Outline"], None, "{range?, groupBy: \"A\", function?: sum|count|average|max|min|product, columns: [\"C\"], header?: true}", has_doc, subtotal), + cmd!( + "data.subtotal", + "Subtotal", + ["Data", "Outline"], + None, + "{range?, groupBy: \"A\", function?: sum|count|average|max|min|product, columns: [\"C\"], header?: true}", + has_doc, + subtotal + ), cmd!("data.flashFill", "Flash Fill", ["Data", "Data Tools"], None, "{range?}", has_doc, |s, p| s.execute("edit.flashFill", p.clone())), ] } @@ -75,7 +133,9 @@ fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { if a.is_text() && !b.is_text() && !b.is_empty() { votes += 1; } - if wb.styles.get(sh.style_id(CellRef::new(r.start.row, c))).font.bold != wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).font.bold { + if wb.styles.get(sh.style_id(CellRef::new(r.start.row, c))).font.bold + != wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).font.bold + { votes += 1; } if a.is_text() && b.is_text() { @@ -165,8 +225,12 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co let c = line(i, k.col); let v = sh.value(c); let color = match k.by.as_str() { - "cellColor" => styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX), - "fontColor" => styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX), + "cellColor" => { + styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + "fontColor" => { + styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } _ => 0, }; (v, color) @@ -217,7 +281,8 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co } } // Row heights travel with rows. - let heights: Vec> = if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + let heights: Vec> = + if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; let shm = cx.sheet_mut(sheet)?; for (c, cell) in &moved { match cell { @@ -254,7 +319,8 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec, by_co fn toggle_filter(s: &mut Session, p: &Json) -> Result { let sheet = s.doc()?.wb.active_sheet; let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone())); + let in_table = + s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone())); if let Some(name) = in_table { return edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; @@ -300,7 +366,10 @@ fn filter_by(s: &mut Session, p: &Json) -> Result { let crit = if bool_param(p, "clear").unwrap_or(false) { None } else if let Some(vals) = p.get("values").and_then(Json::as_array) { - Some(FilterCriterion::Values { values: vals.iter().map(super::edit::json_to_input).collect(), blanks: bool_param(p, "blanks").unwrap_or(false) }) + Some(FilterCriterion::Values { + values: vals.iter().map(super::edit::json_to_input).collect(), + blanks: bool_param(p, "blanks").unwrap_or(false), + }) } else if let Some(c) = p.get("custom") { let a = (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(super::edit::json_to_input).unwrap_or_default()); let b = c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(super::edit::json_to_input).unwrap_or_default())); @@ -339,8 +408,20 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { }; let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); match op { - "=" => if crit.contains(['*', '?']) { wild() } else { cmp == Some(Ordering::Equal) }, - "<>" => if crit.contains(['*', '?']) { !wild() } else { cmp != Some(Ordering::Equal) }, + "=" => { + if crit.contains(['*', '?']) { + wild() + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if crit.contains(['*', '?']) { + !wild() + } else { + cmp != Some(Ordering::Equal) + } + } ">" => cmp == Some(Ordering::Greater), "<" => cmp == Some(Ordering::Less), ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), @@ -405,7 +486,13 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { _ => false, }, FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) { - (Some(n), Some((_, avg))) => if *above { n > *avg } else { n < *avg }, + (Some(n), Some((_, avg))) => { + if *above { + n > *avg + } else { + n < *avg + } + } _ => false, }, FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, @@ -493,7 +580,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let cols: Vec = p.get("columns").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()).unwrap_or_else(|| (r.start.col..=r.end.col).collect()); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); let sheet = d.wb.active_sheet; let start = r.start.row + header as u32; let mut removed = 0; @@ -509,7 +600,8 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { removed += 1; } } - let rows: Vec>> = keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); + let rows: Vec>> = + keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); let shm = cx.sheet_mut(sheet)?; for row in start..=r.end.row { for c in r.start.col..=r.end.col { @@ -535,11 +627,16 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result { fn text_to_columns(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let sheet = target_sheet(s, p)?; - let mut delims: Vec = p.get("delimiters").and_then(Json::as_array).map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()).unwrap_or_else(|| vec!['\t']); + let mut delims: Vec = p + .get("delimiters") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) + .unwrap_or_else(|| vec!['\t']); if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { delims.push(o); } - let fixed: Option> = p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); + let fixed: Option> = + p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); let dest = cell_param(p, "destination").unwrap_or(r.start); @@ -554,7 +651,9 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { let mut cuts = vec![0]; cuts.extend(w.iter().copied().filter(|x| *x < chars.len())); cuts.push(chars.len()); - cuts.windows(2).map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()).collect() + cuts.windows(2) + .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) + .collect() } None => split_delimited(&text, &delims, consecutive, quote), }; @@ -680,7 +779,12 @@ fn validation(s: &mut Session, p: &Json) -> Result { /// List entries of a list validation (literal or from a range). pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { let f = dv.f1.trim(); - if f.starts_with('"') || (!f.contains('!') && sheetcraft_formula::parse(f).map(|e| !matches!(e, sheetcraft_formula::Expr::Ref(_) | sheetcraft_formula::Expr::Name(_))).unwrap_or(true)) { + if f.starts_with('"') + || (!f.contains('!') + && sheetcraft_formula::parse(f) + .map(|e| !matches!(e, sheetcraft_formula::Expr::Ref(_) | sheetcraft_formula::Expr::Name(_))) + .unwrap_or(true)) + { return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); @@ -724,7 +828,11 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - if ok { return None; } - let msg = if dv.error_message.is_empty() { "This value doesn't match the data validation restrictions defined for this cell.".to_string() } else { dv.error_message.clone() }; + let msg = if dv.error_message.is_empty() { + "This value doesn't match the data validation restrictions defined for this cell.".to_string() + } else { + dv.error_message.clone() + }; Some((dv.clone(), msg)) } @@ -782,11 +890,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { return Ok((false, r.start.col, r.end.col)); } let r = s.doc()?.selection.current(); - if r.is_full_cols() && !r.is_full_rows() { - Ok((false, r.start.col, r.end.col)) - } else { - Ok((true, r.start.row, r.end.row)) - } + if r.is_full_cols() && !r.is_full_rows() { Ok((false, r.start.col, r.end.col)) } else { Ok((true, r.start.row, r.end.row)) } } fn group(s: &mut Session, p: &Json, on: bool) -> Result { @@ -847,7 +951,11 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { "product" => 6, _ => 9, }; - let cols: Vec = p.get("columns").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()).unwrap_or_else(|| vec![r.end.col]); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| vec![r.end.col]); let label = match func { "count" => "Count", "average" => "Average", diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 72f1ec22d..6208a542b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,8 +11,24 @@ use crate::{Clipboard, Session}; pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), - cmd!("range.setValues", "Set Values", [], None, "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", has_doc, range_set_values), - cmd!("range.fill", "Fill Range With Input", [], Some("Ctrl+Enter"), "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", has_doc, range_fill), + cmd!( + "range.setValues", + "Set Values", + [], + None, + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + has_doc, + range_set_values + ), + cmd!( + "range.fill", + "Fill Range With Input", + [], + Some("Ctrl+Enter"), + "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", + has_doc, + range_fill + ), cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set), cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move), cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next), @@ -26,24 +42,76 @@ pub fn specs() -> Vec { cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy), cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), - cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}", has_doc, paste), - cmd!("edit.pasteSpecial", "Paste Special…", ["Home", "Clipboard"], Some("Ctrl+Cmd+V"), "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", has_clipboard, paste_special), + cmd!( + "edit.paste", + "Paste", + ["Home", "Clipboard"], + Some("Cmd+V"), + "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}", + has_doc, + paste + ), + cmd!( + "edit.pasteSpecial", + "Paste Special…", + ["Home", "Clipboard"], + Some("Ctrl+Cmd+V"), + "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", + has_clipboard, + paste_special + ), cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard), cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")), cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")), - cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear(s, p, "contents")), - cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "comments")), + cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear( + s, p, "contents" + )), + cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear( + s, p, "comments" + )), cmd!("edit.clearHyperlinks", "Clear Hyperlinks", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "hyperlinks")), cmd!("edit.fillDown", "Fill Down", ["Home", "Editing", "Fill"], Some("Cmd+D"), "{range?}", has_doc, |s, p| fill_dir(s, p, 1, 0)), cmd!("edit.fillRight", "Fill Right", ["Home", "Editing", "Fill"], Some("Cmd+R"), "{range?}", has_doc, |s, p| fill_dir(s, p, 0, 1)), cmd!("edit.fillUp", "Fill Up", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, -1, 0)), cmd!("edit.fillLeft", "Fill Left", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, 0, -1)), - cmd!("edit.autoFill", "AutoFill", [], None, "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag", has_doc, auto_fill), - cmd!("edit.fillSeries", "Series…", ["Home", "Editing", "Fill"], None, "{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}", has_doc, fill_series), + cmd!( + "edit.autoFill", + "AutoFill", + [], + None, + "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag", + has_doc, + auto_fill + ), + cmd!( + "edit.fillSeries", + "Series…", + ["Home", "Editing", "Fill"], + None, + "{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}", + has_doc, + fill_series + ), cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill), cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find), - cmd!("edit.replace", "Replace…", ["Home", "Editing", "Find & Select"], Some("Ctrl+H"), "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}", has_doc, replace), - cmd!("edit.formatPainter", "Format Painter", ["Home", "Clipboard"], None, "{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them", has_doc, format_painter), + cmd!( + "edit.replace", + "Replace…", + ["Home", "Editing", "Find & Select"], + Some("Ctrl+H"), + "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}", + has_doc, + replace + ), + cmd!( + "edit.formatPainter", + "Format Painter", + ["Home", "Clipboard"], + None, + "{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them", + has_doc, + format_painter + ), cmd!(noundo "edit.beginEdit", "Edit Cell", [], Some("F2"), "{}", has_doc, begin_edit), ] } @@ -62,7 +130,11 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut sheetcraft if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } - let is_formula = input.starts_with('=') || (input.len() > 1 && (input.starts_with('+') || input.starts_with('-')) && sheetcraft_core::parse::parse_number_text(input).is_none() && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + let is_formula = input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && sheetcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); if is_formula { let body = input.strip_prefix('=').unwrap_or(input); // Excel closes missing parentheses for you. @@ -81,7 +153,12 @@ pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut sheetcraft // Formulas whose result is a date/time get a format from functions like TODAY(). if wb.styles.get(style).num_fmt.as_str() == "General" { let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); - let auto = if f.starts_with("TODAY(") || f.starts_with("DATE(") || f.starts_with("EDATE(") || f.starts_with("EOMONTH(") || f.starts_with("WORKDAY(") { + let auto = if f.starts_with("TODAY(") + || f.starts_with("DATE(") + || f.starts_with("EDATE(") + || f.starts_with("EOMONTH(") + || f.starts_with("WORKDAY(") + { Some("m/d/yyyy") } else if f.starts_with("NOW(") { Some("m/d/yyyy h:mm") @@ -117,9 +194,11 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { Some(c) => c, None => s.doc()?.selection.active, }; - let input = str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); + let input = + str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); let array = bool_param(p, "array").unwrap_or(false); - let protected = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); + let protected = + s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); if protected { return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); } @@ -156,7 +235,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { pub(crate) fn json_to_input(v: &Json) -> String { match v { Json::Null => String::new(), - Json::Bool(b) => if *b { "TRUE".into() } else { "FALSE".into() }, + Json::Bool(b) => { + if *b { + "TRUE".into() + } else { + "FALSE".into() + } + } Json::Number(n) => n.to_string(), Json::String(s) => s.clone(), other => other.to_string(), @@ -182,7 +267,11 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { let input = json_to_input(v); let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { - Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), + Json::Number(n) => Some(Cell { + value: Value::number(n.as_f64().unwrap_or(0.0)), + formula: None, + style: old.as_ref().map(|c| c.style).unwrap_or_default(), + }), Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, }; @@ -309,9 +398,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { t = n; guard += 1; } - if !extend - && let Some(m) = sheet.merge_at(from) - { + if !extend && let Some(m) = sheet.merge_at(from) { // Leaving a merged cell moves from its edge. if dr > 0 { t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col); @@ -674,7 +761,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let src = clip.range; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. - let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() && cell_param(p, "at").is_none() && !sel.is_full_cols() && !sel.is_full_rows() && sel.count() <= 4_000_000; + let tile = sel.height() % h == 0 + && sel.width() % w == 0 + && !sel.is_single() + && cell_param(p, "at").is_none() + && !sel.is_full_cols() + && !sel.is_full_rows() + && sel.count() <= 4_000_000; let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; let same_doc = s.doc()?.uid == clip.doc_uid; let cut_move = clip.cut && same_doc && what == What::All; @@ -706,7 +799,11 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let sc = CellRef::new(src.start.row + r, src.start.col + c); let (dr, dc) = if transpose { (c, r) } else { (r, c) }; let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; + let src_cell = if cut_move { + moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) + } else { + src_sheet.cell(sc).cloned() + }; let src_val = src_sheet.value(sc); if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { continue; @@ -719,11 +816,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if cut_move { return Some(std::sync::Arc::new(Formula::from_expr(e))); } - let moved = sheetcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + let moved = + sheetcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); Some(std::sync::Arc::new(Formula::from_expr(moved))) }; if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { String::new() } else { format!("{}!", sheetcraft_formula::quote_sheet(&src_sheet_name)) }; + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", sheetcraft_formula::quote_sheet(&src_sheet_name)) + }; new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); new.value = Value::Empty; } else { @@ -760,7 +862,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { What::Comments | What::Validation | What::ColumnWidths => {} } // Operations combine numbers with what is already there. - if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() { + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + { let a = old.value.clone(); let b = new.value.clone(); if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) @@ -887,7 +992,8 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }, None => continue, }; - let cells: Vec<(CellRef, Cell)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = + cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default(); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { @@ -991,7 +1097,9 @@ fn flash_fill(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; if n == 0 { - return Err(EngineError::Other("We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into())); + return Err(EngineError::Other( + "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), + )); } Ok(json!({"filled": n})) } @@ -1080,7 +1188,8 @@ fn find(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); } if all { - let list: Vec = hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); + let list: Vec = + hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); return Ok(json!({"count": list.len(), "results": list})); } // Next hit after the active cell. @@ -1109,7 +1218,8 @@ fn replace(s: &mut Session, p: &Json) -> Result { let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] }; let mut n = 0; for si in sheets { - let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, String)> = + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); for (c, text) in cells { if !all && c != active { continue; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c3dbeec9c..37a58dd63 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -50,12 +50,16 @@ fn open(s: &mut Session, p: &Json) -> Result { let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); // Replace an untouched blank Book1 like Excel does. - if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) { + if s.documents().len() == 1 + && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) + { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); let d = s.doc()?; - Ok(json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings})) + Ok( + json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), + ) } fn save(s: &mut Session, p: &Json) -> Result { @@ -122,7 +126,14 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result { fn properties(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { let pr = &mut cx.wb.props; - for (k, slot) in [("title", &mut pr.title), ("subject", &mut pr.subject), ("author", &mut pr.author), ("company", &mut pr.company), ("keywords", &mut pr.keywords), ("description", &mut pr.description)] { + for (k, slot) in [ + ("title", &mut pr.title), + ("subject", &mut pr.subject), + ("author", &mut pr.author), + ("company", &mut pr.company), + ("keywords", &mut pr.keywords), + ("description", &mut pr.description), + ] { if let Some(v) = str_param(p, k) { *slot = v.to_string(); } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c6cee6593..b9a6d58df 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -9,21 +9,83 @@ use super::*; pub fn specs() -> Vec { vec![ // Font - cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle(s, p, |st| st.font.bold, |st, v| st.font.bold = v)), - cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.italic, |st, v| st.font.italic = v)), - cmd!("home.underline", "Underline", ["Home", "Font"], Some("Cmd+U"), "{range?, on?, style?: single|double|singleAccounting|doubleAccounting}", has_doc, underline), - cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline(s, &with(p, "style", json!("double")))), - cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.strike, |st, v| st.font.strike = v)), - cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.vert == VertAlign::Superscript, |st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline })), - cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.font.vert == VertAlign::Subscript, |st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline })), + cmd!("home.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{range?, on?: bool}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.bold, + |st, v| st.font.bold = v + )), + cmd!("home.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.italic, + |st, v| st.font.italic = v + )), + cmd!( + "home.underline", + "Underline", + ["Home", "Font"], + Some("Cmd+U"), + "{range?, on?, style?: single|double|singleAccounting|doubleAccounting}", + has_doc, + underline + ), + cmd!("home.doubleUnderline", "Double Underline", ["Home", "Font"], None, "{range?}", has_doc, |s, p| underline( + s, + &with(p, "style", json!("double")) + )), + cmd!("home.strikethrough", "Strikethrough", ["Home", "Font"], Some("Cmd+Shift+X"), "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.strike, + |st, v| st.font.strike = v + )), + cmd!("home.superscript", "Superscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.vert == VertAlign::Superscript, + |st, v| st.font.vert = if v { VertAlign::Superscript } else { VertAlign::Baseline } + )), + cmd!("home.subscript", "Subscript", ["Home", "Font"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.font.vert == VertAlign::Subscript, + |st, v| st.font.vert = if v { VertAlign::Subscript } else { VertAlign::Baseline } + )), cmd!("home.fontName", "Font", ["Home", "Font"], None, "{range?, name: \"Calibri\"}", has_doc, font_name), cmd!("home.fontSize", "Font Size", ["Home", "Font"], None, "{range?, size: 11}", has_doc, font_size), cmd!("home.increaseFontSize", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+>"), "{range?}", has_doc, |s, p| step_font(s, p, true)), - cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font(s, p, false)), - cmd!("home.fontColor", "Font Color", ["Home", "Font"], None, "{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}", has_doc, font_color), + cmd!("home.decreaseFontSize", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+<"), "{range?}", has_doc, |s, p| step_font( + s, p, false + )), + cmd!( + "home.fontColor", + "Font Color", + ["Home", "Font"], + None, + "{range?, color: \"#C00000\" | \"auto\" | {theme: 4, tint: -0.25}}", + has_doc, + font_color + ), cmd!("home.fillColor", "Fill Color", ["Home", "Font"], None, "{range?, color: \"#FFFF00\" | \"none\" | {theme, tint}}", has_doc, fill_color), - cmd!("home.borders", "Borders", ["Home", "Font"], None, "{range?, preset: bottom|top|left|right|none|all|outside|thickOutside|thickBottom|doubleBottom|topBottom|topThickBottom|topDoubleBottom|insideHorizontal|insideVertical|inside|diagonalDown|diagonalUp, style?: thin|medium|thick|dashed|dotted|double|hair…, color?}", has_doc, borders), - cmd!("home.formatCells", "Format Cells…", ["Home", "Cells", "Format"], Some("Cmd+1"), "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", has_doc, format_cells), + cmd!( + "home.borders", + "Borders", + ["Home", "Font"], + None, + "{range?, preset: bottom|top|left|right|none|all|outside|thickOutside|thickBottom|doubleBottom|topBottom|topThickBottom|topDoubleBottom|insideHorizontal|insideVertical|inside|diagonalDown|diagonalUp, style?: thin|medium|thick|dashed|dotted|double|hair…, color?}", + has_doc, + borders + ), + cmd!( + "home.formatCells", + "Format Cells…", + ["Home", "Cells", "Format"], + Some("Cmd+1"), + "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", + has_doc, + format_cells + ), // Alignment cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)), cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)), @@ -32,35 +94,108 @@ pub fn specs() -> Vec { cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)), cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)), cmd!("home.alignBottom", "Bottom Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Bottom)), - cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.align.wrap, |st, v| st.align.wrap = v)), - cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.align.shrink, |st, v| st.align.shrink = v)), + cmd!("home.wrapText", "Wrap Text", ["Home", "Alignment"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.align.wrap, + |st, v| st.align.wrap = v + )), + cmd!("home.shrinkToFit", "Shrink to Fit", [], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.align.shrink, + |st, v| st.align.shrink = v + )), cmd!("home.increaseIndent", "Increase Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Tab"), "{range?}", has_doc, |s, p| indent(s, p, 1)), - cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent(s, p, -1)), - cmd!("home.orientation", "Orientation", ["Home", "Alignment"], None, "{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}", has_doc, orientation), + cmd!("home.decreaseIndent", "Decrease Indent", ["Home", "Alignment"], Some("Ctrl+Alt+Shift+Tab"), "{range?}", has_doc, |s, p| indent( + s, p, -1 + )), + cmd!( + "home.orientation", + "Orientation", + ["Home", "Alignment"], + None, + "{range?, angle: -90..90 | \"vertical\" | \"counterclockwise\" | \"clockwise\" | \"up\" | \"down\" | 0}", + has_doc, + orientation + ), cmd!("home.mergeCenter", "Merge & Center", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "center")), cmd!("home.mergeAcross", "Merge Across", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "across")), cmd!("home.mergeCells", "Merge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "cells")), cmd!("home.unmergeCells", "Unmerge Cells", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| merge(s, p, "unmerge")), // Number - cmd!("home.numberFormat", "Number Format", ["Home", "Number"], None, "{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}", has_doc, number_format), - cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(s, p, ACCOUNTING)), + cmd!( + "home.numberFormat", + "Number Format", + ["Home", "Number"], + None, + "{range?, format: \"General\"|\"Number\"|\"Currency\"|\"Accounting\"|\"Short Date\"|\"Long Date\"|\"Time\"|\"Percentage\"|\"Fraction\"|\"Scientific\"|\"Text\" | code: \"0.00\"}", + has_doc, + number_format + ), + cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt( + s, p, ACCOUNTING + )), cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")), cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)), cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)), cmd!("home.decreaseDecimal", "Decrease Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, -1)), // Styles - cmd!("home.cellStyle", "Cell Styles", ["Home", "Styles"], None, "{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}", has_doc, cell_style), + cmd!( + "home.cellStyle", + "Cell Styles", + ["Home", "Styles"], + None, + "{range?, name: \"Good\"|\"Bad\"|\"Neutral\"|\"Heading 1\"|\"Title\"|\"Total\"|\"Accent1\"…|\"Normal\"}", + has_doc, + cell_style + ), // Cells › Format - cmd!("home.rowHeight", "Row Height…", ["Home", "Cells", "Format"], None, "{rows?: \"2:5\", height: 20 (points on screen)}", has_doc, row_height), - cmd!("home.columnWidth", "Column Width…", ["Home", "Cells", "Format"], None, "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", has_doc, col_width), + cmd!( + "home.rowHeight", + "Row Height…", + ["Home", "Cells", "Format"], + None, + "{rows?: \"2:5\", height: 20 (points on screen)}", + has_doc, + row_height + ), + cmd!( + "home.columnWidth", + "Column Width…", + ["Home", "Cells", "Format"], + None, + "{cols?: \"B:D\", width: 64 (points) | chars: 8.43}", + has_doc, + col_width + ), cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows), cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols), cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width), - cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(s, p, true, true)), - cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(s, p, false, true)), - cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(s, p, true, false)), - cmd!("home.unhideColumns", "Unhide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+0"), "{cols?}", has_doc, |s, p| hide(s, p, false, false)), - cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle(s, p, |st| st.protection.locked, |st, v| st.protection.locked = v)), + cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide( + s, p, true, true + )), + cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide( + s, p, false, true + )), + cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide( + s, p, true, false + )), + cmd!( + "home.unhideColumns", + "Unhide Columns", + ["Home", "Cells", "Format", "Hide & Unhide"], + Some("Cmd+Shift+0"), + "{cols?}", + has_doc, + |s, p| hide(s, p, false, false) + ), + cmd!("home.lockCell", "Lock Cell", ["Home", "Cells", "Format"], None, "{range?, on?}", has_doc, |s, p| toggle( + s, + p, + |st| st.protection.locked, + |st, v| st.protection.locked = v + )), ] } @@ -100,7 +235,8 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> map.entry(line).or_default().style = Some(new); } // Existing cells in those lines. - let cells: Vec<(CellRef, StyleId)> = cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default(); + let cells: Vec<(CellRef, StyleId)> = + cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default(); for (c, st) in cells { let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f)); cx.sheet_mut(sheet)?.set_style(c, new); @@ -110,6 +246,9 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> if r.count() > 4_000_000 { return Err(bad("format", "selection too large to format cell by cell")); } + if r.height() <= 100_000 { + cx.fit_rows.extend((r.start.row..=r.end.row).map(|row| (sheet, row))); + } for c in r.iter() { let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default(); let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f)); @@ -171,14 +310,17 @@ const SIZES: [f32; 16] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24 fn step_font(s: &mut Session, p: &Json, up: bool) -> Result { let cur = active_style(s)?.font.size; - let next = if up { SIZES.iter().copied().find(|x| *x > cur).unwrap_or(409.0) } else { SIZES.iter().rev().copied().find(|x| *x < cur).unwrap_or(1.0) }; + let next = + if up { SIZES.iter().copied().find(|x| *x > cur).unwrap_or(409.0) } else { SIZES.iter().rev().copied().find(|x| *x < cur).unwrap_or(1.0) }; apply_style(s, p, |st| st.font.size = next)?; Ok(json!({"size": next})) } pub(crate) fn color_param(v: Option<&Json>) -> Option { match v? { - Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => Some(Color::Auto), + Json::String(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") || s.eq_ignore_ascii_case("none") => { + Some(Color::Auto) + } Json::String(s) => Color::from_hex(s), Json::Object(o) => { let theme = o.get("theme")?.as_u64()? as u8; @@ -390,7 +532,8 @@ fn orientation(s: &mut Session, p: &Json) -> Result { fn merge(s: &mut Session, p: &Json, how: &str) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; - let how = if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how }; + let how = + if how == "center" && s.doc()?.wb.sheet(sheet).is_some_and(|sh| ranges.iter().all(|r| sh.merges.contains(r))) { "unmerge" } else { how }; edit(s, |cx| { let mut touched = Vec::new(); for r in &ranges { @@ -454,7 +597,10 @@ pub fn format_code_for(name: &str) -> &str { } fn number_format(s: &mut Session, p: &Json) -> Result { - let code = str_param(p, "code").map(str::to_string).or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string())).ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?; + let code = str_param(p, "code") + .map(str::to_string) + .or_else(|| str_param(p, "format").map(|f| format_code_for(f).to_string())) + .ok_or_else(|| bad("home.numberFormat", "missing `format` or `code`"))?; set_fmt(s, p, &code) } @@ -516,12 +662,13 @@ pub fn adjust_decimals(code: &str, value: &sheetcraft_core::Value, delta: i32) - } } } else if let Some(pt) = point - && ld > pt { - out.remove(ld); - if ld == pt + 1 { - out.remove(pt); - } + && ld > pt + { + out.remove(ld); + if ld == pt + 1 { + out.remove(pt); } + } out.into_iter().collect() }) .collect(); @@ -659,17 +806,64 @@ pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option", esc(&sh.name)); + let _ = write!( + out, + "{}
", + esc(&sh.name) + ); if let Some(r) = sh.used_range() { for row in r.start.row..=r.end.row.min(r.start.row + 100_000) { if sh.is_row_hidden(row) { @@ -133,7 +137,12 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { _ => "left", }; let _ = write!(css, "text-align:{align};"); - let span = sh.merges.iter().find(|m| m.start == c).map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())).unwrap_or_default(); + let span = sh + .merges + .iter() + .find(|m| m.start == c) + .map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())) + .unwrap_or_default(); let _ = write!(out, "", esc(&crate::display::cell_text(wb, sh, c))); } out.push_str(""); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 62a530da5..6faace9ec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -256,7 +256,8 @@ impl Session { let result = match result { Ok(r) => r, Err(p) => { - let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + let msg = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); // Restore the workbook as it was. if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 6dcb8fb09..a322d2196 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -80,25 +80,35 @@ fn sales(s: &mut Session) -> bool { ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": col, "function": "sum"})); } ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Growth", "function": "average"})); + ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Trend", "function": "none"})); ok &= run(s, "home.numberFormat", json!({"range": "C5:G11", "code": "\"$\"#,##0"})); ok &= run(s, "home.numberFormat", json!({"range": "H5:H11", "code": "0.0%"})); ok &= run(s, "home.conditionalFormat", json!({"range": "G5:G10", "rule": {"type": "dataBar", "color": "#5B9BD5"}})); ok &= run(s, "home.conditionalFormat", json!({"range": "H5:H10", "rule": {"type": "iconSet", "set": "3Arrows"}})); ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 20})); - ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 90})); + ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 132})); ok &= run(s, "home.columnWidth", json!({"cols": "C:G", "width": 84})); ok &= run(s, "home.columnWidth", json!({"cols": "H:I", "width": 76})); ok &= run(s, "selection.set", json!({"range": "B4:F10"})); ok &= run(s, "insert.chart", json!({"type": "column", "title": "Revenue by Region", "at": "K3", "width": 460, "height": 270})); ok &= run(s, "selection.set", json!({"range": "B4:B10"})); // Pie of totals. - ok &= run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260})); + ok &= + run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260})); if let Ok(d) = s.doc_mut() { let wb = Arc::make_mut(&mut d.wb); if let Some(sh) = wb.sheet_mut(0) && let Some(c) = sh.charts.last_mut() { - c.series = vec![sheetcraft_model::Series { name: Some("Sales!$G$4".into()), categories: Some("Sales!$B$5:$B$10".into()), values: "Sales!$G$5:$G$10".into(), bubble_sizes: None, color: None, secondary: false, kind: None }]; + c.series = vec![sheetcraft_model::Series { + name: Some("Sales!$G$4".into()), + categories: Some("Sales!$B$5:$B$10".into()), + values: "Sales!$G$5:$G$10".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + }]; } } ok &= run(s, "range.setValues", json!({"range": "B14", "values": [["Best region"], ["Average quarter"], ["Online share"], ["Year-over-year"]]})); @@ -146,7 +156,11 @@ fn budget(s: &mut Session) -> bool { ok &= run(s, "home.cellStyle", json!({"range": "B15:F15", "name": "Total"})); ok &= run(s, "home.numberFormat", json!({"range": "C5:E15", "format": "Accounting"})); ok &= run(s, "home.numberFormat", json!({"range": "F5:F15", "code": "0%"})); - ok &= run(s, "home.conditionalFormat", json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}})); + ok &= run( + s, + "home.conditionalFormat", + json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}}), + ); ok &= run(s, "home.conditionalFormat", json!({"range": "F5:F14", "rule": {"type": "colorScale", "colors": ["#63BE7B", "#FFEB84", "#F8696B"]}})); ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 130})); ok &= run(s, "home.columnWidth", json!({"cols": "C:E", "width": 90})); @@ -172,12 +186,20 @@ fn grades(s: &mut Session) -> bool { let mut ok = run(s, "range.setValues", json!({"range": "A1", "values": rows})); for r in 2..=9 { ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=AVERAGE(B{r}:E{r})")})); - ok &= run(s, "cell.set", json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")})); + ok &= run( + s, + "cell.set", + json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")}), + ); } ok &= run(s, "home.numberFormat", json!({"range": "F2:F9", "code": "0.0"})); ok &= run(s, "insert.table", json!({"range": "A1:G9", "style": "TableStyleLight9", "name": "Grades"})); ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 120})); - ok &= run(s, "home.conditionalFormat", json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}})); + ok &= run( + s, + "home.conditionalFormat", + json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}}), + ); ok &= run(s, "home.conditionalFormat", json!({"range": "F2:F9", "rule": {"type": "dataBar", "color": "#63BE7B"}})); ok } diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 24915217c..4fccfb4ae 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -141,12 +141,29 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef { return cur; } let beyond = match used { - Some(u) => (dr > 0 && cur.row > u.end.row) || (dc > 0 && cur.col > u.end.col) || (dr < 0 && cur.row < u.start.row) || (dc < 0 && cur.col < u.start.col), + Some(u) => { + (dr > 0 && cur.row > u.end.row) + || (dc > 0 && cur.col > u.end.col) + || (dr < 0 && cur.row < u.start.row) + || (dc < 0 && cur.col < u.start.col) + } None => true, }; if beyond { - let r = if dr > 0 { MAX_ROWS - 1 } else if dr < 0 { 0 } else { cur.row }; - let c = if dc > 0 { MAX_COLS - 1 } else if dc < 0 { 0 } else { cur.col }; + let r = if dr > 0 { + MAX_ROWS - 1 + } else if dr < 0 { + 0 + } else { + cur.row + }; + let c = if dc > 0 { + MAX_COLS - 1 + } else if dc < 0 { + 0 + } else { + cur.col + }; return CellRef::new(r, c); } match cur.offset(dr, dc) { diff --git a/crates/engine/src/tables.rs b/crates/engine/src/tables.rs index f3bacfc00..b239e7071 100644 --- a/crates/engine/src/tables.rs +++ b/crates/engine/src/tables.rs @@ -119,9 +119,10 @@ pub fn table_cell_style(wb: &Workbook, sheet: usize, t: &Table, c: CellRef) -> S } if let Some(l) = lk.line && t.style.contains("Light") - && c.row == t.range.end.row { - s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; - } + && c.row == t.range.end.row + { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l }; + } } if (t.first_col && c.col == t.range.start.col) || (t.last_col && c.col == t.range.end.col) { s.font.bold = true; diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 7cef10685..d38aa9fa2 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -113,8 +113,20 @@ fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Op if lo >= at && hi < end { return None; // fully deleted } - let lo2 = if lo < at { lo } else if lo >= end { lo - count } else { at }; - let hi2 = if hi < at { hi } else if hi >= end { hi - count } else { at.saturating_sub(1) }; + let lo2 = if lo < at { + lo + } else if lo >= end { + lo - count + } else { + at + }; + let hi2 = if hi < at { + hi + } else if hi >= end { + hi - count + } else { + at.saturating_sub(1) + }; Some((lo2, hi2.max(lo2))) } } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 49e58000c..46264f4e2 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -77,7 +77,8 @@ fn errors() { #[test] fn no_panic_on_garbage() { - let samples = ["(((((", "[[[", "'''", "!!!", "$$$", "=@@@", "A1:B2:C3:D4", "{,}", "{;}", "F(,,,,)", ":::", "1e", "1e+", ".", "..", "$A$", "XFD1048577"]; + let samples = + ["(((((", "[[[", "'''", "!!!", "$$$", "=@@@", "A1:B2:C3:D4", "{,}", "{;}", "F(,,,,)", ":::", "1e", "1e+", ".", "..", "$A$", "XFD1048577"]; for s in samples { let _ = parse(s); } diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 000000000..90d835967 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "sheetcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SheetCraft MCP server: JSON-RPC 2.0 over stdio, driving a running app or a headless session" + +[lints] +workspace = true + +[dependencies] +sheetcraft-engine = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 000000000..cd31151cb --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,113 @@ +//! Where MCP tool calls end up: a control-channel method call. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Something that answers control-channel methods (`engine.execute`, `engine.commands`, +/// `document.inspect`, `sheet.read`, `ui.screenshot`, …). +pub trait Backend { + /// Call one method. `Ok` carries the `result`, `Err` the error message. + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a real UI is attached (`ui.screenshot` and dialogs work). + fn has_ui(&self) -> bool; + /// Short human description ("headless", "connected to 127.0.0.1:7979"). + fn describe(&self) -> String; +} + +/// Read/write timeout on the control connection. +const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// Longest reply line accepted from the app (guards against a runaway peer). +const MAX_REPLY: u64 = 256 * 1024 * 1024; + +/// A running SheetCraft app, reached through its loopback control port. +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Remote { + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + pub fn connect(addr: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + r.reconnect()?; + Ok(r) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn reconnect(&mut self) -> std::io::Result<()> { + self.conn = None; + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); + for sa in self.addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) { + Ok(s) => { + s.set_nodelay(true).ok(); + s.set_read_timeout(Some(IO_TIMEOUT)).ok(); + s.set_write_timeout(Some(IO_TIMEOUT)).ok(); + let read = s.try_clone()?; + self.conn = Some((BufReader::new(read), s)); + return Ok(()); + } + Err(e) => last = e, + } + } + Err(last) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { + if self.conn.is_none() { + self.reconnect()?; + } + let Some((reader, writer)) = self.conn.as_mut() else { + return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + let n = std::io::Read::take(reader, MAX_REPLY).read_line(&mut reply)?; + if n == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + } + Ok(reply) + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + let line = json!({"id": id, "method": method, "params": params}).to_string(); + // One retry with a fresh connection (the app may have restarted). + let reply = match self.roundtrip(&line) { + Ok(r) => r, + Err(_) => { + self.conn = None; + self.roundtrip(&line).map_err(|e| { + self.conn = None; + format!("SheetCraft app at {} is not reachable: {e}", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string()) + } + } + + fn has_ui(&self) -> bool { + true + } + + fn describe(&self) -> String { + format!("connected to the SheetCraft app at {}", self.addr) + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 000000000..57fc5be0d --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,94 @@ +//! An in-process engine session that answers the engine-level control-channel methods itself. + +use serde_json::{Value, json}; +use sheetcraft_engine::Session; + +use crate::backend::Backend; + +/// Error for methods that need a window. +pub(crate) const NO_UI: &str = "no UI in headless mode: start the app with `sheetcraft --control 7979` and run the MCP server \ +with `sheetcraft-cli mcp --connect 7979`"; + +/// Headless backend: a [`Session`] with one blank workbook to start with. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +impl Headless { + /// A session with a blank workbook (`Book1`) open. + pub fn new() -> Self { + let mut session = Session::new(); + session.new_workbook(); + Self { session } + } + + /// A session wrapping an existing one (e.g. with a file already open). + pub fn with_session(session: Session) -> Self { + Self { session } + } + + fn execute(&mut self, id: &str, params: Value) -> Result { + let r = self.session.execute(id, params).map_err(|e| e.to_string()); + // Programmatic calls never open dialogs; drop anything a command queued for a UI. + for req in self.session.take_ui_requests() { + log::debug!("headless: ignoring UI request {req:?}"); + } + r + } + + fn commands(&self, params: &Value) -> Value { + let q = params.get("search").and_then(Value::as_str).map(str::to_ascii_lowercase); + let list: Vec = self + .session + .commands() + .into_iter() + .filter(|c| { + q.as_ref().is_none_or(|q| { + c.id.to_ascii_lowercase().contains(q.as_str()) + || c.label.to_ascii_lowercase().contains(q.as_str()) + || c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str())) + }) + }) + .map(|c| serde_json::to_value(c).unwrap_or_default()) + .collect(); + Value::Array(list) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let params = if params.is_null() { json!({}) } else { params }; + match method { + "ping" => Ok(json!("pong")), + "engine.execute" => { + let id = params.get("command").and_then(Value::as_str).ok_or("engine.execute: missing `command`")?.to_string(); + let p = params.get("params").cloned().unwrap_or_else(|| json!({})); + if !(p.is_object() || p.is_null()) { + return Err(format!("engine.execute: `params` of `{id}` must be an object")); + } + self.execute(&id, p) + } + "engine.commands" => Ok(self.commands(¶ms)), + "engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())), + m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")), + // Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are + // commands with the same id. + m if sheetcraft_engine::find_command(m).is_some() => self.execute(m, params), + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine, no window)".to_string() + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 000000000..74125c13c --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,36 @@ +//! SheetCraft's MCP server. +//! +//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes SheetCraft as MCP tools and +//! resources and forwards everything to a [`Backend`]: +//! +//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`sheetcraft --control 7979`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). +//! - [`Headless`] hosts an in-process [`sheetcraft_engine::Session`] and implements the +//! engine-level control-channel methods itself, so agents can build workbooks without a window. +//! +//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod backend; +mod headless; +mod server; +mod tools; + +pub use backend::{Backend, Remote}; +pub use headless::Headless; +pub use server::{COMMANDS_URI, FUNCTIONS_URI, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server, WORKBOOK_URI}; +pub use tools::{ToolResult, call_tool, tool_definitions}; + +/// Default control-channel port of the desktop app. +pub const DEFAULT_PORT: u16 = 7979; + +/// `"7979"` → `"127.0.0.1:7979"`; `"host:port"` is kept as is. +pub fn control_addr(s: &str) -> String { + if s.parse::().is_ok() { format!("127.0.0.1:{s}") } else { s.to_string() } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 000000000..e26787bed --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,197 @@ +//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::backend::Backend; +use crate::tools::{call_tool, check_arguments, tool_definitions}; + +/// The MCP revision we implement. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +/// Revisions we can speak; an older client gets its own version echoed back. +pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; +const RESOURCE_NOT_FOUND: i64 = -32002; + +/// Longest accepted message line (16 MB): bigger payloads belong in files. +const MAX_LINE: usize = 16 * 1024 * 1024; + +const INSTRUCTIONS: &str = "SheetCraft is a spreadsheet app (an Excel clone). Cells use A1 notation (\"B2\", \"A1:D10\", \ +\"Sheet2!A1:B3\"). Every action is a command: find ids and parameters with list_commands and run any of them with \ +execute_command. Convenience tools cover the common flow: write_range / set_cell to enter values and formulas (strings \ +starting with = are formulas), read_range / get_cell to read computed values back, format_range, create_table, \ +insert_chart, sort_range, filter, evaluate_formula (try a formula without changing the sheet), undo / redo, \ +open_workbook / save_workbook. inspect_workbook summarizes sheets, used ranges, tables, charts and names. \ +list_functions lists the worksheet functions."; + +/// Resource URIs. +pub const WORKBOOK_URI: &str = "sheetcraft://workbook"; +pub const COMMANDS_URI: &str = "sheetcraft://commands"; +pub const FUNCTIONS_URI: &str = "sheetcraft://functions"; + +/// An MCP server bound to one backend. +pub struct Server { + backend: Box, + initialized: bool, +} + +fn response(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error(id: Value, code: i64, message: impl Into) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +impl Server { + pub fn new(backend: Box) -> Self { + Self { backend, initialized: false } + } + + pub fn backend(&mut self) -> &mut dyn Backend { + self.backend.as_mut() + } + + /// Whether the client has sent `notifications/initialized`. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is + /// the protocol stream). + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + buf.clear(); + let n = input.read_until(b'\n', &mut buf)?; + if n == 0 { + return Ok(()); + } + let reply = match std::str::from_utf8(&buf) { + Ok(line) => self.handle_line(line), + Err(_) => Some(error(Value::Null, PARSE_ERROR, "message is not valid UTF-8").to_string()), + }; + if let Some(reply) = reply { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + } + + /// Handle one line; returns the reply line (None for notifications and blank lines). + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + if line.len() > MAX_LINE { + return Some(error(Value::Null, INVALID_REQUEST, "message too large (16 MB max)").to_string()); + } + let reply = match serde_json::from_str::(line) { + Ok(Value::Array(batch)) => { + // Batches were removed in 2025-06-18; still answer older clients sensibly. + if batch.is_empty() { + Some(error(Value::Null, INVALID_REQUEST, "empty batch")) + } else { + let replies: Vec = batch.into_iter().filter_map(|m| self.handle(m)).collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) + } + } + Ok(msg) => self.handle(msg), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one JSON-RPC message; `None` for notifications and responses. + pub fn handle(&mut self, msg: Value) -> Option { + let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) }; + let id = o.get("id").cloned(); + let Some(method) = o.get("method").and_then(Value::as_str) else { + // A response to a server→client request (we send none): ignore. Anything else is invalid. + if o.contains_key("result") || o.contains_key("error") { + return None; + } + return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`")); + }; + let params = o.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + self.notification(method); + return None; + }; + if !(id.is_string() || id.is_number()) { + return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number")); + } + if !(params.is_object() || params.is_null()) { + return Some(error(id, INVALID_PARAMS, "`params` must be an object")); + } + Some(match self.request(method, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn notification(&mut self, method: &str) { + match method { + "notifications/initialized" => self.initialized = true, + "notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {} + other => log::debug!("ignoring notification {other}"), + } + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); + let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "prompts": {"listChanged": false}}, + "serverInfo": {"name": "sheetcraft", "title": "SheetCraft", "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + let args = match params.get("arguments") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err((INVALID_PARAMS, "tool `arguments` must be an object".to_string())), + }; + check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?; + Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + } + "resources/list" => Ok(json!({"resources": [ + {"uri": WORKBOOK_URI, "name": "workbook", "title": "Active workbook", "description": "Sheets, used ranges, tables, charts, names, selection and undo history of the active workbook (document.inspect)", "mimeType": "application/json"}, + {"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, ribbon path, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"}, + {"uri": FUNCTIONS_URI, "name": "functions", "title": "Worksheet functions", "description": "Every worksheet function: name, category, signature, description (formulas.functions)", "mimeType": "application/json"}, + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; + let v = match uri { + WORKBOOK_URI => self.backend.call("document.inspect", json!({})), + COMMANDS_URI => self.backend.call("engine.commands", json!({})), + FUNCTIONS_URI => self.backend.call("engine.execute", json!({"command": "formulas.functions", "params": {}})), + _ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))), + } + .map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + } + "prompts/list" => Ok(json!({"prompts": []})), + "prompts/get" => Err((INVALID_PARAMS, "no prompts are defined".to_string())), + "logging/setLevel" => Ok(json!({})), + "completion/complete" => Ok(json!({"completion": {"values": [], "hasMore": false}})), + other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 000000000..28d8c9a9a --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,383 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, control_addr, tool_definitions}; + +fn server() -> Server { + Server::new(Box::new(Headless::new())) +} + +fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply = s.handle_line(&line).expect("reply"); + let v: Value = serde_json::from_str(&reply).expect("reply is JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], id); + v +} + +fn call(s: &mut Server, name: &str, args: Value) -> Value { + let v = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args})); + assert!(v.get("error").is_none(), "{v}"); + v["result"].clone() +} + +fn text_of(result: &Value) -> String { + result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect() +} + +/// Call a tool that must succeed; returns its JSON payload. +fn ok(s: &mut Server, name: &str, args: Value) -> Value { + let r = call(s, name, args); + assert_eq!(r["isError"], false, "{name}: {r}"); + serde_json::from_str(&text_of(&r)).unwrap_or(Value::Null) +} + +/// Call a tool that must fail; returns its message. +fn fails(s: &mut Server, name: &str, args: Value) -> String { + let r = call(s, name, args); + assert_eq!(r["isError"], true, "{name} should fail: {r}"); + text_of(&r) +} + +fn err_code(v: &Value) -> i64 { + v["error"]["code"].as_i64().unwrap_or_else(|| panic!("expected an error: {v}")) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sheetcraft-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +// ---------- protocol ---------- + +#[test] +fn initialize_and_version_negotiation() { + let mut s = server(); + let v = + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})); + let r = &v["result"]; + assert_eq!(r["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(r["serverInfo"]["name"], "sheetcraft"); + assert!(r["capabilities"]["tools"].is_object()); + assert!(r["capabilities"]["resources"].is_object()); + assert!(r["instructions"].as_str().unwrap().contains("headless")); + // Older supported version is echoed back; unknown versions get ours. + let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "2024-11-05"})); + assert_eq!(v["result"]["protocolVersion"], "2024-11-05"); + let v = rpc(&mut s, 3, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION); + assert!(!s.is_initialized()); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None); + assert!(s.is_initialized()); + assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({})); + assert_eq!(rpc(&mut s, 5, "prompts/list", json!({}))["result"]["prompts"], json!([])); +} + +#[test] +fn tools_list_has_schemas() { + let mut s = server(); + let v = rpc(&mut s, 1, "tools/list", json!({})); + let tools = v["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for want in [ + "execute_command", + "list_commands", + "inspect_workbook", + "read_range", + "write_range", + "set_cell", + "get_cell", + "evaluate_formula", + "select", + "format_range", + "insert_chart", + "create_table", + "sort_range", + "filter", + "open_workbook", + "save_workbook", + "new_workbook", + "undo", + "redo", + "screenshot", + "list_functions", + ] { + assert!(names.contains(&want), "missing tool {want}"); + } + for t in tools { + assert_eq!(t["inputSchema"]["type"], "object", "{t}"); + assert!(t["description"].as_str().unwrap().len() > 20, "{t}"); + let name = t["name"].as_str().unwrap(); + assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name} is not snake_case"); + for r in t["inputSchema"]["required"].as_array().into_iter().flatten() { + assert!(t["inputSchema"]["properties"].get(r.as_str().unwrap()).is_some(), "{name}: required {r} not in properties"); + } + } + assert_eq!(tools.len(), tool_definitions().len()); +} + +#[test] +fn malformed_input_never_panics() { + let mut s = server(); + assert_eq!(s.handle_line(" "), None); + let parse = |s: &mut Server, l: &str| -> Value { serde_json::from_str(&s.handle_line(l).unwrap()).unwrap() }; + assert_eq!(err_code(&parse(&mut s, "{not json")), -32700); + assert_eq!(err_code(&parse(&mut s, "\"just a string\"")), -32600); + assert_eq!(err_code(&parse(&mut s, "[]")), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":1}"#)), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":{"x":1},"method":"ping"}"#)), -32600); + assert_eq!(err_code(&parse(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":[1,2]}"#)), -32602); + // Unknown method. + assert_eq!(err_code(&rpc(&mut s, 1, "nope/nothing", json!({}))), -32601); + // Bad tools/call params. + assert_eq!(err_code(&rpc(&mut s, 2, "tools/call", json!({}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 3, "tools/call", json!({"name": "no_such_tool"}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 4, "tools/call", json!({"name": "set_cell", "arguments": "x"}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 5, "tools/call", json!({"name": "set_cell", "arguments": {"cell": "A1"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 6, "tools/call", json!({"name": "set_cell", "arguments": {"cell": 5, "input": "x"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 7, "tools/call", json!({"name": "insert_chart", "arguments": {"range": "A1", "type": "piechart"}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 8, "tools/call", json!({"name": "get_cell", "arguments": {"cell": "A1", "bogus": 1}}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 9, "resources/read", json!({}))), -32602); + assert_eq!(err_code(&rpc(&mut s, 10, "resources/read", json!({"uri": "sheetcraft://nope"}))), -32002); + // Responses and unknown notifications are ignored. + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#), None); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/whatever"}"#), None); + // A batch still gets answers. + let v = parse(&mut s, r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"}]"#); + assert_eq!(v.as_array().unwrap().len(), 1); + // Tool-level errors are isError results, not protocol errors. + assert!(fails(&mut s, "set_cell", json!({"cell": "not a cell!!", "input": "1"})).contains("set_cell")); + assert!(fails(&mut s, "execute_command", json!({"command": "no.such.command"})).contains("unknown command")); + fails(&mut s, "read_range", json!({"range": "ZZZZZZZZ99999999999"})); + fails(&mut s, "format_range", json!({"range": "A1"})); + fails(&mut s, "sort_range", json!({"keys": []})); + fails(&mut s, "open_workbook", json!({"path": "/definitely/not/here.xlsx"})); + fails(&mut s, "save_workbook", json!({})); + assert!(fails(&mut s, "screenshot", json!({"path": "/tmp/x.png"})).contains("no UI in headless mode")); +} + +#[test] +fn every_tool_survives_hostile_arguments() { + let hostile = [json!({}), json!({"range": "A1:XFD1048576"}), json!({"range": "", "cell": "", "path": "", "formula": "", "command": ""})]; + for t in tool_definitions() { + let name = t["name"].as_str().unwrap(); + for args in &hostile { + let mut s = server(); + // Either a protocol error or a tool result; never a panic. + let _ = + s.handle_line(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string()); + } + } +} + +#[test] +fn resources() { + let mut s = server(); + let v = rpc(&mut s, 1, "resources/list", json!({})); + let uris: Vec<&str> = v["result"]["resources"].as_array().unwrap().iter().map(|r| r["uri"].as_str().unwrap()).collect(); + assert_eq!(uris, ["sheetcraft://workbook", "sheetcraft://commands", "sheetcraft://functions"]); + for uri in uris { + let v = rpc(&mut s, 2, "resources/read", json!({"uri": uri})); + let text = v["result"]["contents"][0]["text"].as_str().unwrap(); + let parsed: Value = serde_json::from_str(text).unwrap(); + assert!(!parsed.is_null(), "{uri}"); + } + let v = rpc(&mut s, 3, "resources/read", json!({"uri": "sheetcraft://functions"})); + assert!(v["result"]["contents"][0]["text"].as_str().unwrap().contains("VLOOKUP")); +} + +#[test] +fn basic_tools() { + let mut s = server(); + let cmds = ok(&mut s, "list_commands", json!({"search": "bold"})); + assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "home.bold")); + let all = ok(&mut s, "list_commands", json!({})); + assert!(all.as_array().unwrap().len() > 100); + let f = ok(&mut s, "list_functions", json!({"search": "vlookup"})); + assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP")); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0)); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"})); + ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0)); + ok(&mut s, "undo", json!({})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], Value::Null); + ok(&mut s, "redo", json!({})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0)); + ok(&mut s, "select", json!({"range": "A1:A2"})); + assert_eq!(ok(&mut s, "inspect_workbook", json!({}))["selection"], "A1:A2"); + ok(&mut s, "new_workbook", json!({"sample": "sales"})); + let info = ok(&mut s, "inspect_workbook", json!({})); + assert!(info["sheets"][0]["cells"].as_u64().unwrap() > 10, "{info}"); +} + +#[test] +fn sort_and_filter() { + let mut s = server(); + ok(&mut s, "write_range", json!({"range": "A1", "values": [["Name", "Score"], ["b", 2], ["c", 3], ["a", 1]]})); + ok(&mut s, "sort_range", json!({"range": "A1:B4", "keys": [{"column": "B", "order": "desc"}], "header": true})); + let v = ok(&mut s, "read_range", json!({"range": "A2:A4"})); + assert_eq!(v["values"], json!([["c"], ["b"], ["a"]])); + ok(&mut s, "sort_range", json!({"range": "A1:B4", "keys": ["A"], "header": true})); + let v = ok(&mut s, "read_range", json!({"range": "A2:A4"})); + assert_eq!(v["values"], json!([["a"], ["b"], ["c"]])); + ok(&mut s, "filter", json!({"range": "A1:B4", "column": "A", "values": ["a", "c"]})); + let info = ok(&mut s, "inspect_workbook", json!({})); + assert_eq!(info["sheets"][0]["autofilter"], "A1:B4"); +} + +#[test] +fn save_and_open_roundtrip() { + let mut s = server(); + ok(&mut s, "write_range", json!({"range": "B2", "values": [[1, 2], [3, "=SUM(B2:C2)+B3"]]})); + let path = tmp("roundtrip.xlsx"); + let p = path.to_string_lossy().to_string(); + ok(&mut s, "save_workbook", json!({"path": p})); + ok(&mut s, "save_workbook", json!({})); + ok(&mut s, "new_workbook", json!({})); + ok(&mut s, "open_workbook", json!({"path": p})); + let v = ok(&mut s, "read_range", json!({"range": "B2:C3", "formulas": true})); + assert_eq!(v["values"], json!([[1.0, 2.0], [3.0, "=SUM(B2:C2)+B3"]])); + let v = ok(&mut s, "read_range", json!({"range": "C3"})); + assert_eq!(v["values"], json!([[6.0]])); + let _ = std::fs::remove_file(path); +} + +/// An agent builds a small monthly budget through MCP tool calls only. +#[test] +fn agent_builds_a_budget() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "agent", "version": "1"}})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + + ok( + &mut s, + "write_range", + json!({"range": "A1", "values": [ + ["Category", "January", "February", "March"], + ["Rent", 1200, 1200, 1250], + ["Groceries", 410.5, 389.25, 402], + ["Utilities", 150, 162, 140], + ["Transport", 90, 85, 110], + ]}), + ); + ok(&mut s, "set_cell", json!({"cell": "A6", "input": "Total"})); + for col in ["B", "C", "D"] { + ok(&mut s, "set_cell", json!({"cell": format!("{col}6"), "input": format!("=SUM({col}2:{col}5)")})); + } + ok(&mut s, "set_cell", json!({"cell": "E1", "input": "Quarter"})); + ok( + &mut s, + "write_range", + json!({"range": "E2", "values": [["=SUM(B2:D2)"], ["=SUM(B3:D3)"], ["=SUM(B4:D4)"], ["=SUM(B5:D5)"], ["=SUM(B6:D6)"]]}), + ); + + let fmt = + ok(&mut s, "format_range", json!({"range": "A1:E1", "bold": true, "fillColor": "#DDEBF7", "horizontalAlign": "center", "borders": "bottom"})); + assert_eq!(fmt["applied"].as_array().unwrap().len(), 4, "{fmt}"); + ok(&mut s, "format_range", json!({"range": "B2:E6", "numberFormat": "#,##0.00"})); + ok(&mut s, "format_range", json!({"range": "A6:E6", "bold": true, "borders": {"preset": "top", "style": "double"}})); + + ok(&mut s, "create_table", json!({"range": "A1:E5", "style": "TableStyleLight9"})); + ok(&mut s, "insert_chart", json!({"range": "A1:D5", "type": "column", "title": "Monthly spend", "at": "G2"})); + + // Read back computed values. + let totals = ok(&mut s, "read_range", json!({"range": "B6:E6"})); + let row = totals["values"][0].as_array().unwrap(); + let nums: Vec = row.iter().map(|v| v.as_f64().unwrap()).collect(); + assert!((nums[0] - 1850.5).abs() < 1e-9, "{nums:?}"); + assert!((nums[1] - 1836.25).abs() < 1e-9, "{nums:?}"); + assert!((nums[2] - 1902.0).abs() < 1e-9, "{nums:?}"); + assert!((nums[3] - 5588.75).abs() < 1e-9, "{nums:?}"); + let shown = ok(&mut s, "read_range", json!({"range": "E6", "formatted": true})); + assert_eq!(shown["values"][0][0], "5,588.75"); + let formulas = ok(&mut s, "read_range", json!({"range": "B6", "formulas": true})); + assert_eq!(formulas["values"][0][0], "=SUM(B2:B5)"); + let cell = ok(&mut s, "get_cell", json!({"cell": "A1"})); + assert_eq!(cell["style"]["font"]["bold"], true, "{cell}"); + + let info = ok(&mut s, "inspect_workbook", json!({})); + let sheet = &info["sheets"][0]; + assert_eq!(sheet["tables"].as_array().unwrap().len(), 1, "{info}"); + assert_eq!(sheet["tables"][0]["range"], "A1:E5"); + assert_eq!(sheet["charts"].as_array().unwrap().len(), 1, "{info}"); + assert_eq!(sheet["charts"][0]["title"], "Monthly spend"); + + // Change an input: totals follow. + ok(&mut s, "set_cell", json!({"cell": "B2", "input": "1300"})); + let v = ok(&mut s, "read_range", json!({"range": "E6"})); + assert!((v["values"][0][0].as_f64().unwrap() - 5688.75).abs() < 1e-9); + // And the whole thing evaluates without touching the sheet. + let v = ok(&mut s, "evaluate_formula", json!({"formula": "=AVERAGE(B6:D6)"})); + assert!((v["result"].as_f64().unwrap() - (1950.5 + 1836.25 + 1902.0) / 3.0).abs() < 1e-9); +} + +// ---------- serve loop + remote ---------- + +#[test] +fn serve_loop_over_buffers() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + "garbage\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"evaluate_formula","arguments":{"formula":"=2*21"}}}"#, + "\n" + ); + let mut out = Vec::new(); + let mut s = server(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[1]["error"]["code"], -32700); + assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("42")); +} + +#[test] +fn remote_backend_over_tcp() { + // A fake app: answers engine.execute by running a headless session; closes after the first + // request to exercise the reconnect path. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let mut h = Headless::new(); + for (i, stream) in listener.incoming().take(2).enumerate() { + let stream = stream.unwrap(); + let mut w = stream.try_clone().unwrap(); + let mut r = BufReader::new(stream); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let req: Value = serde_json::from_str(&line).unwrap(); + let reply = match h.call(req["method"].as_str().unwrap(), req["params"].clone()) { + Ok(v) => json!({"id": req["id"], "ok": true, "result": v}), + Err(e) => json!({"id": req["id"], "ok": false, "error": e}), + }; + w.write_all(format!("{reply}\n").as_bytes()).unwrap(); + if i == 0 { + break; // drop the first connection + } + } + } + }); + let addr = control_addr(&port.to_string()); + assert_eq!(addr, format!("127.0.0.1:{port}")); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0)); + // Second call reconnects transparently. + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); + assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A1"}))["value"], json!(7.0)); + let msg = fails(&mut s, "execute_command", json!({"command": "nope"})); + assert!(msg.contains("unknown command"), "{msg}"); + drop(s); + handle.join().unwrap(); + assert!(Remote::connect("127.0.0.1:1").is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 000000000..8921dc046 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,616 @@ +//! MCP tool definitions (names, descriptions, JSON Schemas) and their implementations on top of a +//! [`Backend`]. + +use serde_json::{Map, Value, json}; + +use crate::backend::Backend; +use crate::headless::NO_UI; + +/// The result of `tools/call`: MCP content blocks plus the `isError` flag. +#[derive(Clone, Debug, PartialEq)] +pub struct ToolResult { + pub content: Vec, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false } + } + pub fn json(v: &Value) -> Self { + Self::text(serde_json::to_string_pretty(v).unwrap_or_default()) + } + pub fn error(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true } + } + pub fn to_value(&self) -> Value { + json!({"content": self.content, "isError": self.is_error}) + } +} + +// ---------- schemas ---------- + +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn boolean(desc: &str) -> Value { + json!({"type": "boolean", "description": desc}) +} +fn int(desc: &str) -> Value { + json!({"type": "integer", "minimum": 0, "description": desc}) +} +fn enumeration(desc: &str, values: &[&str]) -> Value { + json!({"type": "string", "enum": values, "description": desc}) +} +fn params_schema() -> Value { + json!({"type": "object", "description": "Command parameters (see the `params` field of list_commands); omit or {} for defaults"}) +} +fn color(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { + json!({ + "name": name, + "title": title, + "description": desc, + "inputSchema": schema, + "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": false, "openWorldHint": false}, + }) +} + +const RANGE: &str = "A1-style range, e.g. \"A1:D10\", \"B:B\", \"Sheet2!A1:C3\""; +const CHART_TYPES: &[&str] = &[ + "column", + "bar", + "line", + "pie", + "doughnut", + "area", + "scatter", + "bubble", + "radar", + "combo", + "histogram", + "waterfall", + "funnel", + "treemap", + "sunburst", + "boxWhisker", + "stock", +]; +const BORDER_PRESETS: &[&str] = &[ + "bottom", + "top", + "left", + "right", + "none", + "all", + "outside", + "thickOutside", + "thickBottom", + "doubleBottom", + "topBottom", + "topThickBottom", + "topDoubleBottom", + "insideHorizontal", + "insideVertical", + "inside", + "diagonalDown", + "diagonalUp", +]; + +/// All tools, in `tools/list` order. +pub fn tool_definitions() -> Vec { + let empty = || obj(json!({}), &[]); + vec![ + // ----- generic ----- + tool( + "execute_command", + "Execute command", + "Run ANY SheetCraft command by id with JSON params — every ribbon/menu action is a command. Find ids and parameter \ + docs with list_commands. Examples: {\"command\":\"cell.set\",\"params\":{\"cell\":\"B2\",\"input\":\"=SUM(A1:A9)\"}}; \ + {\"command\":\"home.bold\",\"params\":{\"range\":\"A1:C1\"}}; {\"command\":\"sheet.insert\"}; \ + {\"command\":\"data.removeDuplicates\",\"params\":{\"range\":\"A1:C20\"}}. Most commands act on the current selection \ + when `range` is omitted. Programmatic calls never open dialogs: missing required params give an error.", + obj( + json!({"command": string("Command id, e.g. \"cell.set\", \"home.fillColor\", \"insert.chart\""), "params": params_schema()}), + &["command"], + ), + false, + ), + tool( + "list_commands", + "List commands", + "List SheetCraft commands: id, label, ribbon path, shortcut, a parameter description (`params`) and whether it can run \ + right now. Use it to discover what execute_command can do (e.g. search \"sort\", \"border\", \"chart\", \"sheet\").", + obj(json!({"search": string("Case-insensitive substring matched against id, label and ribbon path")}), &[]), + true, + ), + // ----- workbook ----- + tool( + "inspect_workbook", + "Inspect workbook", + "Summary of the active workbook: title, path, dirty flag, sheets (name, used range, cell count, tables, charts, merges, \ + filters, freeze panes), defined names, current selection and active cell, calculation mode and undo/redo history.", + empty(), + true, + ), + tool( + "read_range", + "Read range", + "Read cell values from a range as rows (2-D array). Numbers/booleans/strings are returned as JSON values, empty cells as \ + null, errors as {\"error\":\"#DIV/0!\"}. Without `range`, reads the sheet's used range. formulas:true returns formula \ + text (\"=SUM(B2:B4)\") for formula cells; formatted:true returns the displayed text (\"$1,234.00\", \"12%\").", + obj( + json!({ + "range": string(RANGE), + "sheet": string("Sheet name (default: the active sheet)"), + "formulas": boolean("Return formulas instead of computed values for formula cells"), + "formatted": boolean("Return display text with number formats applied"), + }), + &[], + ), + true, + ), + tool( + "write_range", + "Write range", + "Write a 2-D array of values starting at the top-left cell of `range` (rows of cells). Numbers, booleans, null (clears) \ + and strings are accepted; strings starting with = are formulas (\"=SUM(B2:B4)\"), other strings are parsed like typed \ + input (\"12%\", \"1/2/2024\", \"$5\"). One undo step. Example: {\"range\":\"A1\",\"values\":[[\"Item\",\"Cost\"],[\"Rent\",1200]]}.", + obj( + json!({ + "range": string("Top-left cell or range, e.g. \"A1\" or \"Sheet2!B3\""), + "values": {"type": "array", "description": "Rows of cell values", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}}, + }), + &["range", "values"], + ), + false, + ), + tool( + "set_cell", + "Set cell", + "Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \ + Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.", + obj( + json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}), + &["cell", "input"], + ), + false, + ), + tool( + "get_cell", + "Get cell", + "Details of one cell: computed value and type, formula, display text, raw input, spill range, style, merge, comment and hyperlink.", + obj(json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\"")}), &["cell"]), + true, + ), + tool( + "evaluate_formula", + "Evaluate formula", + "Evaluate a formula against the active sheet WITHOUT changing any cell, e.g. \"=SUM(B2:B10)/COUNT(B2:B10)\" or \ + \"=VLOOKUP(\\\"x\\\",A:B,2,FALSE)\". `cell` sets the formula's position for relative references and ROW()/COLUMN().", + obj( + json!({"formula": string("Formula text, with or without the leading ="), "cell": string("Evaluate as if in this cell (default: active cell)")}), + &["formula"], + ), + true, + ), + tool( + "select", + "Select", + "Set the selection (what commands without a `range` param act on). Multiple areas with commas: \"A1:B2,D4\". \ + A \"Sheet2!A1\" prefix switches sheets.", + obj( + json!({"range": string(RANGE), "active": string("Active cell inside the selection (default: top-left of the last area)")}), + &["range"], + ), + false, + ), + tool( + "format_range", + "Format range", + "Apply several formats to a range in one call: bold, italic, underline, font name/size, font color, fill color, number \ + format, horizontal alignment, wrap, borders. Colors are \"#RRGGBB\" (fontColor also \"auto\", fillColor also \"none\"). \ + numberFormat is a name (General, Number, Currency, Accounting, Comma, Percentage, Short Date, Long Date, Time, Fraction, \ + Scientific, Text) or a format code (\"#,##0.00\", \"0%\", \"yyyy-mm-dd\"). Each property runs one command; the result \ + lists them.", + obj( + json!({ + "range": string(RANGE), + "bold": boolean("Bold on/off"), + "italic": boolean("Italic on/off"), + "underline": boolean("Single underline on/off"), + "fontName": string("Font family, e.g. \"Calibri\""), + "fontSize": {"type": "number", "minimum": 1, "maximum": 409, "description": "Font size in points"}, + "fontColor": color("Text color \"#RRGGBB\" or \"auto\""), + "fillColor": color("Background color \"#RRGGBB\" or \"none\""), + "numberFormat": string("Number format name or code"), + "horizontalAlign": enumeration("Horizontal alignment", &["left", "center", "right", "justify"]), + "wrapText": boolean("Wrap text on/off"), + "borders": { + "description": "A border preset name, or {preset, style?, color?}", + "oneOf": [ + enumeration("Border preset", BORDER_PRESETS), + obj(json!({ + "preset": enumeration("Border preset", BORDER_PRESETS), + "style": string("thin|medium|thick|dashed|dotted|double|hair…"), + "color": color("Border color \"#RRGGBB\""), + }), &["preset"]), + ], + }, + }), + &["range"], + ), + false, + ), + tool( + "insert_chart", + "Insert chart", + "Insert a chart from a data range (first row/column used as series names and categories, like Excel). Returns the chart id.", + obj( + json!({ + "range": string("Source data range including headers, e.g. \"A1:C6\""), + "type": enumeration("Chart type", CHART_TYPES), + "subtype": enumeration("Variant (optional)", &["clustered", "stacked", "stacked100", "markers"]), + "title": string("Chart title"), + "at": string("Top-left anchor cell (default: right of the data)"), + }), + &["range", "type"], + ), + false, + ), + tool( + "create_table", + "Create table", + "Format a range as an Excel table (structured references, banded rows, filter buttons). The first row is the header by default.", + obj( + json!({ + "range": string("Range including the header row, e.g. \"A1:D20\""), + "style": string("Table style, e.g. \"TableStyleMedium2\" (default), \"TableStyleLight9\""), + "header": boolean("First row holds headers (default true)"), + "name": string("Table name (default Table1, Table2…)"), + }), + &["range"], + ), + false, + ), + tool( + "sort_range", + "Sort range", + "Sort a range by one or more columns. keys: [{\"column\":\"B\",\"order\":\"desc\"}, …] or just [\"B\"]. Without `range` the \ + current region around the selection is sorted; header detection follows Excel unless `header` is given.", + obj( + json!({ + "range": string(RANGE), + "keys": { + "type": "array", + "minItems": 1, + "description": "Sort levels, most significant first", + "items": {"oneOf": [ + string("Column letter, e.g. \"B\" (ascending)"), + obj(json!({"column": string("Column letter, e.g. \"B\""), "order": enumeration("Sort order", &["asc", "desc"])}), &["column"]), + ]}, + }, + "header": boolean("The first row is a header (kept in place)"), + }), + &["keys"], + ), + false, + ), + tool( + "filter", + "Filter", + "Filter rows by a column (turns on AutoFilter on the current region, or `range`, when needed). Give `values` (keep rows whose \ + cell text is one of them), `custom` ({op:\">\", value:\"10\", op2?, value2?, and?}), `top` ({count, bottom?, percent?}), or \ + clear:true to remove this column's filter.", + obj( + json!({ + "column": string("Column letter, e.g. \"C\""), + "range": string("Data range to filter (default: the current region of the selection)"), + "values": {"type": "array", "items": {"type": ["string", "number", "boolean"]}, "description": "Values to keep"}, + "custom": {"type": "object", "description": "Custom criteria: {op: \"=\"|\"<>\"|\">\"|\">=\"|\"<\"|\"<=\"|\"beginsWith\"|\"contains\"…, value, op2?, value2?, and?: bool}"}, + "top": {"type": "object", "description": "Top/bottom N: {count, bottom?: bool, percent?: bool}"}, + "blanks": boolean("Also keep blank cells"), + "clear": boolean("Clear this column's filter"), + }), + &["column"], + ), + false, + ), + // ----- files ----- + tool( + "open_workbook", + "Open workbook", + "Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.", + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_workbook", + "Save workbook", + "Save the active workbook. The format follows the extension (.xlsx default; .csv/.tsv save the active sheet; .json; .html). \ + Without `path` saves to the workbook's current file.", + obj(json!({"path": string("Destination path")}), &[]), + false, + ), + tool( + "new_workbook", + "New workbook", + "Create a new workbook and make it active: blank, or a built-in sample (\"budget\", \"sales\", \"grades\").", + obj(json!({"sample": enumeration("Sample workbook", &["budget", "sales", "grades"])}), &[]), + false, + ), + tool( + "undo", + "Undo", + "Undo the last change(s) to the active workbook.", + obj(json!({"steps": int("Number of steps (default 1)")}), &[]), + false, + ), + tool("redo", "Redo", "Redo the last undone change(s).", obj(json!({"steps": int("Number of steps (default 1)")}), &[]), false), + tool( + "screenshot", + "Screenshot", + "Save a PNG screenshot of the SheetCraft window to `path`. Desktop app only (`sheetcraft-cli mcp --connect 7979`).", + obj(json!({"path": string("Output PNG path")}), &["path"]), + true, + ), + tool( + "list_functions", + "List functions", + "List worksheet functions: name, category, signature and description. Filter with `search` (name or description) or `category`.", + obj( + json!({"search": string("Substring of the name or description"), "category": string("Category, e.g. \"Math & Trig\", \"Lookup & Reference\"")}), + &[], + ), + true, + ), + ] +} + +// ---------- argument validation ---------- + +fn type_matches(t: &str, v: &Value) -> bool { + match t { + "string" => v.is_string(), + "number" => v.is_number(), + "integer" => v.is_u64() || v.is_i64(), + "boolean" => v.is_boolean(), + "array" => v.is_array(), + "object" => v.is_object(), + "null" => v.is_null(), + _ => true, + } +} + +/// Validates `args` against the tool's schema: unknown tool, missing required properties and +/// top-level type mismatches are JSON-RPC "invalid params" errors. +pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { + let defs = tool_definitions(); + let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?; + let schema = &def["inputSchema"]; + let Some(a) = args.as_object() else { return Err("tool arguments must be an object".into()) }; + for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { + if a.get(r).is_none_or(Value::is_null) { + return Err(format!("{name}: missing required argument `{r}`")); + } + } + let props = schema["properties"].as_object(); + for (k, v) in a { + let Some(p) = props.and_then(|p| p.get(k)) else { + return Err(format!("{name}: unknown argument `{k}`")); + }; + let ok = match &p["type"] { + Value::String(t) => type_matches(t, v), + Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)), + _ => true, + }; + if !ok { + return Err(format!("{name}: argument `{k}` must be of type {}", p["type"])); + } + if let Some(allowed) = p["enum"].as_array() + && !allowed.contains(v) + { + return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone()))); + } + } + Ok(()) +} + +// ---------- implementations ---------- + +type Args = Map; + +fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> { + a.get(k).and_then(Value::as_str).ok_or_else(|| format!("missing string argument `{k}`")) +} + +fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": cmd, "params": params})) +} + +/// Copies the listed keys (when present and non-null) into a params object. +fn pick(a: &Args, keys: &[&str]) -> Value { + Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect()) +} + +fn format_range(b: &mut dyn Backend, a: &Args) -> Result { + let range = req_str(a, "range")?; + let mut steps: Vec<(&str, Value)> = Vec::new(); + let r = json!(range); + if let Some(v) = a.get("bold").and_then(Value::as_bool) { + steps.push(("home.bold", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("italic").and_then(Value::as_bool) { + steps.push(("home.italic", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("underline").and_then(Value::as_bool) { + steps.push(("home.underline", json!({"range": r, "on": v}))); + } + if let Some(v) = a.get("fontName").and_then(Value::as_str) { + steps.push(("home.fontName", json!({"range": r, "name": v}))); + } + if let Some(v) = a.get("fontSize").and_then(Value::as_f64) { + steps.push(("home.fontSize", json!({"range": r, "size": v}))); + } + if let Some(v) = a.get("fontColor").and_then(Value::as_str) { + steps.push(("home.fontColor", json!({"range": r, "color": v}))); + } + if let Some(v) = a.get("fillColor").and_then(Value::as_str) { + steps.push(("home.fillColor", json!({"range": r, "color": v}))); + } + if let Some(v) = a.get("numberFormat").and_then(Value::as_str) { + steps.push(("home.numberFormat", json!({"range": r, "format": v}))); + } + if let Some(v) = a.get("horizontalAlign").and_then(Value::as_str) { + let id = match v { + "left" => "home.alignLeft", + "center" => "home.alignCenter", + "right" => "home.alignRight", + "justify" => "home.justify", + other => return Err(format!("horizontalAlign `{other}` must be left, center, right or justify")), + }; + steps.push((id, json!({"range": r}))); + } + if let Some(v) = a.get("wrapText").and_then(Value::as_bool) { + steps.push(("home.wrapText", json!({"range": r, "on": v}))); + } + match a.get("borders") { + None | Some(Value::Null) => {} + Some(Value::String(preset)) => steps.push(("home.borders", json!({"range": r, "preset": preset}))), + Some(Value::Object(o)) => { + let mut p = Value::Object(o.clone()); + p["range"] = r.clone(); + steps.push(("home.borders", p)); + } + Some(_) => return Err("`borders` must be a preset name or {preset, style?, color?}".into()), + } + if steps.is_empty() { + return Err("nothing to apply: give at least one of bold, italic, underline, fontName, fontSize, fontColor, fillColor, numberFormat, horizontalAlign, wrapText, borders".into()); + } + let mut applied = Vec::new(); + for (id, p) in steps { + exec(b, id, p).map_err(|e| format!("{id}: {e} (applied before the failure: {applied:?})"))?; + applied.push(id); + } + Ok(json!({"range": range, "applied": applied})) +} + +fn sort_range(b: &mut dyn Backend, a: &Args) -> Result { + let keys = a.get("keys").and_then(Value::as_array).ok_or("`keys` must be an array")?; + if keys.is_empty() { + return Err("`keys` needs at least one sort level".into()); + } + let mut norm = Vec::with_capacity(keys.len()); + for k in keys { + norm.push(match k { + Value::String(c) => { + // "B" or "B desc" + let mut it = c.split_whitespace(); + let col = it.next().unwrap_or_default(); + let order = match it.next().map(str::to_ascii_lowercase).as_deref() { + Some("desc" | "descending") => "desc", + _ => "asc", + }; + json!({"column": col, "order": order}) + } + Value::Object(o) => { + let mut o = o.clone(); + o.entry("order").or_insert(json!("asc")); + Value::Object(o) + } + _ => return Err("each sort key is a column letter or {column, order}".into()), + }); + } + let mut p = pick(a, &["range", "header"]); + p["keys"] = Value::Array(norm); + exec(b, "data.sort", p) +} + +fn filter(b: &mut dyn Backend, a: &Args) -> Result { + if let Some(r) = a.get("range").and_then(Value::as_str) { + exec(b, "selection.set", json!({"range": r}))?; + } + exec(b, "data.filterBy", pick(a, &["column", "values", "custom", "top", "blanks", "clear"])) +} + +fn save_workbook(b: &mut dyn Backend, a: &Args) -> Result { + if a.get("path").and_then(Value::as_str).is_none() { + let info = b.call("document.inspect", json!({}))?; + if info.get("path").is_none_or(Value::is_null) { + return Err("this workbook has never been saved: pass `path` (e.g. \"/tmp/book.xlsx\")".into()); + } + } + exec(b, "file.save", pick(a, &["path"])) +} + +fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { + if !b.has_ui() { + return Err(NO_UI.into()); + } + b.call("ui.screenshot", json!({"path": req_str(a, "path")?})) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { + match name { + "execute_command" => { + let cmd = req_str(a, "command")?; + let p = match a.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err("`params` must be an object".into()), + }; + exec(b, cmd, p) + } + "list_commands" => b.call("engine.commands", pick(a, &["search"])), + "inspect_workbook" => b.call("document.inspect", json!({})), + "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), + "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), + "set_cell" => exec(b, "cell.set", pick(a, &["cell", "input"])), + "get_cell" => exec(b, "cell.get", pick(a, &["cell"])), + "evaluate_formula" => { + let f = req_str(a, "formula")?; + let formula = if f.starts_with('=') { f.to_string() } else { format!("={f}") }; + let mut p = pick(a, &["cell"]); + p["formula"] = json!(formula); + let v = exec(b, "formulas.evaluate", p)?; + Ok(json!({"formula": formula, "result": v})) + } + "select" => exec(b, "selection.set", pick(a, &["range", "active"])), + "format_range" => format_range(b, a), + "insert_chart" => exec(b, "insert.chart", pick(a, &["range", "type", "subtype", "title", "at"])), + "create_table" => exec(b, "insert.table", pick(a, &["range", "style", "header", "name"])), + "sort_range" => sort_range(b, a), + "filter" => filter(b, a), + "open_workbook" => exec(b, "file.open", pick(a, &["path"])), + "save_workbook" => save_workbook(b, a), + "new_workbook" => exec(b, "file.new", pick(a, &["sample"])), + "undo" => exec(b, "edit.undo", pick(a, &["steps"])), + "redo" => exec(b, "edit.redo", pick(a, &["steps"])), + "screenshot" => screenshot(b, a), + "list_functions" => exec(b, "formulas.functions", pick(a, &["search", "category"])), + other => Err(format!("unknown tool `{other}`")), + } +} + +/// Runs one tool. Failures become `isError` results with a readable message. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { + let empty = Map::new(); + let a = match args { + Value::Object(o) => o, + Value::Null => &empty, + _ => return ToolResult::error("tool arguments must be a JSON object"), + }; + match dispatch(b, name, a) { + Ok(v) => ToolResult::json(&v), + Err(e) => ToolResult::error(format!("{name}: {e}")), + } +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b128b53ee..6c69dd667 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -114,28 +114,86 @@ pub enum CfValueKind { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum CfRule { /// Cell value compared with one or two formulas. - CellIs { op: CfOperator, a: String, b: Option, style: Box
{}