From b36fd771abf25a80b9e9ea7317926ae9dc2b1d1f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 13:31:53 -0400 Subject: [PATCH] M0.9: MCP server + figcraft-cli, PDF export, web app, release/CI packaging, vector edit mode, workspace lint cleanup - crates/mcp: JSON-RPC MCP server (18 tools, resources, prompts), headless and attached backends, end-to-end agent tests - apps/figcraft-cli: info/export/convert/render/run/commands/mcp/perf - crates/pdf: vector PDF export via krilla with embedded text; file.exportFramesPdf - apps/figcraft-web + packaging/{macos,windows,linux,freebsd,web} + release/ci workflows - engine vectoredit: edit points/handles/bend/add/delete-heal; canvas overlay and edit strip Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 3 + .github/workflows/ci.yml | 86 ++ .github/workflows/freebsd.yml | 61 ++ .github/workflows/packaging-lint.yml | 73 ++ .github/workflows/release.yml | 439 ++++++++++ .github/workflows/windows-arm64.yml | 98 +++ Cargo.lock | 2 - Cargo.toml | 3 + apps/figcraft-cli/Cargo.toml | 23 + apps/figcraft-cli/src/args.rs | 112 +++ apps/figcraft-cli/src/files.rs | 324 ++++++++ apps/figcraft-cli/src/main.rs | 173 ++++ apps/figcraft-cli/src/perf.rs | 172 ++++ apps/figcraft-cli/tests/cli.rs | 107 +++ apps/figcraft-web/Cargo.toml | 27 + apps/figcraft-web/Trunk.toml | 10 + apps/figcraft-web/index.html | 23 + apps/figcraft-web/src/main.rs | 25 + apps/figcraft-web/src/web.rs | 77 ++ apps/figcraft/src/main.rs | 15 +- apps/figcraft/src/services.rs | 11 +- clippy.toml | 1 + crates/color/src/lib.rs | 23 +- crates/doc/src/geometry.rs | 12 +- crates/doc/src/lib.rs | 11 +- crates/doc/src/node.rs | 9 +- crates/doc/src/props.rs | 19 +- crates/doc/src/proto.rs | 77 +- crates/doc/src/tests.rs | 39 +- crates/doc/src/text.rs | 8 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/arrange.rs | 26 +- crates/engine/src/cmd/component.rs | 44 +- crates/engine/src/cmd/create.rs | 30 +- crates/engine/src/cmd/edit.rs | 103 ++- crates/engine/src/cmd/file.rs | 111 ++- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/page.rs | 8 +- crates/engine/src/cmd/props.rs | 48 +- crates/engine/src/cmd/proto.rs | 23 +- crates/engine/src/cmd/structure.rs | 20 +- crates/engine/src/cmd/styles.rs | 111 ++- crates/engine/src/cmd/text.rs | 88 +- crates/engine/src/cmd/view.rs | 22 +- crates/engine/src/components.rs | 9 +- crates/engine/src/inspect.rs | 18 +- crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 91 ++- crates/engine/src/textedit.rs | 16 +- crates/engine/src/tools.rs | 241 +++++- crates/engine/src/vectoredit.rs | 481 +++++++++++ crates/format/src/lib.rs | 3 +- crates/layout/src/lib.rs | 10 +- crates/layout/src/tests.rs | 10 +- crates/mcp/Cargo.toml | 22 + crates/mcp/src/backend.rs | 175 ++++ crates/mcp/src/headless.rs | 309 +++++++ crates/mcp/src/lib.rs | 38 + crates/mcp/src/prompts.rs | 114 +++ crates/mcp/src/resources.rs | 55 ++ crates/mcp/src/server.rs | 207 +++++ crates/mcp/src/tools.rs | 763 ++++++++++++++++++ crates/mcp/tests/agent_tasks.rs | 290 +++++++ crates/mcp/tests/attached.rs | 72 ++ crates/mcp/tests/common/mod.rs | 88 ++ crates/mcp/tests/garbage.rs | 87 ++ crates/mcp/tests/protocol.rs | 174 ++++ crates/pathops/src/lib.rs | 3 +- crates/pdf/Cargo.toml | 24 + crates/pdf/src/draw.rs | 474 +++++++++++ crates/pdf/src/lib.rs | 143 ++++ crates/pdf/src/raster.rs | 227 ++++++ crates/pdf/src/tests.rs | 272 +++++++ crates/pdf/src/text.rs | 189 +++++ crates/render/src/draw.rs | 37 +- crates/render/src/fx.rs | 16 +- crates/render/src/images.rs | 11 +- crates/render/src/lib.rs | 23 +- crates/render/src/paint.rs | 25 +- crates/render/src/tests.rs | 7 +- crates/svg/src/import.rs | 25 +- crates/svg/src/lib.rs | 2 +- crates/text/src/layout.rs | 72 +- crates/ui-egui/src/canvas.rs | 101 ++- crates/ui-egui/src/colorpicker.rs | 87 +- crates/ui-egui/src/control.rs | 26 +- crates/ui-egui/src/dialogs.rs | 21 +- crates/ui-egui/src/home.rs | 4 +- crates/ui-egui/src/icons.rs | 27 +- crates/ui-egui/src/layers.rs | 50 +- crates/ui-egui/src/lib.rs | 16 +- crates/ui-egui/src/menus.rs | 219 +++-- crates/ui-egui/src/present.rs | 34 +- crates/ui-egui/src/right.rs | 207 ++++- crates/ui-egui/src/shortcuts.rs | 34 +- crates/ui-egui/src/tabs.rs | 7 +- crates/ui-egui/src/toolbar.rs | 81 +- crates/ui-egui/src/widgets.rs | 69 +- docs/cli.md | 89 ++ docs/control-protocol.md | 119 +++ docs/mcp.md | 151 ++++ docs/release-playbook.md | 11 + docs/releasing.md | 168 ++++ packaging/.shellcheckrc | 3 + packaging/env.sh | 93 +++ packaging/freebsd/package.sh | 104 +++ packaging/linux/AppRun | 72 ++ .../linux/ai.storyteller.figcraft.desktop | 14 + .../ai.storyteller.figcraft.metainfo.xml.in | 66 ++ .../linux/ai.storyteller.figcraft.mime.xml | 11 + packaging/linux/apprun-test.sh | 98 +++ packaging/linux/flatpak-bundle.sh | 74 ++ .../ai.storyteller.figcraft.bundle.yml | 51 ++ .../linux/flatpak/ai.storyteller.figcraft.yml | 73 ++ packaging/linux/nfpm.yaml | 81 ++ packaging/linux/package.sh | 150 ++++ packaging/linux/postinst.sh | 13 + packaging/macos/Info.plist.in | 113 +++ packaging/macos/entitlements.plist | 9 + packaging/macos/import-cert.sh | 50 ++ packaging/macos/package.sh | 188 +++++ packaging/macos/verify.sh | 153 ++++ packaging/web/.htaccess | 16 + packaging/web/README.md | 113 +++ packaging/web/_headers | 13 + packaging/web/package.sh | 56 ++ packaging/windows/check-icons.ps1 | 46 ++ packaging/windows/figcraft.en-us.wxl | 11 + packaging/windows/figcraft.wxs | 126 +++ packaging/windows/package.ps1 | 188 +++++ packaging/windows/sign.ps1 | 95 +++ xtask/src/icon.rs | 17 +- xtask/src/main.rs | 25 +- 133 files changed, 10620 insertions(+), 546 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/freebsd.yml create mode 100644 .github/workflows/packaging-lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/windows-arm64.yml create mode 100644 apps/figcraft-cli/Cargo.toml create mode 100644 apps/figcraft-cli/src/args.rs create mode 100644 apps/figcraft-cli/src/files.rs create mode 100644 apps/figcraft-cli/src/main.rs create mode 100644 apps/figcraft-cli/src/perf.rs create mode 100644 apps/figcraft-cli/tests/cli.rs create mode 100644 apps/figcraft-web/Cargo.toml create mode 100644 apps/figcraft-web/Trunk.toml create mode 100644 apps/figcraft-web/index.html create mode 100644 apps/figcraft-web/src/main.rs create mode 100644 apps/figcraft-web/src/web.rs create mode 100644 crates/engine/src/vectoredit.rs create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/backend.rs create mode 100644 crates/mcp/src/headless.rs create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/prompts.rs create mode 100644 crates/mcp/src/resources.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/mcp/src/tools.rs create mode 100644 crates/mcp/tests/agent_tasks.rs create mode 100644 crates/mcp/tests/attached.rs create mode 100644 crates/mcp/tests/common/mod.rs create mode 100644 crates/mcp/tests/garbage.rs create mode 100644 crates/mcp/tests/protocol.rs create mode 100644 crates/pdf/Cargo.toml create mode 100644 crates/pdf/src/draw.rs create mode 100644 crates/pdf/src/lib.rs create mode 100644 crates/pdf/src/raster.rs create mode 100644 crates/pdf/src/tests.rs create mode 100644 crates/pdf/src/text.rs create mode 100644 docs/cli.md create mode 100644 docs/control-protocol.md create mode 100644 docs/mcp.md create mode 100644 docs/release-playbook.md create mode 100644 docs/releasing.md create mode 100644 packaging/.shellcheckrc create mode 100644 packaging/env.sh create mode 100755 packaging/freebsd/package.sh create mode 100755 packaging/linux/AppRun create mode 100644 packaging/linux/ai.storyteller.figcraft.desktop create mode 100644 packaging/linux/ai.storyteller.figcraft.metainfo.xml.in create mode 100644 packaging/linux/ai.storyteller.figcraft.mime.xml create mode 100755 packaging/linux/apprun-test.sh create mode 100755 packaging/linux/flatpak-bundle.sh create mode 100644 packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml create mode 100644 packaging/linux/flatpak/ai.storyteller.figcraft.yml create mode 100644 packaging/linux/nfpm.yaml create mode 100755 packaging/linux/package.sh create mode 100755 packaging/linux/postinst.sh create mode 100644 packaging/macos/Info.plist.in create mode 100644 packaging/macos/entitlements.plist create mode 100755 packaging/macos/import-cert.sh create mode 100755 packaging/macos/package.sh create mode 100755 packaging/macos/verify.sh create mode 100644 packaging/web/.htaccess create mode 100644 packaging/web/README.md create mode 100644 packaging/web/_headers create mode 100755 packaging/web/package.sh create mode 100644 packaging/windows/check-icons.ps1 create mode 100644 packaging/windows/figcraft.en-us.wxl create mode 100644 packaging/windows/figcraft.wxs create mode 100644 packaging/windows/package.ps1 create mode 100644 packaging/windows/sign.ps1 diff --git a/.cargo/config.toml b/.cargo/config.toml index 541c8523..acebfe9f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,3 +2,6 @@ # directory is missing the build warns and carries on with egui's bundled fonts and system fonts. [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } + +[alias] +xtask = "run -q -p xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7edccb16 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and +# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only +# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md), +# and Windows/FreeBSD packaging have their own workflows. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the + # debug info: faster codegen and links, and a smaller target dir. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + +# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every +# main state that gets a run is checked to the end. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + name: test (ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux system dependencies (egui/winit/rfd) + 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 libgtk-3-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + # Only main saves caches; PRs restore main's, so PR caches don't evict main's. + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + # Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at + # a sibling checkout that CI doesn't have): everything must build and pass without it. + - name: Test + run: cargo test --workspace --locked + + - name: Dependency layering + run: cargo run --locked -q -p xtask -- layers + + - name: Asset attribution + run: cargo run --locked -q -p xtask -- assets + + wasm: + name: wasm32 check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: wasm32 + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + # The engine, the UI crate and apps/figcraft-web must keep compiling for the browser. + - name: Check wasm-safe crates + run: cargo run --locked -q -p xtask -- wasm diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 00000000..fdacbe59 --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -0,0 +1,61 @@ +# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner +# (GitHub has no FreeBSD runners). +# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the +# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is +# uploaded and no secrets are used. +name: FreeBSD + +# The VM build takes one to several hours, so it runs nightly on main rather than on every push +# or dependency change: a drifted dependency still shows up within a day. PRs that change the +# FreeBSD packaging itself still run it. +on: + schedule: + - cron: "17 2 * * *" + pull_request: + paths: + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + workflow_dispatch: + +concurrency: + group: freebsd-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + freebsd: + name: FreeBSD 14 (build + test + package) + runs-on: ubuntu-latest + timeout-minutes: 300 + steps: + - uses: actions/checkout@v4 + - name: Build, test and package in a FreeBSD VM + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + # Nothing to bring back; copying the target dir out of the VM fails on large builds. + copyback: false + 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: | + set -e + . "$HOME/.cargo/env" + # The checkout is synced in under another owner; let tests that call git read it. + git config --global --add safe.directory "*" + rustc --version + CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked + CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # The release package, as release.yml builds it. Free the debug build's disk first. + rm -rf target/debug + CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh + tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" + for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do + grep -E "$f" "$HOME/freebsd-contents.txt" + done diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml new file mode 100644 index 00000000..4ac82ad3 --- /dev/null +++ b/.github/workflows/packaging-lint.yml @@ -0,0 +1,73 @@ +# 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 packaging/linux/AppRun + - name: AppImage AppRun desktop integration + run: bash packaging/linux/apprun-test.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)" + } + ./packaging/windows/check-icons.ps1 + - name: FreeBSD package layout (dry run, no build) + run: bash packaging/freebsd/package.sh --dry-run + - name: No template leftovers from other apps in package metadata + run: | + if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then + echo "::error::package metadata mentions another product (see above)" + exit 1 + fi + - name: XML, desktop entry, AppStream, Flatpak manifest + run: | + xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml* + desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop + sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" + appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" + python3 - <<'EOF' + import yaml + src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml")) + bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml")) + for m in (src, bundle): + assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"] + for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + print("Flatpak manifests ok") + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..16ce694e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,439 @@ +# Release pipeline: every push to `release` builds signed installers for every platform and +# creates or updates the draft GitHub Release "FigCraft v". See docs/releasing.md. +# +# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the +# `release` environment (restricted to the `release` branch), which holds the signing secrets; the +# rest build on any branch, so `gh workflow run release.yml --ref ` is a real dry run. +# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed +# build. Playbook: storytold/craftrules release/playbook.md. +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 + FIGCRAFT_BUILD_SHA: ${{ github.sha }} + INPUT_VERSION: ${{ inputs.version }} + # craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release + # embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build + # embeds Inter only, see crates/text/build.rs). An optional build input for everyone else. + # crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump + # deliberately; every build job checks out this commit. + CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + +jobs: + version: + name: version + runs-on: ubuntu-latest + # No `environment: release`: this job reads no secrets, so a branch dry run can run it. + 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 "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" + + macos: + name: macOS universal + needs: version + runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) + environment: release + env: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts (embedded fonts) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + 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 --locked -q -p xtask -- version set "$FIGCRAFT_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: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + run: packaging/macos/package.sh --arch universal + # Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime, + # timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's + # stapled ticket. Strict when the signing secrets are present, warnings otherwise. + - name: Verify signatures and notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packaging/macos/verify.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: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts (embedded fonts) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + 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 --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" + - name: Install WiX + shell: pwsh + run: | + dotnet tool install --global wix --version 5.0.2 + wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + - name: Build, sign, package + shell: pwsh + env: + 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 }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + 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 }} + # No `environment: release`: this job reads no secrets, so a branch dry run can run it. + 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: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + NFPM_VERSION: 2.47.0 + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts (embedded fonts) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + - name: Install build and packaging tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ + desktop-file-utils appstream python3-yaml zsync + curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" + sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-${{ matrix.arch }} + - name: Apply version override + if: inputs.version != '' + run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_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/figcraft$|metainfo|applications' + ldd target/release/figcraft + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'" + for appimage in dist/release/*.AppImage; do + chmod +x "$appimage" + APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + # Update information is embedded, and the .zsync for delta updates sits beside it. + "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' + test -s "$appimage.zsync" + done + - uses: actions/upload-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/release/* + if-no-files-found: error + + flatpak: + name: Flatpak ${{ matrix.arch }} + needs: [version, linux] + # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust + # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. + runs-on: ${{ matrix.runner }} + # No `environment: release`: this job signs nothing, so it gets no release secrets. + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install flatpak-builder + run: | + sudo apt-get update + # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's + # `appstreamcli compose`, which renders the scalable icon and fails without it. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Build the Flatpak bundle + run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" + - uses: actions/upload-artifact@v4 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + if-no-files-found: error + + freebsd: + name: FreeBSD x86_64 + needs: version + # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as + # .github/workflows/freebsd.yml. + runs-on: ubuntu-latest + timeout-minutes: 180 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts (embedded fonts) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + - name: Build and package in a FreeBSD VM (tar.gz) + # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + with: + release: "14.3" + usesh: true + cpu: 4 + mem: 12288 + # Passed into the VM by name; never templated into the script below. + envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION" + # The FreeBSD CI packages, plus bash for the packaging script. + 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 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable + run: | + set -e + . "$HOME/.cargo/env" + git config --global --add safe.directory "$PWD" + # Build outside the checkout so only dist/ is copied back out of the VM (copying the + # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. + export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4 + if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi + # craft-fonts was checked out next to the sources and synced into the VM with them. + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 + bash packaging/freebsd/package.sh + tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" + for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do + grep -E "$f" "$HOME/freebsd-contents.txt" + done + - uses: actions/upload-artifact@v4 + with: + name: release-freebsd-x86_64 + path: dist/release/* + if-no-files-found: error + + web: + name: Web (wasm) + needs: version + runs-on: ubuntu-latest + # No `environment: release`: this job reads no secrets, so a branch dry run can run it. + env: + FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} + FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + TRUNK_VERSION: 0.21.14 + steps: + - uses: actions/checkout@v4 + - 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 --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" + # The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have + # no system fonts to fall back on, and the rest would push the wasm towards + # packaging/web/package.sh's 24 MiB gate. + - name: Check out craft-fonts (embedded fonts) + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + - 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 + + release: + name: Draft GitHub Release + needs: [version, macos, windows, linux, flatpak, freebsd, web] + 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="FigCraft 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 run -p xtask -- version set X.Y.Z) 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 00000000..484a06ef --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,98 @@ +# 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: PdfCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that +# change the Windows packaging itself. +on: + pull_request: + paths: + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * *" + 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 + wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/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: figcraft-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: figcraft-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 'FigCraft' + # figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed. + foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { + if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue } + 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 binary runs natively on ARM64 (the CLI when present, else the app's --version). + $cli = Join-Path $dir 'figcraft-cli.exe' + if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' } + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" } + Write-Output "ok $(Split-Path -Leaf $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 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/Cargo.lock b/Cargo.lock index 2d634fe8..46d13e51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1380,10 +1380,8 @@ dependencies = [ "base64 0.22.1", "figcraft-doc", "figcraft-engine", - "figcraft-format", "figcraft-geom", "figcraft-render", - "figcraft-svg", "image", "log", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 3176df52..afcdfb4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,9 @@ unsafe_code = "forbid" [workspace.lints.clippy] dbg_macro = "warn" +# Style lints from newer toolchains that fight index-heavy pixel code. +needless_range_loop = "allow" +chunks_exact_to_as_chunks = "allow" # FigCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see # AGENTS.md › Never crash). Tests may unwrap (clippy.toml). unwrap_used = "deny" diff --git a/apps/figcraft-cli/Cargo.toml b/apps/figcraft-cli/Cargo.toml new file mode 100644 index 00000000..07ecf84b --- /dev/null +++ b/apps/figcraft-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "figcraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "FigCraft command line: info, export, convert, render, scripted commands, MCP server, perf" + +[[bin]] +name = "figcraft-cli" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +figcraft-engine = { workspace = true } +figcraft-doc = { workspace = true } +figcraft-render = { workspace = true } +figcraft-svg = { workspace = true } +figcraft-format = { workspace = true } +figcraft-mcp = { workspace = true } +serde_json = { workspace = true } diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs new file mode 100644 index 00000000..b497dd21 --- /dev/null +++ b/apps/figcraft-cli/src/args.rs @@ -0,0 +1,112 @@ +//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` +//! options, and boolean `--flag`s. Unknown options are usage errors. + +use std::collections::BTreeMap; + +use crate::{CliError, CliResult}; + +#[derive(Debug, Default)] +pub struct Args { + pub positional: Vec, + values: BTreeMap, + flags: Vec, +} + +impl Args { + pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let mut a = Args::default(); + let mut it = argv.iter(); + while let Some(arg) = it.next() { + let name = if let Some(n) = arg.strip_prefix("--") { + n + } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { + n + } else { + a.positional.push(arg.clone()); + continue; + }; + let (name, inline) = match name.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (name, None), + }; + if valued.contains(&name) { + let v = match inline { + Some(v) => v, + None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, + }; + a.values.insert(name.to_string(), v); + } else if flags.contains(&name) && inline.is_none() { + a.flags.push(name.to_string()); + } else { + return Err(CliError::Usage(format!("unknown option `{arg}`"))); + } + } + Ok(a) + } + + pub fn value(&self, k: &str) -> Option { + self.values.get(k).cloned() + } + + pub fn flag(&self, k: &str) -> bool { + self.flags.iter().any(|f| f == k) + } + + pub fn one_positional(&self, usage: &str) -> CliResult { + match self.positional.as_slice() { + [one] => Ok(one.clone()), + _ => Err(CliError::Usage(format!("expected: {usage}"))), + } + } + + pub fn scale(&self) -> CliResult { + match self.values.get("scale") { + None => Ok(1.0), + Some(s) => s + .trim_end_matches('x') + .parse::() + .ok() + .filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0) + .ok_or_else(|| CliError::Usage(format!("--scale must be a number in (0, 64], got `{s}`"))), + } + } + + pub fn usize(&self, k: &str, default: usize) -> CliResult { + match self.values.get(k) { + None => Ok(default), + Some(s) => s.replace('_', "").parse::().map_err(|_| CliError::Usage(format!("--{k} must be a whole number, got `{s}`"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn parses_options_and_flags() { + let a = Args::parse(&v(&["in.figcraft", "-o", "out", "--scale=2", "--json"]), &["o", "scale"], &["json"]).unwrap(); + assert_eq!(a.positional, ["in.figcraft"]); + assert_eq!(a.value("o").as_deref(), Some("out")); + assert_eq!(a.scale().unwrap(), 2.0); + assert!(a.flag("json")); + } + + #[test] + fn rejects_bad_input() { + assert!(matches!(Args::parse(&v(&["--nope"]), &[], &[]), Err(CliError::Usage(_)))); + assert!(matches!(Args::parse(&v(&["-o"]), &["o"], &[]), Err(CliError::Usage(_)))); + assert!(matches!(Args::parse(&v(&["--json=1"]), &[], &["json"]), Err(CliError::Usage(_)))); + let a = Args::parse(&v(&["--scale", "abc"]), &["scale"], &[]).unwrap(); + assert!(a.scale().is_err()); + let a = Args::parse(&v(&["--scale", "-3"]), &["scale"], &[]).unwrap(); + assert!(a.scale().is_err()); + // Negative numbers are positionals, not options. + let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap(); + assert_eq!(a.positional, ["-5"]); + } +} diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs new file mode 100644 index 00000000..4b93778f --- /dev/null +++ b/apps/figcraft-cli/src/files.rs @@ -0,0 +1,324 @@ +//! The file subcommands: info, export, convert, render and run. + +use std::path::Path; +use std::process::ExitCode; + +use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::{CliError, CliResult}; + +pub fn load(path: &str) -> CliResult { + figcraft_engine::cmd::file::load(path).map(|(d, _)| d).map_err(|e| CliError::Failed(format!("can't open {path}: {e}"))) +} + +fn ext(path: &str) -> String { + Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() +} + +fn image_format(e: &str) -> Option { + match e { + "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e), + _ => None, + } +} + +fn write(path: &str, bytes: &[u8]) -> CliResult<()> { + if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?; + } + std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) +} + +fn page_named(doc: &Document, name: Option<&str>) -> CliResult { + match name { + None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())), + Some(n) => doc + .pages + .iter() + .copied() + .find(|p| doc.node(*p).is_some_and(|pn| pn.name == n)) + .or_else(|| doc.pages.iter().copied().find(|p| doc.node(*p).is_some_and(|pn| pn.name.eq_ignore_ascii_case(n)))) + .ok_or_else(|| { + let names: Vec = doc.pages.iter().filter_map(|p| doc.node(*p).map(|n| n.name.clone())).collect(); + CliError::Failed(format!("no page named `{n}` (pages: {})", names.join(", "))) + }), + } +} + +fn find_node(doc: &Document, page: NodeId, q: &str) -> CliResult { + if let Ok(id) = q.parse::() + && doc.contains(id) + { + return Ok(id); + } + doc.find_by_name(q, Some(page)) + .into_iter() + .find(|i| doc.node(*i).is_some_and(|n| n.name == q)) + .or_else(|| doc.find_by_name(q, None).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.name == q))) + .or_else(|| doc.find_by_name(q, Some(page)).into_iter().next()) + .ok_or_else(|| CliError::Failed(format!("no layer with id or name `{q}`"))) +} + +fn children(doc: &Document, page: NodeId) -> Vec { + doc.node(page).map(|p| p.children.clone()).unwrap_or_default() +} + +/// Render a page's content (with the page background) at `scale`, shrinking to fit 16384 px. +pub fn render_page(doc: &Document, page: NodeId, scale: f64) -> CliResult { + let b = doc.page_content_bounds(page).ok_or_else(|| CliError::Failed("the page is empty: nothing to render".into()))?; + let side = b.width().max(b.height()).max(1e-6); + let scale = scale.min(16_384.0 / side); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or_else(|| CliError::Failed("render failed".into())) +} + +fn encode(pix: &figcraft_render::Pixmap, e: &str) -> CliResult> { + match e { + "jpg" | "jpeg" => figcraft_render::encode_jpeg(pix, figcraft_doc::color::Color::WHITE, 90), + "webp" => figcraft_render::encode_webp(pix), + _ => figcraft_render::encode_png(pix), + } + .map_err(CliError::Failed) +} + +/// Write `doc` to `out`, choosing the format by extension. +pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> { + let e = ext(out); + let bytes = match e.as_str() { + "figcraft" => { + let thumb = figcraft_engine::cmd::file::thumbnail(doc); + figcraft_format::save_bytes(doc, thumb.as_deref()).map_err(|e| CliError::Failed(e.to_string()))? + } + "json" => { + let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?; + serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))? + } + "svg" => figcraft_svg::export_svg(doc, &children(doc, page), &Default::default()) + .ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))? + .into_bytes(), + "png" | "jpg" | "jpeg" | "webp" => encode(&render_page(doc, page, 1.0)?, &e)?, + "pdf" => figcraft_engine::cmd::file::export_bytes(doc, &children(doc, page), ExportFormat::Pdf, 1.0) + .map_err(|e| CliError::Failed(e.to_string()))?, + other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))), + }; + write(out, &bytes) +} + +pub fn info(path: &str, as_json: bool) -> CliResult { + let doc = load(path)?; + let pages: Vec = doc + .pages + .iter() + .filter_map(|p| { + let pn = doc.node(*p)?; + let nodes = doc.subtree(*p).len().saturating_sub(1); + let frames: Vec = pn + .children + .iter() + .filter_map(|c| doc.node(*c)) + .filter(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + .map(|n| json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "width": n.width, "height": n.height})) + .collect(); + Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) + }) + .collect(); + let v = json!({ + "file": path, + "name": doc.name, + "nodes": doc.node_count(), + "pages": pages, + "styles": doc.styles.len(), + "variables": doc.variables.variables.len(), + "images": doc.images.len(), + }); + if as_json { + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + return Ok(ExitCode::SUCCESS); + } + println!( + "{} — {} ({} nodes, {} styles, {} variables, {} images)", + path, + doc.name, + doc.node_count(), + doc.styles.len(), + doc.variables.variables.len(), + doc.images.len() + ); + for p in &pages { + println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]); + for f in p["frames"].as_array().into_iter().flatten() { + println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); + } + } + Ok(ExitCode::SUCCESS) +} + +fn sanitize(name: &str) -> String { + let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); + let s = s.trim().trim_matches('.').to_string(); + if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } +} + +pub fn export(file: &str, out: &str, format: Option<&str>, scale: f64, page: Option<&str>, node: Option<&str>) -> CliResult { + let doc = load(file)?; + let page = page_named(&doc, page)?; + let fmt = match format { + Some(f) => image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf)")))?, + None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), + }; + let target = node.map(|q| find_node(&doc, page, q)).transpose()?; + let single = image_format(&ext(out)).is_some(); + let bytes_of = |ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes(&doc, ids, fmt, scale).map_err(|e| CliError::Failed(e.to_string())); + if single { + let ids = match target { + Some(t) => vec![t], + None => children(&doc, page), + }; + write(out, &bytes_of(&ids)?)?; + println!("{out}"); + return Ok(ExitCode::SUCCESS); + } + std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?; + let ids = match target { + Some(t) => vec![t], + None => { + let kids = children(&doc, page); + let frames: Vec = kids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())).collect(); + if frames.is_empty() { kids } else { frames } + } + }; + if ids.is_empty() { + return Err(CliError::Failed("the page is empty: nothing to export".into())); + } + let mut used = std::collections::BTreeSet::new(); + for id in ids { + let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer")); + let mut name = base.clone(); + let mut k = 2; + while !used.insert(name.to_lowercase()) { + name = format!("{base} {k}"); + k += 1; + } + let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned(); + write(&path, &bytes_of(&[id])?)?; + println!("{path}"); + } + Ok(ExitCode::SUCCESS) +} + +pub fn convert(input: &str, out: &str) -> CliResult { + let doc = load(input)?; + let page = doc + .current_page + .filter(|p| doc.pages.contains(p)) + .or_else(|| doc.first_page()) + .ok_or_else(|| CliError::Failed("the document has no pages".into()))?; + write_doc(&doc, page, out)?; + println!("{out}"); + Ok(ExitCode::SUCCESS) +} + +pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResult { + let doc = load(file)?; + let page = page_named(&doc, page)?; + let e = ext(out); + if !matches!(e.as_str(), "png" | "jpg" | "jpeg" | "webp") { + return Err(CliError::Usage(format!("render writes .png, .jpg or .webp, not `{out}`"))); + } + let pix = render_page(&doc, page, scale)?; + write(out, &encode(&pix, &e)?)?; + println!("{out} ({}×{})", pix.width(), pix.height()); + Ok(ExitCode::SUCCESS) +} + +/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. +pub fn parse_script(text: &str) -> Result, String> { + let steps: Vec = match serde_json::from_str::(text) { + Ok(Value::Array(a)) => a, + Ok(Value::Object(o)) if o.contains_key("commands") => { + o.get("commands").and_then(Value::as_array).cloned().ok_or("`commands` must be an array")? + } + Ok(v @ Value::Object(_)) => vec![v], + Ok(_) => return Err("a script is an array of {\"command\", \"params\"} objects".into()), + Err(_) => text + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with("//")) + .map(|(i, l)| serde_json::from_str::(l).map_err(|e| format!("line {}: {e}", i + 1))) + .collect::>()?, + }; + steps + .into_iter() + .enumerate() + .map(|(i, s)| { + let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?; + let params = match s.get("params") { + None | Some(Value::Null) => json!({}), + Some(p @ Value::Object(_)) => p.clone(), + Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), + }; + Ok((cmd.to_string(), params)) + }) + .collect() +} + +pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { + let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let mut s = match input { + Some(path) => { + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?; + s + } + None => Session::new(), + }; + for (i, (cmd, params)) in steps.iter().enumerate() { + let r = s.execute(cmd, params); + s.ui_requests.clear(); + match r { + Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), + Err(e) => { + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + return Ok(ExitCode::from(1)); + } + } + } + if let Some(out) = out { + let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; + write_doc(&ds.doc, ds.page, out)?; + eprintln!("wrote {out}"); + } + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_formats() { + assert_eq!(parse_script(r#"[{"command":"a"},{"command":"b","params":{"x":1}}]"#).unwrap().len(), 2); + assert_eq!(parse_script(r#"{"commands":[{"command":"a"}]}"#).unwrap().len(), 1); + assert_eq!(parse_script("{\"command\":\"a\"}\n\n// note\n{\"command\":\"b\"}\n").unwrap().len(), 2); + assert!(parse_script("[{\"params\":{}}]").is_err()); + assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err()); + assert!(parse_script("not json\n").is_err()); + assert!(parse_script("42").is_err()); + } + + #[test] + fn sanitizes_names() { + assert_eq!(sanitize("Home / Hero"), "Home _ Hero"); + assert_eq!(sanitize("../.."), "_"); + assert_eq!(sanitize(""), "layer"); + } +} diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs new file mode 100644 index 00000000..bcc96f72 --- /dev/null +++ b/apps/figcraft-cli/src/main.rs @@ -0,0 +1,173 @@ +//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run +//! command scripts, list commands, serve MCP on stdio and measure performance. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod args; +mod files; +mod perf; + +use std::io::Write; +use std::process::ExitCode; + +use args::Args; +use serde_json::{Value, json}; + +pub const USAGE: &str = "figcraft-cli — FigCraft without a window + +USAGE: + figcraft-cli info [--json] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] + figcraft-cli convert + figcraft-cli render -o [--scale N] [--page NAME] + figcraft-cli run [--in ] [--out ] + figcraft-cli commands [--json] [--filter TEXT] + figcraft-cli mcp [--attach PORT] + figcraft-cli perf [--shapes N] [--frames N] + figcraft-cli help | --help | --version + +Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image +layer). `export` writes one file per top-level frame into the directory, or one file when +has an image extension or --node is given. `run` executes a JSON array (or JSON lines) of +{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. +See docs/cli.md and docs/mcp.md."; + +fn main() -> ExitCode { + let argv: Vec = std::env::args().skip(1).collect(); + match run(&argv) { + Ok(code) => code, + Err(CliError::Usage(msg)) => { + eprintln!("error: {msg}\n\n{USAGE}"); + ExitCode::from(2) + } + Err(CliError::Failed(msg)) => { + eprintln!("error: {msg}"); + ExitCode::from(1) + } + } +} + +#[derive(Debug)] +pub enum CliError { + /// Bad arguments: usage is printed, exit code 2. + Usage(String), + /// The operation failed: exit code 1. + Failed(String), +} + +impl From for CliError { + fn from(s: String) -> Self { + CliError::Failed(s) + } +} + +pub type CliResult = Result; + +fn run(argv: &[String]) -> CliResult { + let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + match cmd.as_str() { + "help" | "--help" | "-h" => { + println!("{USAGE}"); + Ok(ExitCode::SUCCESS) + } + "--version" | "-V" | "version" => { + println!("figcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(ExitCode::SUCCESS) + } + "info" => { + let a = Args::parse(rest, &[], &["json"])?; + let file = a.one_positional("info ")?; + files::info(&file, a.flag("json")) + } + "export" => { + let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out"], &[])?; + let file = a.one_positional("export -o ")?; + let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; + files::export(&file, &out, a.value("format").as_deref(), a.scale()?, a.value("page").as_deref(), a.value("node").as_deref()) + } + "convert" => { + let a = Args::parse(rest, &[], &[])?; + match a.positional.as_slice() { + [i, o] => files::convert(i, o), + _ => Err(CliError::Usage("convert ".into())), + } + } + "render" => { + let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?; + let file = a.one_positional("render -o ")?; + let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; + files::render(&file, &out, a.scale()?, a.value("page").as_deref()) + } + "run" => { + let a = Args::parse(rest, &["in", "out"], &[])?; + let script = a.one_positional("run ")?; + files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()) + } + "commands" => { + let a = Args::parse(rest, &["filter"], &["json"])?; + if !a.positional.is_empty() { + return Err(CliError::Usage("commands takes no positional arguments".into())); + } + commands(a.flag("json"), a.value("filter").as_deref()); + Ok(ExitCode::SUCCESS) + } + "mcp" => { + let a = Args::parse(rest, &["attach"], &[])?; + if !a.positional.is_empty() { + return Err(CliError::Usage("mcp takes no positional arguments".into())); + } + mcp(a.value("attach").as_deref()) + } + "perf" => { + let a = Args::parse(rest, &["shapes", "frames"], &[])?; + let shapes = a.usize("shapes", 20_000)?.clamp(1, 2_000_000); + let frames = a.usize("frames", 200)?.clamp(1, 100_000); + perf::run(shapes, frames) + } + other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), + } +} + +fn commands(as_json: bool, filter: Option<&str>) { + let f = filter.map(str::to_lowercase).unwrap_or_default(); + let list: Vec<&figcraft_engine::CommandSpec> = figcraft_engine::all_commands() + .iter() + .filter(|c| f.is_empty() || c.id.to_lowercase().contains(&f) || c.label.to_lowercase().contains(&f) || c.params.to_lowercase().contains(&f)) + .collect(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + if as_json { + let v: Vec = list + .iter() + .map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})) + .collect(); + let _ = writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + return; + } + for c in list { + let sc = c.shortcut().map(|s| format!(" ({s})")).unwrap_or_default(); + let undo = if c.history { "*" } else { "" }; + let _ = writeln!(out, "{}{undo} {}{sc}\n {}", c.id, c.label, c.params); + } +} + +fn mcp(attach: Option<&str>) -> CliResult { + let backend: Box = match attach { + Some(p) => { + let addr = if p.contains(':') { + p.to_string() + } else { + format!("127.0.0.1:{}", p.parse::().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?) + }; + let b = figcraft_mcp::Attached::connect(&addr) + .map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?; + Box::new(b) + } + None => Box::new(figcraft_mcp::Headless::new()), + }; + eprintln!("figcraft MCP server ({}) on stdio", backend.describe()); + let mut server = figcraft_mcp::Server::new(backend); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?; + Ok(ExitCode::SUCCESS) +} diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs new file mode 100644 index 00000000..874660cd --- /dev/null +++ b/apps/figcraft-cli/src/perf.rs @@ -0,0 +1,172 @@ +//! `figcraft-cli perf`: a synthetic large document and timings of the hot paths. + +use std::process::ExitCode; +use std::time::Instant; + +use figcraft_doc::color::{Color, Paint}; +use figcraft_doc::geom::Point; +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::json; + +use crate::{CliError, CliResult}; + +const FRAME_W: f64 = 400.0; +const FRAME_H: f64 = 300.0; +const GAP: f64 = 100.0; + +/// `shapes` layers spread over `frames` frames in a grid; every tenth layer is text. +pub fn build(shapes: usize, frames: usize) -> CliResult<(Document, NodeId)> { + let mut doc = Document::new("Perf"); + let page = doc.first_page().ok_or_else(|| CliError::Failed("new document has no page".into()))?; + let cols = (frames as f64).sqrt().ceil().max(1.0) as usize * 2; + let per = shapes.div_ceil(frames).max(1); + let grid = (per as f64).sqrt().ceil().max(1.0) as usize; + let (cw, ch) = (FRAME_W / grid as f64, FRAME_H / grid as f64); + let mut made = 0usize; + let err = |e: figcraft_doc::DocError| CliError::Failed(e.to_string()); + for f in 0..frames { + let (fx, fy) = ((f % cols) as f64 * (FRAME_W + GAP), (f / cols) as f64 * (FRAME_H + GAP)); + let frame = doc.create(page, NodeKind::Frame, &format!("Frame {}", f + 1), fx, fy, FRAME_W, FRAME_H).map_err(err)?; + if let Some(n) = doc.node_mut(frame) { + n.fills = vec![Paint::solid(Color::WHITE)]; + } + for k in 0..per { + if made >= shapes { + break; + } + let (x, y) = ((k % grid) as f64 * cw + 2.0, (k / grid) as f64 * ch + 2.0); + let hue = (made % 360) as f32 / 360.0; + let (kind, name) = match made % 10 { + 0 => { + let (mut kind, _) = figcraft_engine::cmd::create::kind_for("text").ok_or_else(|| CliError::Failed("no text kind".into()))?; + if let NodeKind::Text(t) = &mut kind { + t.characters = format!("Item {made}"); + } + (kind, "Text") + } + 1 | 4 | 7 => (figcraft_engine::cmd::create::kind_for("ellipse").map(|k| k.0).unwrap_or(NodeKind::Rectangle), "Ellipse"), + _ => (NodeKind::Rectangle, "Rectangle"), + }; + let id = doc.create(frame, kind, name, x, y, (cw - 4.0).max(1.0), (ch - 4.0).max(1.0)).map_err(err)?; + if let Some(n) = doc.node_mut(id) + && name != "Text" + { + n.fills = vec![Paint::solid(Color::new(hue, 0.5, 1.0 - hue, 1.0))]; + n.corners.radii = [4.0; 4]; + } + made += 1; + } + } + Ok((doc, page)) +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +pub fn run(shapes: usize, frames: usize) -> CliResult { + let mut rows: Vec<(String, f64, String)> = Vec::new(); + + let t = Instant::now(); + let (doc, page) = build(shapes, frames)?; + rows.push(("build document (doc API)".into(), ms(t), format!("{} nodes", doc.node_count()))); + + let bounds = doc.page_content_bounds(page).ok_or_else(|| CliError::Failed("empty page".into()))?; + let t = Instant::now(); + let full = crate::files::render_page(&doc, page, 1.0)?; + rows.push(("render full page @1x".into(), ms(t), format!("{}×{} px", full.width(), full.height()))); + drop(full); + + let view = figcraft_render::View { origin: (bounds.x0 + 200.0, bounds.y0 + 150.0), scale: 2.0, width: 1920, height: 1080 }; + let t = Instant::now(); + let mut n = 0; + for _ in 0..5 { + if figcraft_render::render_view(&doc, page, &view, &Default::default()).is_some() { + n += 1; + } + } + rows.push(("render 1920×1080 view @2x (avg of 5)".into(), ms(t) / f64::from(n.max(1)), "zoomed into the first frames".into())); + + let fit = figcraft_render::View { + origin: (bounds.x0, bounds.y0), + scale: (1920.0 / bounds.width()).min(1080.0 / bounds.height()), + width: 1920, + height: 1080, + }; + let t = Instant::now(); + let _ = figcraft_render::render_view(&doc, page, &fit, &Default::default()); + rows.push(("render 1920×1080 zoom-to-fit".into(), ms(t), format!("scale {:.3}", fit.scale))); + + let hits = 10_000; + let mut found = 0; + let t = Instant::now(); + for i in 0..hits { + let fx = (i * 7919 % 1000) as f64 / 1000.0; + let fy = (i * 104_729 % 1000) as f64 / 1000.0; + let p = Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy); + if doc.hit_test(page, p, 1.0).is_some() { + found += 1; + } + } + let total = ms(t); + rows.push((format!("hit test ×{hits}"), total, format!("{:.1} µs each, {found} hits", total * 1000.0 / hits as f64))); + + let t = Instant::now(); + let bytes = figcraft_format::save_bytes(&doc, None).map_err(|e| CliError::Failed(e.to_string()))?; + rows.push(("save .figcraft".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6))); + let t = Instant::now(); + let (back, _) = figcraft_format::load_bytes(&bytes).map_err(|e| CliError::Failed(e.to_string()))?; + rows.push(("load .figcraft".into(), ms(t), format!("{} nodes", back.node_count()))); + drop(back); + + let kids = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); + let t = Instant::now(); + let svg = figcraft_svg::export_svg(&doc, &kids, &Default::default()).unwrap_or_default(); + rows.push(("export page as SVG".into(), ms(t), format!("{:.1} MB", svg.len() as f64 / 1e6))); + drop(svg); + + // Command throughput through the engine (undo snapshots included). + let mut s = figcraft_engine::Session::with_document(doc); + let first = kids.first().copied(); + let t = Instant::now(); + let cmds = 200; + let mut ok = 0; + for i in 0..cmds { + ok += + usize::from(s.execute("node.create", &json!({"type": "rectangle", "parent": first, "x": i, "y": i, "width": 10, "height": 10})).is_ok()); + } + let total = ms(t); + rows.push((format!("node.create ×{cmds} via Session::execute"), total, format!("{:.2} ms each, {ok} ok", total / f64::from(cmds)))); + let _ = s.execute("select.none", &json!({})); + let t = Instant::now(); + let _ = s.execute("select.all", &json!({})); + let _ = s.execute("transform.move", &json!({"dx": 10, "dy": 0})); + rows.push(("select all + move top-level frames".into(), ms(t), format!("{} selected", s.selection().len()))); + let t = Instant::now(); + let _ = s.undo(); + rows.push(("undo".into(), ms(t), String::new())); + + println!( + "figcraft perf — {shapes} shapes in {frames} frames ({} {})", + std::env::consts::OS, + if cfg!(debug_assertions) { "debug build" } else { "release build" } + ); + let w = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(10); + println!("{:10} notes", "operation", "ms"); + println!("{}", "-".repeat(w + 30)); + for (name, t, note) in rows { + println!("{name:10.1} {note}"); + } + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + #[test] + fn builds_the_requested_size() { + let (doc, page) = super::build(1000, 10).unwrap(); + assert_eq!(doc.node(page).unwrap().children.len(), 10); + // page + 10 frames + 1000 shapes + assert_eq!(doc.node_count(), 1 + 10 + 1000); + } +} diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs new file mode 100644 index 00000000..390e6414 --- /dev/null +++ b/apps/figcraft-cli/tests/cli.rs @@ -0,0 +1,107 @@ +//! The binary end to end. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn bad_arguments_exit_2_without_panicking() { + for args in [ + &[][..], + &["bogus"], + &["export"], + &["export", "x.figcraft"], + &["info", "--nope"], + &["render", "a", "-o"], + &["perf", "--shapes", "lots"], + &["mcp", "--attach", "notaport"], + &["convert", "a"], + ] { + let out = bin().args(args).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("USAGE"), "{args:?}"); + assert!(!err.contains("panicked")); + } + let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); +} + +#[test] +fn run_export_info_convert() { + let d = tmp("run"); + let script = d.join("s.jsonl"); + std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap(); + let file = d.join("doc.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|l| l["ok"] == true)); + + let info = bin().args(["info", "--json"]).arg(&file).output().unwrap(); + let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap(); + assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2); + + let dir = d.join("png"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success()); + let a = std::fs::read(dir.join("A.png")).unwrap(); + assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]); + assert!(dir.join("B.png").exists()); + + let svg = d.join("b.svg"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success()); + assert!(std::fs::read_to_string(&svg).unwrap().contains(" = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18"); + assert_eq!(lines[1]["error"]["code"], -32700); + assert_eq!(lines[2]["result"]["isError"], false); +} + +#[test] +fn commands_json_lists_everything() { + let out = bin().args(["commands", "--json"]).output().unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(v.as_array().unwrap().len() > 200); +} diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml new file mode 100644 index 00000000..5da90110 --- /dev/null +++ b/apps/figcraft-web/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "figcraft-web" +description = "FigCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "figcraft-web" +path = "src/main.rs" + +[dependencies] +figcraft-engine = { workspace = true } +figcraft-ui-egui = { workspace = true } +egui = { workspace = true } +log = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] } diff --git a/apps/figcraft-web/Trunk.toml b/apps/figcraft-web/Trunk.toml new file mode 100644 index 00000000..e9fe5a5e --- /dev/null +++ b/apps/figcraft-web/Trunk.toml @@ -0,0 +1,10 @@ +# Build with: trunk build --release (from apps/figcraft-web) +[build] +target = "index.html" +dist = "../../dist/web" +public_url = "./" +filehash = true + +[serve] +addresses = ["127.0.0.1"] +port = 8767 diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html new file mode 100644 index 00000000..7c6487fc --- /dev/null +++ b/apps/figcraft-web/index.html @@ -0,0 +1,23 @@ + + + + + + FigCraft + + + + + +
Loading FigCraft…
+ + diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs new file mode 100644 index 00000000..f741414a --- /dev/null +++ b/apps/figcraft-web/src/main.rs @@ -0,0 +1,25 @@ +//! FigCraft in the browser. +//! +//! Runs the same [`figcraft_ui_egui::FigApp`] as the desktop app through eframe's web runner +//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this +//! directory; the site lands in `dist/web` (see `packaging/web/README.md`). +//! +//! Differences from the desktop app: +//! - no TCP control server (browsers can't listen on sockets); +//! - no native file dialogs yet: the session starts with one new document; +//! - links open in a new browser tab. +//! +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. + +#[cfg(target_arch = "wasm32")] +mod web; + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("figcraft-web only runs in the browser: build it with `trunk build --release` in apps/figcraft-web"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs new file mode 100644 index 00000000..e9222478 --- /dev/null +++ b/apps/figcraft-web/src/web.rs @@ -0,0 +1,77 @@ +//! The browser shell: web `Services` and the eframe web runner. + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, Services}; +use wasm_bindgen::JsCast as _; + +const CANVAS_ID: &str = "figcraft_canvas"; +const LOADING_ID: &str = "figcraft_loading"; + +pub fn start() { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + wasm_bindgen_futures::spawn_local(async { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { + log::error!("no document"); + return; + }; + let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { + log::error!("missing "); + return; + }; + let mut options = eframe::WebOptions::default(); + // `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU + // adapter request returns nothing). + if query().contains("webgl") + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = eframe::wgpu::Backends::GL; + } + let result = eframe::WebRunner::new() + .start( + canvas, + options, + Box::new(|cc| { + if let Some(rs) = &cc.wgpu_render_state { + log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); + } + let fig = FigApp::new(Session::new(), Box::new(WebServices)); + Ok(Box::new(WebShell { fig })) + }), + ) + .await; + if let Some(el) = document.get_element_by_id(LOADING_ID) { + match result { + Ok(()) => el.remove(), + Err(e) => el.set_inner_html(&format!("

FigCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + } + } + }); +} + +fn query() -> String { + web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() +} + +struct WebShell { + fig: FigApp, +} + +impl eframe::App for WebShell { + // `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app. + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.fig.ui(ui); + } +} + +/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard +/// goes through egui's own web clipboard handling. +struct WebServices; + +impl Services for WebServices { + fn open_url(&mut self, url: &str) { + let Some(window) = web_sys::window() else { return }; + if let Err(e) = window.open_with_url_and_target(url, "_blank") { + log::error!("couldn't open {url}: {e:?}"); + } + } +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 4693aa27..a8e8275a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -46,7 +46,8 @@ impl eframe::App for App { // Last-resort guard: a panic in the UI frame becomes a message, not a lost document. let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.fig.ui(ui))); if let Err(e) = res { - let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + let msg = + e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); log::error!("UI panic: {msg}"); self.fig.toast(format!("Internal error (please report): {msg}")); services::emergency_save(&self.fig.session); @@ -62,7 +63,11 @@ fn main() -> eframe::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { - println!("FigCraft {}{}", env!("CARGO_PKG_VERSION"), option_env!("FIGCRAFT_BUILD_SHA").filter(|s| !s.is_empty()).map(|s| format!(" ({s})")).unwrap_or_default()); + println!( + "FigCraft {}{}", + env!("CARGO_PKG_VERSION"), + option_env!("FIGCRAFT_BUILD_SHA").filter(|s| !s.is_empty()).map(|s| format!(" ({s})")).unwrap_or_default() + ); return Ok(()); } let mut port: Option = std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); @@ -102,7 +107,11 @@ fn main() -> eframe::Result { control_server::start(p, queue.clone(), ctx_slot.clone()); } let icon = services::window_icon(); - let mut viewport = egui::ViewportBuilder::default().with_title("FigCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true); + let mut viewport = egui::ViewportBuilder::default() + .with_title("FigCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([800.0, 500.0]) + .with_drag_and_drop(true); if let Some(icon) = icon { viewport = viewport.with_icon(icon); } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 52e0495e..7d33906e 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -24,8 +24,15 @@ fn config_dir() -> Option { impl Desktop { pub fn load() -> Self { - let state: Value = config_dir().and_then(|d| std::fs::read_to_string(d.join("state.json")).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})); - let recents = state.get("recents").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let state: Value = config_dir() + .and_then(|d| std::fs::read_to_string(d.join("state.json")).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let recents = state + .get("recents") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()); Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs } } diff --git a/clippy.toml b/clippy.toml index 0369326b..724e05c5 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,3 +3,4 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true +too-many-arguments-threshold = 10 diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 16fede06..3e9b29bc 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -114,7 +114,11 @@ impl Color { let parts = css_args(inner); let num = |i: usize| -> Option { let p = parts.get(i)?; - if let Some(pc) = p.strip_suffix('%') { pc.trim().parse::().ok().map(|v| v / 100.0) } else { p.parse::().ok().map(|v| v / 255.0) } + if let Some(pc) = p.strip_suffix('%') { + pc.trim().parse::().ok().map(|v| v / 100.0) + } else { + p.parse::().ok().map(|v| v / 255.0) + } }; let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); @@ -227,8 +231,12 @@ impl Gradient { /// Stops sorted by position with NaNs removed; at least one stop. pub fn sorted_stops(&self) -> Vec { - let mut s: Vec = - self.stops.iter().filter(|s| s.position.is_finite()).map(|s| ColorStop { position: clamp01(s.position), color: s.color.sanitized() }).collect(); + let mut s: Vec = self + .stops + .iter() + .filter(|s| s.position.is_finite()) + .map(|s| ColorStop { position: clamp01(s.position), color: s.color.sanitized() }) + .collect(); s.sort_by(|a, b| a.position.total_cmp(&b.position)); if s.is_empty() { s.push(ColorStop { position: 0.0, color: Color::BLACK }); @@ -349,7 +357,14 @@ impl Paint { pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { Self { - kind: PaintKind::Image(ImagePaint { image: hash.into(), scale_mode: mode, scale: 1.0, rotation: 0, crop: None, filters: ImageFilters::default() }), + kind: PaintKind::Image(ImagePaint { + image: hash.into(), + scale_mode: mode, + scale: 1.0, + rotation: 0, + crop: None, + filters: ImageFilters::default(), + }), opacity: 1.0, visible: true, blend: BlendMode::Normal, diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index bb2281d6..183fbc4b 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -9,9 +9,12 @@ pub fn shape_path(n: &Node) -> Option { let (w, h) = (n.width, n.height); let c = &n.corners; Some(match &n.kind { - NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { - figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing) - } + NodeKind::Rectangle + | NodeKind::Frame + | NodeKind::Component { .. } + | NodeKind::ComponentSet { .. } + | NodeKind::Instance { .. } + | NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing), NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), @@ -165,7 +168,8 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo if top_is_board && chain.len() > 1 { // Sections contain frames: the frame inside a section is the board. let is_section = |i: usize| chain.get(i).and_then(|c| doc.node(*c)).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })); - let is_board = |i: usize| chain.get(i).and_then(|c| doc.node(*c)).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + let is_board = + |i: usize| chain.get(i).and_then(|c| doc.node(*c)).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); let mut i = 1; while is_section(i - 1) && is_board(i) && i + 1 < chain.len() { i += 1; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 18d1a935..8776d784 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -438,7 +438,7 @@ impl Document { let nw = world * shift; { let g = self.get_mut(id)?; - g.transform = g.transform * shift; + g.transform *= shift; g.width = local.width(); g.height = local.height(); } @@ -478,7 +478,14 @@ impl Document { /// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids /// remapped by `map`. Interactions and instance references pointing inside the subtree are /// remapped too. - pub fn paste_nodes(&mut self, nodes: &[Node], map: &BTreeMap, root: NodeId, parent: NodeId, index: Option) -> DocResult { + pub fn paste_nodes( + &mut self, + nodes: &[Node], + map: &BTreeMap, + root: NodeId, + parent: NodeId, + index: Option, + ) -> DocResult { let p = self.get(parent)?; if !p.kind.is_container() { return Err(DocError::NotContainer(parent)); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8ea7dee1..2bba663a 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -194,7 +194,12 @@ impl NodeKind { pub fn is_shape(&self) -> bool { matches!( self, - NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Line | NodeKind::Vector { .. } + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } ) } @@ -419,7 +424,7 @@ impl Node { StrokeAlign::Outside => w, }; let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w * 3.0 } else { w }; - base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * (self.stroke.miter_limit.max(1.0)).min(4.0) + base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * self.stroke.miter_limit.clamp(1.0, 4.0) } else { 0.0 }; diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index ac406d9f..59b47cd5 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -121,8 +121,12 @@ impl Default for Shadow { pub enum EffectKind { DropShadow(Shadow), InnerShadow(Shadow), - LayerBlur { radius: f64 }, - BackgroundBlur { radius: f64 }, + LayerBlur { + radius: f64, + }, + BackgroundBlur { + radius: f64, + }, /// Monotone grain over the layer. Noise { #[serde(default = "noise_size")] @@ -392,7 +396,16 @@ pub struct LayoutGrid { impl Default for LayoutGrid { fn default() -> Self { - Self { kind: GridKind::Grid, size: 10.0, count: 5, gutter: 20.0, offset: 0.0, align: GridAlign::Stretch, color: Color::new(1.0, 0.0, 0.0, 0.1), visible: true } + Self { + kind: GridKind::Grid, + size: 10.0, + count: 5, + gutter: 20.0, + offset: 0.0, + align: GridAlign::Stretch, + color: Color::new(1.0, 0.0, 0.0, 0.1), + visible: true, + } } } diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index d55f4ec4..a39e88c0 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] +#[derive(Default)] pub enum Trigger { + #[default] OnClick, OnHover, OnPress, @@ -14,14 +16,12 @@ pub enum Trigger { MouseLeave, MouseDown, MouseUp, - AfterDelay { ms: u32 }, - KeyPress { key: String }, -} - -impl Default for Trigger { - fn default() -> Self { - Trigger::OnClick - } + AfterDelay { + ms: u32, + }, + KeyPress { + key: String, + }, } impl Trigger { @@ -113,21 +113,27 @@ fn spring(t: f64, freq: f64, damping: f64) -> f64 { #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] +#[derive(Default)] pub enum Transition { + #[default] Instant, Dissolve, SmartAnimate, - MoveIn { direction: Direction }, - MoveOut { direction: Direction }, - Push { direction: Direction }, - SlideIn { direction: Direction }, - SlideOut { direction: Direction }, -} - -impl Default for Transition { - fn default() -> Self { - Transition::Instant - } + MoveIn { + direction: Direction, + }, + MoveOut { + direction: Direction, + }, + Push { + direction: Direction, + }, + SlideIn { + direction: Direction, + }, + SlideOut { + direction: Direction, + }, } impl Transition { @@ -162,9 +168,13 @@ pub enum OverlayPosition { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { - Navigate { dest: NodeId }, + Navigate { + dest: NodeId, + }, Back, - ScrollTo { dest: NodeId }, + ScrollTo { + dest: NodeId, + }, OpenOverlay { dest: NodeId, #[serde(default)] @@ -172,12 +182,21 @@ pub enum Action { #[serde(default)] close_on_outside: bool, }, - SwapOverlay { dest: NodeId }, + SwapOverlay { + dest: NodeId, + }, CloseOverlay, - OpenUrl { url: String }, + OpenUrl { + url: String, + }, /// Change an instance to another variant (interactive components). - ChangeTo { dest: NodeId }, - SetVariable { variable: String, value: serde_json::Value }, + ChangeTo { + dest: NodeId, + }, + SetVariable { + variable: String, + value: serde_json::Value, + }, } impl Action { @@ -196,9 +215,11 @@ impl Action { } pub fn dest(&self) -> Option { match self { - Action::Navigate { dest } | Action::ScrollTo { dest } | Action::OpenOverlay { dest, .. } | Action::SwapOverlay { dest } | Action::ChangeTo { dest } => { - Some(*dest) - } + Action::Navigate { dest } + | Action::ScrollTo { dest } + | Action::OpenOverlay { dest, .. } + | Action::SwapOverlay { dest } + | Action::ChangeTo { dest } => Some(*dest), _ => None, } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 4693ce33..f1bd7a49 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -187,7 +187,11 @@ fn vector_network_paths() { let rr = figcraft_geom::rounded_rect(40.0, 20.0, [5.0; 4], 0.0); let n2 = VectorNetwork::from_path(&rr); assert!((n2.fill_path().area() - rr.area()).abs() < 1e-6); - let mut bad = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0)], segments: vec![Segment::line(0, 5)], regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }] }; + let mut bad = VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0)], + segments: vec![Segment::line(0, 5)], + regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }], + }; bad.sanitize(); assert!(bad.segments.is_empty()); let _ = bad.fill_path(); @@ -196,14 +200,32 @@ fn vector_network_paths() { #[test] fn variables_resolve_modes_and_aliases() { let mut v = Variables::default(); - v.collections.push(Collection { id: "c1".into(), name: "Theme".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }] }); + v.collections.push(Collection { + id: "c1".into(), + name: "Theme".into(), + modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + }); let mut vals = std::collections::BTreeMap::new(); vals.insert("light".to_string(), VariableValue::Color(Color::WHITE)); vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK)); - v.variables.push(Variable { id: "bg".into(), name: "bg".into(), collection: "c1".into(), kind: VariableType::Color, values: vals, description: String::new() }); + v.variables.push(Variable { + id: "bg".into(), + name: "bg".into(), + collection: "c1".into(), + kind: VariableType::Color, + values: vals, + description: String::new(), + }); let mut alias = std::collections::BTreeMap::new(); alias.insert("light".to_string(), VariableValue::Alias("bg".into())); - v.variables.push(Variable { id: "surface".into(), name: "surface".into(), collection: "c1".into(), kind: VariableType::Color, values: alias, description: String::new() }); + v.variables.push(Variable { + id: "surface".into(), + name: "surface".into(), + collection: "c1".into(), + kind: VariableType::Color, + values: alias, + description: String::new(), + }); let mut modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE))); modes.insert("c1".to_string(), "dark".to_string()); @@ -211,7 +233,14 @@ fn variables_resolve_modes_and_aliases() { // Cycle. let mut cyc = std::collections::BTreeMap::new(); cyc.insert("light".to_string(), VariableValue::Alias("loop".into())); - v.variables.push(Variable { id: "loop".into(), name: "loop".into(), collection: "c1".into(), kind: VariableType::Color, values: cyc, description: String::new() }); + v.variables.push(Variable { + id: "loop".into(), + name: "loop".into(), + collection: "c1".into(), + kind: VariableType::Color, + values: cyc, + description: String::new(), + }); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 97509a01..b92f8b22 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -5,18 +5,14 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "unit", content = "value", rename_all = "camelCase")] +#[derive(Default)] pub enum LineHeight { + #[default] Auto, Pixels(f64), Percent(f64), } -impl Default for LineHeight { - fn default() -> Self { - LineHeight::Auto - } -} - impl LineHeight { /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4298bc4e..8ce96053 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,6 +18,7 @@ figcraft-pathops = { workspace = true } figcraft-render = { workspace = true } figcraft-format = { workspace = true } figcraft-svg = { workspace = true } +figcraft-pdf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index baff7185..c5db6f03 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -64,7 +64,13 @@ pub fn specs() -> Vec { } ok() }), - spec!("transform.scale", "Scale", "{ids?, factor, cx?, cy? (pivot; default top-left)} — scales size, strokes, effects, text (the K tool)", true, scale_cmd), + spec!( + "transform.scale", + "Scale", + "{ids?, factor, cx?, cy? (pivot; default top-left)} — scales size, strokes, effects, text (the K tool)", + true, + scale_cmd + ), spec!("transform.resize", "Resize", "{ids?, width?, height?, anchor? (tl|t|tr|l|c|r|bl|b|br)}", true, resize_cmd), ] } @@ -171,12 +177,26 @@ fn tidy(s: &mut Session, p: &Value) -> Result { } } let all: Vec = rows.iter().flatten().map(|(_, b)| *b).collect(); - let (Some(origin), maxw, maxh) = (all.iter().copied().reduce(|a, b| a.union(b)), all.iter().map(Rect::width).fold(0.0, f64::max), all.iter().map(Rect::height).fold(0.0, f64::max)) else { + let (Some(origin), maxw, maxh) = ( + all.iter().copied().reduce(|a, b| a.union(b)), + all.iter().map(Rect::width).fold(0.0, f64::max), + all.iter().map(Rect::height).fold(0.0, f64::max), + ) else { return ok(); }; let gap = num(p, "spacing").unwrap_or_else(|| { // Keep the average existing horizontal gap (min 10). - 10f64.max(rows.iter().filter(|r| r.len() > 1).map(|r| (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) - r.iter().map(|(_, b)| b.width()).sum::()) / (r.len() - 1) as f64).fold(0.0, f64::max)) + 10f64.max( + rows.iter() + .filter(|r| r.len() > 1) + .map(|r| { + (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) + - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) + - r.iter().map(|(_, b)| b.width()).sum::()) + / (r.len() - 1) as f64 + }) + .fold(0.0, f64::max), + ) }); for (ri, row) in rows.iter_mut().enumerate() { row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index f68f2ff7..9a45549e 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,7 +11,13 @@ use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ - spec!("component.create", "Create component", "{ids?} (one frame/group becomes a component; several layers are wrapped in one)", true, create), + spec!( + "component.create", + "Create component", + "{ids?} (one frame/group becomes a component; several layers are wrapped in one)", + true, + create + ), spec!("component.createMultiple", "Create multiple components", "{ids?}", true, |s, p| { let ids = req_targets("component.createMultiple", s, p)?; let mut out = Vec::new(); @@ -54,7 +60,9 @@ pub fn specs() -> Vec { let i = targets(s, p).first().copied().ok_or_else(|| bad("component.goToMain", "select an instance"))?; let doc = s.doc()?; let inst = owning_instance(doc, i).ok_or_else(|| bad("component.goToMain", "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return Err(bad("component.goToMain", "not an instance")) }; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { + return Err(bad("component.goToMain", "not an instance")); + }; if !doc.contains(component) { return Err(EngineError::Other("the main component was deleted".into())); } @@ -78,8 +86,20 @@ pub fn specs() -> Vec { } ok() }), - spec!("instance.setProperties", "Set instance properties", "{ids?, properties {name: value}} (variant properties switch variants)", true, set_props), - spec!("component.addProperty", "Add component property", "{id? (component or set), name, type: boolean|text|instanceSwap|variant, default, layer? (node bound to it)}", true, add_prop), + spec!( + "instance.setProperties", + "Set instance properties", + "{ids?, properties {name: value}} (variant properties switch variants)", + true, + set_props + ), + spec!( + "component.addProperty", + "Add component property", + "{id? (component or set), name, type: boolean|text|instanceSwap|variant, default, layer? (node bound to it)}", + true, + add_prop + ), spec!("component.list", "List components", "{}", false, |s, _| { let doc = s.doc()?; Ok(Value::Array( @@ -139,7 +159,11 @@ fn combine(s: &mut Session, p: &Value) -> Result { let bounds = doc.bounds_of(&ids).unwrap_or_default(); let pw = doc.world_transform(parent); let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids.first().and_then(|i| doc.node(*i)).map(|n| n.name.split('/').next().unwrap_or("Component").trim().to_string()).unwrap_or_else(|| "Component".into()); + let base = ids + .first() + .and_then(|i| doc.node(*i)) + .map(|n| n.name.split('/').next().unwrap_or("Component").trim().to_string()) + .unwrap_or_else(|| "Component".into()); let set = doc.alloc_id(); let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); @@ -221,12 +245,14 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { fn set_props(s: &mut Session, p: &Value) -> Result { let ids = req_targets("instance.setProperties", s, p)?; - let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad("instance.setProperties", "`properties` object required"))?; + let props = + get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad("instance.setProperties", "`properties` object required"))?; let doc = s.doc_mut()?; for i in ids { let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { continue }; // Variant properties: switch to the matching sibling variant. - let set = doc.node(component).and_then(|c| c.parent).filter(|pp| doc.node(*pp).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))); + let set = + doc.node(component).and_then(|c| c.parent).filter(|pp| doc.node(*pp).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))); let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); let mut variant_change = false; for (k, v) in &props { @@ -261,7 +287,9 @@ fn add_prop(s: &mut Session, p: &Value) -> Result { let def = match t.to_ascii_lowercase().as_str() { "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, "text" => PropDef::Text { default: string(p, "default").unwrap_or_default() }, - "instanceswap" | "instance_swap" | "instance" => PropDef::InstanceSwap { default: id(p, "default").ok_or_else(|| bad(cmd, "default: component id"))? }, + "instanceswap" | "instance_swap" | "instance" => { + PropDef::InstanceSwap { default: id(p, "default").ok_or_else(|| bad(cmd, "default: component id"))? } + } "variant" => PropDef::Variant { options: vec![], default: string(p, "default").unwrap_or_default() }, _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")), }; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 377f6f57..874abcea 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -57,16 +57,32 @@ pub fn specs() -> Vec { true, create ), - spec!("frame.create", "Create frame", "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?}", true, frame_create), + spec!( + "frame.create", + "Create frame", + "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?}", + true, + frame_create + ), spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")), spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")), spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")), spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")), spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")), spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")), - spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| create_kind(s, p, "text")), - spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(s, p, "vector")), - spec!("image.place", "Place image", "{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)}", true, place_image), + spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { + create_kind(s, p, "text") + }), + spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind( + s, p, "vector" + )), + spec!( + "image.place", + "Place image", + "{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)}", + true, + place_image + ), spec!("frame.presets", "List frame presets", "{}", false, |_, _| { Ok(Value::Array(FRAME_PRESETS.iter().map(|(c, n, w, h)| json!({"category": c, "name": n, "width": w, "height": h})).collect())) }), @@ -198,7 +214,11 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return first; } } - if matches!(base, "Frame" | "Section" | "Component" | "Group") { s.doc().map(|d| d.next_name(base)).unwrap_or_else(|_| base.to_string()) } else { base.to_string() } + if matches!(base, "Frame" | "Section" | "Component" | "Group") { + s.doc().map(|d| d.next_name(base)).unwrap_or_else(|_| base.to_string()) + } else { + base.to_string() + } }); let ds = s.ds_mut()?; let doc = ds.doc_mut(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4d9d9c3e..cc7d42c9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -15,7 +15,13 @@ pub fn specs() -> Vec { spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), spec!("edit.redo", "Redo", "{}", false, |s, _| Ok(json!({"redone": s.redo()?}))), spec!("edit.delete", "Delete", "{ids?}", true, delete), - spec!("edit.duplicate", "Duplicate", "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", true, duplicate), + spec!( + "edit.duplicate", + "Duplicate", + "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", + true, + duplicate + ), spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), @@ -23,7 +29,13 @@ pub fn specs() -> Vec { spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), spec!("edit.pasteProperties", "Paste properties", "{ids?}", true, paste_props), - spec!("edit.findReplace", "Find and replace", "{find, replace? (text layers), layerNames? (bool), select? (default true)}", true, find_replace), + spec!( + "edit.findReplace", + "Find and replace", + "{find, replace? (text layers), layerNames? (bool), select? (default true)}", + true, + find_replace + ), spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| { let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?; let v = copy_props_of(s.doc()?, id); @@ -70,19 +82,33 @@ pub fn specs() -> Vec { spec!("select.previous", "Select previous sibling", "{}", false, |s, _| select_sibling(s, -1)), spec!("select.matching", "Select matching layers", "{}", false, select_matching), spec!("select.sameFill", "Select same fill", "{}", false, |s, _| select_same(s, |a, b| a.fills == b.fills && !a.fills.is_empty())), - spec!("select.sameStroke", "Select same stroke", "{}", false, |s, _| select_same(s, |a, b| a.strokes == b.strokes && a.stroke == b.stroke && !a.strokes.is_empty())), + spec!("select.sameStroke", "Select same stroke", "{}", false, |s, _| select_same(s, |a, b| a.strokes == b.strokes + && a.stroke == b.stroke + && !a.strokes.is_empty())), spec!("select.sameEffect", "Select same effect", "{}", false, |s, _| select_same(s, |a, b| a.effects == b.effects && !a.effects.is_empty())), spec!("select.sameText", "Select same text properties", "{}", false, |s, _| { select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style == y.style)) }), spec!("select.sameFont", "Select same font", "{}", false, |s, _| { - select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style.font_family == y.style.font_family && x.style.font_style == y.style.font_style)) + select_same( + s, + |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style.font_family == y.style.font_family && x.style.font_style == y.style.font_style), + ) }), spec!("select.sameInstance", "Select same instance", "{}", false, |s, _| { - select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x == y)) + select_same( + s, + |a, b| matches!((&a.kind, &b.kind), (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x == y), + ) }), spec!("select.sameProperties", "Select same properties", "{}", false, |s, _| { - select_same(s, |a, b| a.kind.type_name() == b.kind.type_name() && a.fills == b.fills && a.strokes == b.strokes && a.effects == b.effects && a.corners == b.corners) + select_same(s, |a, b| { + a.kind.type_name() == b.kind.type_name() + && a.fills == b.fills + && a.strokes == b.strokes + && a.effects == b.effects + && a.corners == b.corners + }) }), spec!("select.byName", "Select layers by name", "{query, page? (default current; \"all\" for every page)}", false, |s, p| { let q = req_string("select.byName", p, "query")?; @@ -101,7 +127,13 @@ pub fn specs() -> Vec { } ok() }), - spec!("object.batchRename", "Batch rename", "{ids?, find?, replace?, name? (pattern: $& = current name, $N or $n = number asc/desc)}", true, batch_rename), + spec!( + "object.batchRename", + "Batch rename", + "{ids?, find?, replace?, name? (pattern: $& = current name, $N or $n = number asc/desc)}", + true, + batch_rename + ), spec!("object.toggleVisible", "Show/Hide selection", "{ids?, visible?}", true, |s, p| toggle(s, p, "visible")), spec!("object.toggleLock", "Lock/Unlock selection", "{ids?, locked?}", true, |s, p| toggle(s, p, "locked")), spec!("object.hideOthers", "Hide other layers", "{}", true, hide_others), @@ -146,7 +178,13 @@ pub fn specs() -> Vec { spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)), - spec!("effect.add", "Add effect", "{ids?, type? (dropShadow|innerShadow|layerBlur|backgroundBlur|noise|texture), effect? {..}}", true, effect_add), + spec!( + "effect.add", + "Add effect", + "{ids?, type? (dropShadow|innerShadow|layerBlur|backgroundBlur|noise|texture), effect? {..}}", + true, + effect_add + ), spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| { let i = req_num("effect.remove", p, "index")? as usize; for id in req_targets("effect.remove", s, p)? { @@ -185,13 +223,19 @@ pub fn specs() -> Vec { } ok() }), - spec!("export.add", "Add export setting", "{ids?, setting? {size:{by:scale,value:2}, suffix, format: png|jpg|svg|pdf|webp}}", true, |s, p| { - let e: ExportSetting = parse("export.add", p, "setting")?.unwrap_or_default(); - for id in req_targets("export.add", s, p)? { - s.doc_mut()?.get_mut(id)?.exports.push(e.clone()); + spec!( + "export.add", + "Add export setting", + "{ids?, setting? {size:{by:scale,value:2}, suffix, format: png|jpg|svg|pdf|webp}}", + true, + |s, p| { + let e: ExportSetting = parse("export.add", p, "setting")?.unwrap_or_default(); + for id in req_targets("export.add", s, p)? { + s.doc_mut()?.get_mut(id)?.exports.push(e.clone()); + } + ok() } - ok() - }), + ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { let i = req_num("export.remove", p, "index")? as usize; for id in req_targets("export.remove", s, p)? { @@ -205,7 +249,9 @@ pub fn specs() -> Vec { spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); let cmds: Vec<(String, Value)> = match get(p, "commands") { - Some(Value::Array(a)) => a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect(), + Some(Value::Array(a)) => { + a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect() + } _ => return Err(bad("command.batch", "`commands` must be an array")), }; Ok(Value::Array(s.batch(&label, &cmds)?)) @@ -401,7 +447,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { let sel = s.selection(); let doc = s.doc()?; let target = id(p, "parent").or_else(|| { - sel.first().copied().filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + sel.first() + .copied() + .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { @@ -529,7 +577,8 @@ fn select_inverse(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| !n.locked)).collect(); + let ids: Vec = + doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| !n.locked)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } @@ -592,7 +641,8 @@ fn select_matching(s: &mut Session, _p: &Value) -> Result { let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; let (name, ty) = (first.name.clone(), first.kind.type_name()); let page = s.page()?; - let ids: Vec = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); + let ids: Vec = + doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } @@ -648,11 +698,14 @@ fn hide_others(s: &mut Session, _p: &Value) -> Result { let sel = s.selection(); let page = s.page()?; let doc = s.doc_mut()?; - let keep: BTreeSet = sel.iter().flat_map(|i| { - let mut v = doc.ancestors(*i); - v.push(*i); - v - }).collect(); + let keep: BTreeSet = sel + .iter() + .flat_map(|i| { + let mut v = doc.ancestors(*i); + v.push(*i); + v + }) + .collect(); let tops: Vec = doc.get(page)?.children.clone(); for t in tops { if !keep.contains(&t) { @@ -844,7 +897,9 @@ fn effect_add(s: &mut Session, p: &Value) -> Result { "innershadow" => Effect::inner_shadow(), "layerblur" | "blur" => Effect::layer_blur(), "backgroundblur" => Effect::background_blur(), - "noise" => Effect { kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) }, visible: true }, + "noise" => { + Effect { kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) }, visible: true } + } "texture" => Effect { kind: EffectKind::Texture { size: 0.5, radius: 4.0 }, visible: true }, other => return Err(bad("effect.add", format!("unknown effect type `{other}`"))), }, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3fe06a1f..0487f1d2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -21,7 +21,13 @@ pub fn specs() -> Vec { spec!("file.open", "Open", "{path} (.figcraft, .json, .svg, .png, .jpg …)", false, open), spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), spec!("file.saveAs", "Save as", "{path}", false, save_as), - spec!("file.export", "Export", "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1), path? (else returns base64 `data`)}", false, export), + spec!( + "file.export", + "Export", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1), path? (else returns base64 `data`)}", + false, + export + ), spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| { let mut q = p.clone(); if let Some(o) = q.as_object_mut() { @@ -29,7 +35,20 @@ pub fn specs() -> Vec { } export(s, &q) }), - spec!("file.import", "Import", "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft pages are appended", true, import), + spec!( + "file.exportFramesPdf", + "Export frames to PDF", + "{path?, page? (page id, default the current page)} every top-level frame in reading order, one PDF page each; returns base64 `data` without a path", + false, + export_frames_pdf + ), + spec!( + "file.import", + "Import", + "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft pages are appended", + true, + import + ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), spec!("file.rename", "Rename file", "{name}", true, |s, p| { @@ -63,14 +82,17 @@ pub fn specs() -> Vec { }), spec!("file.versionHistory", "Show version history", "{}", false, |s, _| { let ds = s.ds()?; - let v: Vec = versions(ds).iter().enumerate().map(|(i, v)| json!({"index": i, "name": v.0, "description": v.1, "time": v.2})).collect(); + let v: Vec = + versions(ds).iter().enumerate().map(|(i, v)| json!({"index": i, "name": v.0, "description": v.1, "time": v.2})).collect(); s.request_ui("panel.versionHistory", json!({})); Ok(Value::Array(v)) }), spec!("version.restore", "Restore version", "{index}", true, |s, p| { let i = req_num("version.restore", p, "index")? as usize; let ds = s.ds_mut()?; - let Some((_, _, _, doc)) = version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).and_then(|v| v.get(i)).cloned() else { + let Some((_, _, _, doc)) = + version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).and_then(|v| v.get(i)).cloned() + else { return Err(bad("version.restore", "no such version")); }; ds.doc = doc; @@ -88,7 +110,13 @@ pub fn specs() -> Vec { ok() }), spec!("doc.list", "List documents", "{}", false, |s, _| { - Ok(Value::Array(s.docs.iter().enumerate().map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})).collect())) + Ok(Value::Array( + s.docs + .iter() + .enumerate() + .map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) + .collect(), + )) }), spec!("doc.home", "Home", "{}", false, |s, _| { s.request_ui("home", json!({})); @@ -120,7 +148,12 @@ fn push_version(ds: &DocState, name: String, desc: String, doc: Arc) { } pub fn versions(ds: &DocState) -> Vec<(String, String, u64)> { - version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).map(|v| v.iter().map(|(a, b, c, _)| (a.clone(), b.clone(), *c)).collect()).unwrap_or_default() + version_list() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&ds.uid) + .map(|v| v.iter().map(|(a, b, c, _)| (a.clone(), b.clone(), *c)).collect()) + .unwrap_or_default() } #[cfg(not(target_arch = "wasm32"))] @@ -204,7 +237,12 @@ pub fn thumbnail(doc: &Document) -> Option> { let page = doc.first_page()?; let b = doc.page_content_bounds(page)?; let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { origin: (b.x0, b.y0), scale, width: (b.width() * scale).ceil().max(1.0) as u32, height: (b.height() * scale).ceil().max(1.0) as u32 }; + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; figcraft_render::encode_png(&pix).ok() } @@ -256,15 +294,31 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: return Err(EngineError::Disabled("file.export".into(), "nothing to export".into())); } match format { - ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default()).map(String::into_bytes).ok_or_else(|| EngineError::Other("nothing to export".into())), - ExportFormat::Pdf => Err(EngineError::Disabled("file.export".into(), "PDF export is not available yet".into())), + ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default()) + .map(String::into_bytes) + .ok_or_else(|| EngineError::Other("nothing to export".into())), + ExportFormat::Pdf => { + // Frames export one page each; anything else shares one page. + let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let r = if all_frames { + figcraft_pdf::export_pdf(doc, ids, &figcraft_pdf::PdfOptions::default()) + } else { + figcraft_pdf::export_nodes_pdf(doc, &super::edit::z_sorted(doc, ids)) + }; + r.map_err(|e| EngineError::Other(e.to_string())) + } _ => { let pix = if ids.len() == 1 { ids.first().and_then(|i| figcraft_render::render_node(doc, *i, scale)) } else { let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b)); b.and_then(|b| { - let view = figcraft_render::View { origin: (b.x0, b.y0), scale, width: (b.width() * scale).ceil().max(1.0) as u32, height: (b.height() * scale).ceil().max(1.0) as u32 }; + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; let mut pix = figcraft_render::Pixmap::new(view.width.clamp(1, 16384), view.height.clamp(1, 16384))?; let ordered = super::edit::z_sorted(doc, ids); figcraft_render::render_nodes_into(doc, &ordered, &view, &mut pix); @@ -288,7 +342,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { ids = doc.get(s.page()?)?.children.clone(); } - let format = string(p, "format").and_then(|f| ExportFormat::from_ext(&f)).or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp)))).unwrap_or_default(); + let format = string(p, "format") + .and_then(|f| ExportFormat::from_ext(&f)) + .or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp)))) + .unwrap_or_default(); let scale = num_or(p, "scale", 1.0).clamp(0.01, 64.0); let bytes = export_bytes(doc, &ids, format, scale)?; match string(p, "path") { @@ -300,12 +357,39 @@ fn export(s: &mut Session, p: &Value) -> Result { } } +/// Every top-level frame of a page (reading order), one PDF page each. +fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let page = match id(p, "page") { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), + None => s.page()?, + }; + let frames = doc.top_frames(page); + if frames.is_empty() { + return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no frames".into())); + } + let title = Some(doc.name.clone()); + let bytes = figcraft_pdf::export_pdf(doc, &frames, &figcraft_pdf::PdfOptions { title, ..Default::default() }) + .map_err(|e| EngineError::Other(e.to_string()))?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "pages": frames.len()})) + } + None => Ok(json!({"format": "pdf", "pages": frames.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } +} + fn import(s: &mut Session, p: &Value) -> Result { let (bytes, name) = if let Some(path) = string(p, "path") { (read(&path)?, path) } else if let Some(d) = string(p, "data") { let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); - (base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, string(p, "name").unwrap_or_else(|| "import.png".into())) + ( + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, + string(p, "name").unwrap_or_else(|| "import.png".into()), + ) } else { return Err(bad("file.import", "pass `path` or `data`")); }; @@ -315,7 +399,8 @@ fn import(s: &mut Session, p: &Value) -> Result { match ext(&name).as_str() { "svg" => { let doc = s.doc_mut()?; - let id = figcraft_svg::import_svg(doc, &bytes, page, x.unwrap_or(center.0), y.unwrap_or(center.1), &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; + let id = figcraft_svg::import_svg(doc, &bytes, page, x.unwrap_or(center.0), y.unwrap_or(center.1), &file_stem(&name)) + .map_err(|e| EngineError::Other(e.to_string()))?; s.set_selection(vec![id]); Ok(json!({"id": id})) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8ac9cd43..e0814f63 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -70,6 +70,7 @@ fn registry() -> &'static Registry { list.extend(styles::specs()); list.extend(component::specs()); list.extend(proto::specs()); + list.extend(crate::vectoredit::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b50d9b51..52064a18 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -99,7 +99,13 @@ pub fn specs() -> Vec { spec!("page.list", "List pages", "{}", false, |s, _| { let doc = s.doc()?; let cur = s.page()?; - Ok(Value::Array(doc.pages.iter().filter_map(|p| doc.node(*p)).map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len()})).collect())) + Ok(Value::Array( + doc.pages + .iter() + .filter_map(|p| doc.node(*p)) + .map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len()})) + .collect(), + )) }), spec!("guide.add", "Add guide", "{vertical (bool), offset, page?}", true, |s, p| { let id = id(p, "page").unwrap_or(s.page()?); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 3c4a87e5..12dfbae0 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -180,7 +180,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, - "strokeSides" => doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) }, + "strokeSides" => { + doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } + } "strokeCap" => { let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; let n = doc.get_mut(id)?; @@ -232,7 +234,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?, - "crossGap" | "counterAxisSpacing" => ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))?, + "crossGap" | "counterAxisSpacing" => { + ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))? + } "padding" => { let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0)); @@ -256,8 +260,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))?, - "crossAlign" | "counterAxisAlignItems" => ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?, + "mainAlign" | "primaryAxisAlignItems" => { + ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))? + } + "crossAlign" | "counterAxisAlignItems" => { + ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? + } "wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false), "gridColumns" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32, "gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32, @@ -286,8 +294,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.grids = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("grids: {e}")))?, - "exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))?, - "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, + "exports" | "exportSettings" => { + doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))? + } + "interactions" => { + doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))? + } "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), @@ -299,15 +311,23 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + "fontFamily" | "fontStyle" | "fontWeight" | "fontSize" | "lineHeight" | "letterSpacing" | "textDecoration" | "textCase" | "hyperlink" + | "textFills" => { let n = doc.get_mut(id)?; let t = text_mut(cmd, n)?; let (start, end) = range_of(props, t.char_count()); apply_text_style(cmd, t, start, end, k, v)?; } - "textAlign" | "textAlignHorizontal" => text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))?, - "textAlignVertical" => text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))?, - "textAutoResize" => text_mut(cmd, doc.get_mut(id)?)?.auto_resize = enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?, + "textAlign" | "textAlignHorizontal" => { + text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? + } + "textAlignVertical" => { + text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? + } + "textAutoResize" => { + text_mut(cmd, doc.get_mut(id)?)?.auto_resize = + enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? + } "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), "listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, @@ -434,7 +454,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map &'static str { match k { "fills" | "fill" => "fills", - "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" => "strokes", + "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" => { + "strokes" + } "effects" => "effects", "opacity" | "blend" | "blendMode" => "opacity", "visible" => "visible", @@ -442,7 +464,9 @@ pub fn override_group(k: &str) -> &'static str { "characters" | "text" => "characters", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", - k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => "textStyle", + k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => { + "textStyle" + } _ => "other", } } diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 124c985f..11657826 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -29,7 +29,8 @@ pub fn specs() -> Vec { }), spec!("proto.interaction.set", "Edit interaction", "{id?, index, interaction {..}}", true, |s, p| { let k = req_num("proto.interaction.set", p, "index")? as usize; - let it: Interaction = parse("proto.interaction.set", p, "interaction")?.ok_or_else(|| bad("proto.interaction.set", "`interaction` required"))?; + let it: Interaction = + parse("proto.interaction.set", p, "interaction")?.ok_or_else(|| bad("proto.interaction.set", "`interaction` required"))?; for i in req_targets("proto.interaction.set", s, p)? { let n = s.doc_mut()?.get_mut(i)?; if let Some(slot) = n.interactions.get_mut(k) { @@ -78,10 +79,14 @@ pub fn specs() -> Vec { spec!("proto.present", "Present", "{start? (frame id)}", false, |s, p| { let start = id(p, "start").or_else(|| { let ds = s.ds().ok()?; - ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).or_else(|| match &ds.doc.node(ds.page)?.kind { - NodeKind::Page { flows, .. } => flows.first().map(|f| f.start), - _ => None, - }).or_else(|| ds.doc.top_frames(ds.page).first().copied()) + ds.selection + .first() + .and_then(|i| ds.doc.top_level_frame(*i)) + .or_else(|| match &ds.doc.node(ds.page)?.kind { + NodeKind::Page { flows, .. } => flows.first().map(|f| f.start), + _ => None, + }) + .or_else(|| ds.doc.top_frames(ds.page).first().copied()) }); let start = start.ok_or_else(|| bad("proto.present", "no frame to present"))?; s.request_ui("present", json!({"start": start})); @@ -93,7 +98,13 @@ pub fn specs() -> Vec { fn add(s: &mut Session, p: &Value) -> Result { let cmd = "proto.interaction.add"; let it = if let Some(d) = id(p, "dest") { - Interaction { trigger: Trigger::OnClick, actions: vec![Action::Navigate { dest: d }], transition: Transition::Instant, duration_ms: 300, easing: Easing::EaseOut } + Interaction { + trigger: Trigger::OnClick, + actions: vec![Action::Navigate { dest: d }], + transition: Transition::Instant, + duration_ms: 300, + easing: Easing::EaseOut, + } } else { let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default(); let action: Action = parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 5184054f..299331fe 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -221,7 +221,11 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); v.transform = Affine::translate((bb.x0, bb.y0)); if let Some(f) = first { - v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { f.fills.clone() }; + v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { + vec![Paint::solid(Color::DEFAULT_SHAPE)] + } else { + f.fills.clone() + }; v.strokes = f.strokes.clone(); v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; v.effects = f.effects.clone(); @@ -296,7 +300,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { /// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { let Some(f) = doc.node(frame) else { return AutoLayout::default() }; - let boxes: Vec = f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); + let boxes: Vec = + f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); if boxes.is_empty() { return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; } @@ -305,7 +310,8 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { let vertical = span_y > span_x; let mut sorted = boxes.clone(); sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) }); - let gaps: Vec = sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); + let gaps: Vec = + sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::() / gaps.len() as f64).max(0.0).round() }; let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default(); let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round); @@ -425,7 +431,11 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); - let res = if d >= 0.0 { figcraft_pathops::boolean(&[path, ring], BoolOp::Union) } else { figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) }; + let res = if d >= 0.0 { + figcraft_pathops::boolean(&[path, ring], BoolOp::Union) + } else { + figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) + }; let Ok(mut res) = res else { continue }; let bb = res.bounding_box(); res.apply_affine(Affine::translate((-bb.x0, -bb.y0))); @@ -435,7 +445,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { r.winding = WindingRule::EvenOdd; } nm.kind = NodeKind::Vector { network: net }; - nm.transform = nm.transform * Affine::translate((bb.x0, bb.y0)); + nm.transform *= Affine::translate((bb.x0, bb.y0)); nm.width = bb.width(); nm.height = bb.height(); } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 4250e367..4ea20c31 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -12,7 +12,13 @@ use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ - spec!("style.create", "Create style", "{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}", true, create), + spec!( + "style.create", + "Create style", + "{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}", + true, + create + ), spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply), spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| { let which = req_string("style.detach", p, "which")?; @@ -38,7 +44,9 @@ pub fn specs() -> Vec { // Nodes keep their values but lose the link. for nid in doc.ids().collect::>() { if let Some(n) = doc.node(nid) - && [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].iter().any(|r| r.as_deref() == Some(sid.as_str())) + && [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text] + .iter() + .any(|r| r.as_deref() == Some(sid.as_str())) { let n = doc.get_mut(nid)?; for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] { @@ -52,7 +60,14 @@ pub fn specs() -> Vec { }), spec!("style.list", "List styles", "{type?}", false, |s, p| { let t = string(p, "type"); - Ok(Value::Array(s.doc()?.styles.iter().filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t)).map(|st| serde_json::to_value(st).unwrap_or(Value::Null)).collect())) + Ok(Value::Array( + s.doc()? + .styles + .iter() + .filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t)) + .map(|st| serde_json::to_value(st).unwrap_or(Value::Null)) + .collect(), + )) }), spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { let name = req_string("variable.createCollection", p, "name")?; @@ -70,7 +85,12 @@ pub fn specs() -> Vec { let c = req_string("variable.addMode", p, "collection")?; let name = req_string("variable.addMode", p, "name")?; let doc = s.doc_mut()?; - let coll = doc.variables.collections.iter_mut().find(|x| x.id == c || x.name == c).ok_or_else(|| bad("variable.addMode", "no such collection"))?; + let coll = doc + .variables + .collections + .iter_mut() + .find(|x| x.id == c || x.name == c) + .ok_or_else(|| bad("variable.addMode", "no such collection"))?; let mid = format!("{}/mode{}", coll.id, coll.modes.len() + 1); let first = coll.modes.first().map(|m| m.id.clone()); coll.modes.push(Mode { id: mid.clone(), name }); @@ -83,7 +103,13 @@ pub fn specs() -> Vec { } Ok(json!({"id": mid})) }), - spec!("variable.create", "Create variable", "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}", true, var_create), + spec!( + "variable.create", + "Create variable", + "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}", + true, + var_create + ), spec!("variable.set", "Set variable value", "{variable, mode? (name or id; default first), value}", true, var_set), spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| { let v = req_string("variable.delete", p, "variable")?; @@ -92,36 +118,43 @@ pub fn specs() -> Vec { doc.variables.variables.retain(|x| x.id != id); ok() }), - spec!("variable.bind", "Bind variable", "{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}", true, |s, p| { - let field = req_string("variable.bind", p, "field")?; - let var = string(p, "variable"); - let ids = req_targets("variable.bind", s, p)?; - let doc = s.doc_mut()?; - if let Some(v) = &var - && doc.variables.get(v).is_none() - { - return Err(bad("variable.bind", "no such variable")); - } - let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone())); - for i in ids { - let n = doc.get_mut(i)?; - match &vid { - Some(v) => { - n.bound.insert(field.clone(), v.clone()); - } - None => { - n.bound.remove(&field); + spec!( + "variable.bind", + "Bind variable", + "{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}", + true, + |s, p| { + let field = req_string("variable.bind", p, "field")?; + let var = string(p, "variable"); + let ids = req_targets("variable.bind", s, p)?; + let doc = s.doc_mut()?; + if let Some(v) = &var + && doc.variables.get(v).is_none() + { + return Err(bad("variable.bind", "no such variable")); + } + let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone())); + for i in ids { + let n = doc.get_mut(i)?; + match &vid { + Some(v) => { + n.bound.insert(field.clone(), v.clone()); + } + None => { + n.bound.remove(&field); + } } } + ok() } - ok() - }), + ), spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, |s, p| { let c = req_string("variable.setMode", p, "collection")?; let m = req_string("variable.setMode", p, "mode")?; let doc = s.doc()?; let coll = doc.variables.collection(&c).ok_or_else(|| bad("variable.setMode", "no such collection"))?; - let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?; + let mode = + coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?; let cid = coll.id.clone(); let mut ids = targets(s, p); if ids.is_empty() { @@ -297,7 +330,13 @@ pub fn propagate_style(doc: &mut Document, sid: &str) { for i in ids { let Some(n) = doc.node(i) else { continue }; let refs = n.styles.clone(); - for (field, r) in [("fillStyle", &refs.fill), ("strokeStyle", &refs.stroke), ("effectStyle", &refs.effect), ("gridStyle", &refs.grid), ("textStyle", &refs.text)] { + for (field, r) in [ + ("fillStyle", &refs.fill), + ("strokeStyle", &refs.stroke), + ("effectStyle", &refs.effect), + ("gridStyle", &refs.grid), + ("textStyle", &refs.text), + ] { if r.as_deref() == Some(sid) { let _ = apply_style_ref(doc, i, field, Some(sid)); } @@ -333,7 +372,12 @@ fn var_create(s: &mut Session, p: &Value) -> Result { let mut values = BTreeMap::new(); if let Some(Value::Object(m)) = get(p, "values") { for (mode, v) in m { - let mid = coll.modes.iter().find(|x| x.id == *mode || x.name == *mode).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?; + let mid = coll + .modes + .iter() + .find(|x| x.id == *mode || x.name == *mode) + .map(|x| x.id.clone()) + .ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?; values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?); } } @@ -353,7 +397,14 @@ fn var_create(s: &mut Session, p: &Value) -> Result { values.entry(m.id.clone()).or_insert_with(|| default.clone()); } let id = format!("VariableID:{}", doc.variables.variables.len() + 1); - doc.variables.variables.push(Variable { id: id.clone(), name, collection: coll.id, kind, values, description: string(p, "description").unwrap_or_default() }); + doc.variables.variables.push(Variable { + id: id.clone(), + name, + collection: coll.id, + kind, + values, + description: string(p, "description").unwrap_or_default(), + }); Ok(json!({"id": id})) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0d71fe7f..0320fe77 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -81,7 +81,13 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result { let italic = all_have(s, p, TextStyle::italic); style(s, p, |st| { let w = super::props::weight_name(st.weight()); - st.font_style = if italic { w.to_string() } else if w == "Regular" { "Italic".into() } else { format!("{w} Italic") }; + st.font_style = if italic { + w.to_string() + } else if w == "Regular" { + "Italic".into() + } else { + format!("{w} Italic") + }; }) } @@ -110,8 +116,10 @@ pub fn specs() -> Vec { let url = req_string("text.link", p, "url")?; style(s, p, |st| st.link = Some(url.clone())) }), - spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })), - spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })), + spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list = + if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })), + spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list = + if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })), spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)), spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)), spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)), @@ -127,16 +135,20 @@ pub fn specs() -> Vec { style(s, p, |st| st.line_height = LineHeight::Pixels((st.line_height.resolve(st.font_size, 1.21).round() - 1.0).max(0.0))) }), spec!("text.trackingUp", "Increase letter spacing", "{ids?}", true, |s, p| { - style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing { - LetterSpacing::Percent(v) => v + 1.0, - LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0, - })) + style(s, p, |st| { + st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing { + LetterSpacing::Percent(v) => v + 1.0, + LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0, + }) + }) }), spec!("text.trackingDown", "Decrease letter spacing", "{ids?}", true, |s, p| { - style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing { - LetterSpacing::Percent(v) => v - 1.0, - LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0, - })) + style(s, p, |st| { + st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing { + LetterSpacing::Percent(v) => v - 1.0, + LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0, + }) + }) }), spec!("text.caseOriginal", "Original case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Original)), spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)), @@ -153,25 +165,31 @@ pub fn specs() -> Vec { } ok() }), - spec!("text.style", "Style text range", "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}", true, |s, p| { - let ids = req_targets("text.style", s, p)?; - let m = p.as_object().cloned().unwrap_or_default(); - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) }; - let len = t.char_count(); - let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len); - let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len); - for (k, v) in &m { - if matches!(k.as_str(), "id" | "ids" | "start" | "end") { - continue; + spec!( + "text.style", + "Style text range", + "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}", + true, + |s, p| { + let ids = req_targets("text.style", s, p)?; + let m = p.as_object().cloned().unwrap_or_default(); + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) }; + let len = t.char_count(); + let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len); + let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len); + for (k, v) in &m { + if matches!(k.as_str(), "id" | "ids" | "start" | "end") { + continue; + } + super::props::apply_text_style("text.style", t, a, b, k, v)?; } - super::props::apply_text_style("text.style", t, a, b, k, v)?; } + ok() } - ok() - }), + ), spec!("text.edit.begin", "Edit text", "{id, caret? (char index; default select all)}", false, |s, p| { let id = req_id("text.edit.begin", p, "id")?; let caret = num(p, "caret").map(|c| c.max(0.0) as usize); @@ -183,11 +201,17 @@ pub fn specs() -> Vec { textedit::insert(s, &t)?; ok() }), - spec!("text.edit.key", "Text key", "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Escape|SelectAll, shift?, alt?, cmd?}", true, |s, p| { - let k = req_string("text.edit.key", p, "key")?; - textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?; - ok() - }), + spec!( + "text.edit.key", + "Text key", + "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Escape|SelectAll, shift?, alt?, cmd?}", + true, + |s, p| { + let k = req_string("text.edit.key", p, "key")?; + textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?; + ok() + } + ), spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| { let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize; textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 40af1c42..f8eb4feb 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -30,7 +30,11 @@ fn zoom_to(s: &mut Session, z: f64) -> Result { const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]; fn step(z: f64, up: bool) -> f64 { - if up { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02) } + if up { + STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) + } else { + STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02) + } } fn zoom_frame(s: &mut Session, dir: i64) -> Result { @@ -174,11 +178,17 @@ pub fn specs() -> Vec { spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")), spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")), spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "dialog.libraries")), - spec!("tool.set", "Choose tool", "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment}", false, |s, p| { - let t = req_string("tool.set", p, "tool")?; - let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?; - tool(s, t) - }), + spec!( + "tool.set", + "Choose tool", + "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment}", + false, + |s, p| { + let t = req_string("tool.set", p, "tool")?; + let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?; + tool(s, t) + } + ), spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)), spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)), spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)), diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index c72b362d..e1467819 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -273,7 +273,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) { figcraft_layout::apply_constraints(doc, inst, cw, ch); } // Nested instances inside the copy. - let nested: Vec = doc.subtree(inst).into_iter().filter(|i| *i != inst && doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))).collect(); + let nested: Vec = + doc.subtree(inst).into_iter().filter(|i| *i != inst && doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))).collect(); for n in nested { // Only re-sync nested instances whose main was swapped by a property or override. let needs = doc.node(n).is_some_and(|nn| nn.overrides.contains("component") || !nn.prop_refs.is_empty()); @@ -347,7 +348,11 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] // 1. Overrides for layers edited inside instances. for id in changed { let Some(n) = doc.node(*id) else { continue }; - if n.source.is_none() || matches!(n.kind, NodeKind::Instance { .. }) && owning_instance(doc, *id) == Some(*id) && n.parent.is_some_and(|p| owning_instance(doc, p).is_none()) { + if n.source.is_none() + || matches!(n.kind, NodeKind::Instance { .. }) + && owning_instance(doc, *id) == Some(*id) + && n.parent.is_some_and(|p| owning_instance(doc, p).is_none()) + { // Root instances: switching the main is not an override; other edits are. if let (Some(b), Some(n)) = (before.node(*id), doc.node(*id)) && matches!(n.kind, NodeKind::Instance { .. }) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 51df1b08..bda376cc 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -57,10 +57,13 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("effects".into(), serde_json::to_value(&n.effects).unwrap_or(Value::Null)); } if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert("cornerRadius".into(), match n.corners.uniform() { - Some(r) => json!(r), - None => json!(n.corners.radii), - }); + o.insert( + "cornerRadius".into(), + match n.corners.uniform() { + Some(r) => json!(r), + None => json!(n.corners.radii), + }, + ); if n.corners.smoothing > 0.0 { o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); } @@ -198,11 +201,8 @@ fn paints(p: &[figcraft_color::Paint]) -> Value { pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { let Ok(ds) = s.ds() else { return json!({"documents": 0}) }; let doc = &ds.doc; - let pages: Vec = doc - .pages - .iter() - .map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }) - .collect(); + let pages: Vec = + doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); json!({ "name": doc.name, "path": ds.path, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f51ecf1a..264e63c3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod params; pub mod snap; pub mod textedit; pub mod tools; +pub mod vectoredit; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_doc as doc; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b6348fe6..ef97362a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -130,8 +130,18 @@ fn components_and_instances_sync() { #[test] fn variants_switch() { let mut s = sess(); - let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}})).unwrap()); - let b = id(&s.execute("node.create", &json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}})).unwrap()); + let a = id(&s + .execute( + "node.create", + &json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}}), + ) + .unwrap()); + let b = id(&s + .execute( + "node.create", + &json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}}), + ) + .unwrap()); s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap(); let i = id(&s.execute("instance.create", &json!({"component": a, "x": 0, "y": 200})).unwrap()); s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Hover"}})).unwrap(); @@ -153,7 +163,8 @@ fn styles_and_variables() { assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF"); // Variables with modes. s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); - s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}})) + .unwrap(); s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap(); assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FFFFFF"); let page = s.page().unwrap(); @@ -240,7 +251,18 @@ fn every_command_survives_junk_params() { // which would fail this test). let junk = [json!({}), json!(null), json!({"ids": ["x", -1, 99999]}), json!({"id": "nope", "x": "NaN", "props": 5}), json!([1, 2, 3])]; for spec in all_commands() { - if matches!(spec.id, "file.open" | "file.save" | "file.saveAs" | "file.import" | "file.export" | "file.exportSvg" | "file.placeImage" | "image.place" | "file.revert") { + if matches!( + spec.id, + "file.open" + | "file.save" + | "file.saveAs" + | "file.import" + | "file.export" + | "file.exportSvg" + | "file.placeImage" + | "image.place" + | "file.revert" + ) { continue; } for j in &junk { @@ -260,3 +282,64 @@ fn parity_catalog_mostly_covered() { assert!(total > 150); assert!(done as f64 / total as f64 > 0.8, "{done}/{total} missing: {missing:?}"); } + +#[test] +fn export_frames_to_pdf() { + let mut s = sess(); + let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 80})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let r = s.execute("file.exportFramesPdf", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + let text = String::from_utf8_lossy(&bytes); + assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2); + assert!(text.contains("/MediaBox[0 0 200 100]") && text.contains("/MediaBox[0 0 120 80]")); + // file.export with format pdf: frames get a page each, other nodes share one. + let r = s.execute("file.export", &json!({"ids": [a, b], "format": "pdf"})).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2); + // A page without frames is refused, not panicking. + s.execute("page.add", &json!({})).ok(); + let empty = s.doc().unwrap().pages.last().copied().unwrap(); + assert!(s.execute("file.exportFramesPdf", &json!({"page": empty})).is_err()); + assert!(s.execute("file.exportFramesPdf", &json!({"page": a})).is_err()); +} + +#[test] +fn vector_edit_mode() { + let mut s = sess(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("vector.edit.begin", &json!({"id": r})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().kind.type_name(), "VECTOR"); + // Move one corner out: the box grows. + s.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap(); + s.execute("vector.edit.moveVertices", &json!({"dx": 50, "dy": 50})).unwrap(); + let b = s.doc().unwrap().world_bounds(r).unwrap(); + assert_eq!((b.width(), b.height()), (150.0, 150.0)); + // Add a point on segment 0 and delete-heal it. + let v = s.execute("vector.edit.addVertex", &json!({"segment": 0, "t": 0.5})).unwrap(); + assert!(v["vertex"].as_u64().is_some()); + let n0 = match &s.doc().unwrap().node(r).unwrap().kind { + NodeKind::Vector { network } => network.vertices.len(), + _ => 0, + }; + s.execute("vector.deleteHeal", &json!({})).unwrap(); + let n1 = match &s.doc().unwrap().node(r).unwrap().kind { + NodeKind::Vector { network } => (network.vertices.len(), network.regions.len()), + _ => (0, 0), + }; + assert_eq!(n1, (n0 - 1, 1), "healed and still closed"); + // Pointer: drag a vertex in edit mode. + let before = s.doc().unwrap().world_bounds(r).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, -20.0, 0.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, -20.0, 0.0, 1.0)).unwrap(); + let after = s.doc().unwrap().world_bounds(r).unwrap(); + assert_eq!(after.x0, before.x0 - 20.0); + s.execute("vector.edit.end", &json!({})).unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none()); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 1728fa02..d67d7ee9 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -129,10 +129,22 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R let (a, b) = if a != b { (a, b) } else if key == "Backspace" { - let from = if line { line_start(&chars, a) } else if word { word_left(&chars, a) } else { a.saturating_sub(1) }; + let from = if line { + line_start(&chars, a) + } else if word { + word_left(&chars, a) + } else { + a.saturating_sub(1) + }; (from, a) } else { - let to = if line { line_end(&chars, a) } else if word { word_right(&chars, a) } else { (a + 1).min(n) }; + let to = if line { + line_end(&chars, a) + } else if word { + word_right(&chars, a) + } else { + (a + 1).min(n) + }; (a, to) }; t.replace_range(a, b, ""); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 15354af7..8247c843 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -124,7 +124,18 @@ impl ToolId { } } fn draws_shape(self) -> bool { - matches!(self, ToolId::Frame | ToolId::Section | ToolId::Slice | ToolId::Rectangle | ToolId::Ellipse | ToolId::Polygon | ToolId::Star | ToolId::Line | ToolId::Arrow) + matches!( + self, + ToolId::Frame + | ToolId::Section + | ToolId::Slice + | ToolId::Rectangle + | ToolId::Ellipse + | ToolId::Polygon + | ToolId::Star + | ToolId::Line + | ToolId::Arrow + ) } } @@ -269,6 +280,26 @@ enum Gesture { PenDrag { anchor: Point, }, + VertexDrag { + start: Point, + id: NodeId, + verts: Vec, + }, + HandleDrag { + id: NodeId, + seg: usize, + start_end: bool, + }, + Bend { + id: NodeId, + seg: usize, + t: f64, + }, + VertexMarquee { + start: Point, + id: NodeId, + base: Vec, + }, } /// The pen tool's in-progress path (page coordinates). @@ -395,6 +426,17 @@ impl Session { /// Feed one pointer event to the current tool. pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { let tool = if ev.kind == PointerKind::Down && ev.ctrl && !ev.cmd && false { ToolId::Hand } else { self.tool.tool }; + if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().map(|d| d.vector_edit.is_some()).unwrap_or(false) { + match ev.kind { + PointerKind::Down => return self.vector_down(ev), + PointerKind::DoubleClick => return crate::vectoredit::end(self), + PointerKind::Hover => { + self.tool.overlay.cursor = Cursor::Pen; + return Ok(()); + } + _ => {} + } + } match ev.kind { PointerKind::Hover => self.hover(ev), PointerKind::Down => match tool { @@ -500,11 +542,16 @@ impl Session { self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; return Ok(()); } - let nodes: Vec<(NodeId, Affine, f64, f64)> = ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); let single = if ids.len() == 1 { nodes.first().map(|(_, w, a, b)| (*w, *a, *b)) } else { None }; let bounds = doc.bounds_of(&ids).unwrap_or_default(); self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; - self.tool.gesture = Gesture::Resize { start: ev.p(), r: Box::new(ResizeStart { nodes, single, bounds, handle: h }), scale_tool: self.tool.tool == ToolId::Scale }; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, single, bounds, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + }; return Ok(()); } let leaf = doc.hit_test(page, ev.p(), ev.tol() * 2.0); @@ -619,7 +666,11 @@ impl Session { *sel = moving.clone(); } // Auto layout children reorder instead of moving freely. - let al_parent = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|pn| pn.layout.is_some())); + let al_parent = moving + .first() + .and_then(|i| doc.node(*i)) + .and_then(|n| n.parent) + .filter(|p| doc.node(*p).is_some_and(|pn| pn.layout.is_some())); let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)); if let (Some(pp), true) = (al_parent, flow) { let pb = doc.world_bounds(pp).unwrap_or_default(); @@ -639,8 +690,15 @@ impl Session { } } // Indicator line before the target slot. - let line_at = kids.get(idx).and_then(|c| doc.world_bounds(*c)).map(|b| if vertical { [b.x0, b.y0 - 2.0, b.x1, b.y0 - 2.0] } else { [b.x0 - 2.0, b.y0, b.x0 - 2.0, b.y1] }); - let line_at = line_at.or_else(|| kids.last().and_then(|c| doc.world_bounds(*c)).map(|b| if vertical { [b.x0, b.y1 + 2.0, b.x1, b.y1 + 2.0] } else { [b.x1 + 2.0, b.y0, b.x1 + 2.0, b.y1] })); + let line_at = kids + .get(idx) + .and_then(|c| doc.world_bounds(*c)) + .map(|b| if vertical { [b.x0, b.y0 - 2.0, b.x1, b.y0 - 2.0] } else { [b.x0 - 2.0, b.y0, b.x0 - 2.0, b.y1] }); + let line_at = line_at.or_else(|| { + kids.last() + .and_then(|c| doc.world_bounds(*c)) + .map(|b| if vertical { [b.x0, b.y1 + 2.0, b.x1, b.y1 + 2.0] } else { [b.x1 + 2.0, b.y0, b.x1 + 2.0, b.y1] }) + }); insert_line = line_at; let mut new_kids = kids.clone(); for (k, m) in moving.iter().enumerate() { @@ -718,6 +776,60 @@ impl Session { textedit::set_caret(self, idx, true)?; Ok(()) } + Gesture::VertexDrag { start, id, verts } => { + let (id, verts) = (*id, verts.clone()); + let w = self.doc()?.world_transform(id); + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Page delta → local delta. + let inv = w.inverse(); + let ld = inv * (Point::ZERO + d) - inv * Point::ZERO; + self.preview(|doc, _| { + crate::vectoredit::move_vertices(doc, id, &verts, ld)?; + crate::vectoredit::refit(doc, id) + })?; + Ok(()) + } + Gesture::HandleDrag { id, seg, start_end } => { + let (id, seg, se) = (*id, *seg, *start_end); + let lp = self.doc()?.world_transform(id).inverse() * ev.p(); + let alt = ev.alt; + self.preview(|doc, _| { + crate::vectoredit::set_handle(doc, id, seg, se, lp, alt)?; + crate::vectoredit::refit(doc, id) + })?; + Ok(()) + } + Gesture::Bend { id, seg, t } => { + let (id, seg, t) = (*id, *seg, *t); + let lp = self.doc()?.world_transform(id).inverse() * ev.p(); + self.preview(|doc, _| { + crate::vectoredit::bend(doc, id, seg, t, lp)?; + crate::vectoredit::refit(doc, id) + })?; + Ok(()) + } + Gesture::VertexMarquee { start, id, base } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + let mut v = base.clone(); + for i in crate::vectoredit::vertices_in(self.doc()?, *id, r) { + if !v.contains(&i) { + v.push(i); + } + } + let id = *id; + let ds = self.ds_mut()?; + ds.vector_edit = Some((id, v)); + ds.revision += 1; + Ok(()) + } Gesture::PenDrag { anchor } => { let out = ev.p() - *anchor; if let Some(pen) = self.tool.pen.as_mut() @@ -778,7 +890,8 @@ impl Session { Handle::Edge(_) => x0 = q.x, Handle::Rotate(_) => {} } - let is_line = r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + let is_line = + r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); if is_line { // Line end handles: move the end point freely (length + angle). let (a, b) = match r.handle { @@ -802,7 +915,9 @@ impl Session { return Ok(()); } // Constrain proportions (Shift, or the node's lock). - let keep = ev.shift || r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false) || scale_tool; + let keep = ev.shift + || r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false) + || scale_tool; if keep && w0 > 1e-9 && h0 > 1e-9 { let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0); let k = match r.handle { @@ -890,10 +1005,10 @@ impl Session { let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; let mut t = *world * Affine::translate((nx0, ny0)); if flip_x { - t = t * Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); } if flip_y { - t = t * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } doc.set_world_transform(*id, t)?; if scale_tool { @@ -948,7 +1063,9 @@ impl Session { } // Frames drawn inside frames nest; sections only at the top or in sections. let parent = match self.tool.tool { - ToolId::Section => frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page), + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), }; let targets = Targets::gather(&doc, page, &[]); @@ -1049,7 +1166,8 @@ impl Session { let sel = self.selection(); let moving = if duplicate { sel } else { ids }; let doc = self.doc()?.clone(); - let in_al = moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); if !in_al { let target = drop_target.unwrap_or(page); let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); @@ -1120,6 +1238,8 @@ impl Session { } Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } => {} + Gesture::VertexDrag { .. } | Gesture::HandleDrag { .. } | Gesture::Bend { .. } => self.commit_interaction()?, + Gesture::VertexMarquee { .. } => {} } if let Ok(ds) = self.ds_mut() { ds.revision += 1; @@ -1136,14 +1256,21 @@ impl Session { let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. - if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { + if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { let idx = self.text_index_at(target, ev.p())?; textedit::begin(self, target, Some(idx), false)?; textedit::select_word(self, idx)?; return Ok(()); } + // Shapes and vectors: enter vector edit mode. + if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { + return crate::vectoredit::begin(self, target); + } // Groups/frames: select one level deeper (the child under the pointer). - let deeper = select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::>(), false); + let deeper = + select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::>(), false); if deeper != target || !sel.contains(&deeper) { self.set_selection(vec![deeper]); } @@ -1213,7 +1340,12 @@ impl Session { let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; let rgba = figcraft_render::to_rgba8(&pix); - let c = figcraft_color::Color::from_rgba8(rgba.first().copied().unwrap_or(0), rgba.get(1).copied().unwrap_or(0), rgba.get(2).copied().unwrap_or(0), 255); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); let sel = ds.selection.clone(); if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; @@ -1376,6 +1508,79 @@ impl Session { Ok(()) } + fn vector_down(&mut self, ev: &PointerEvent) -> Result<()> { + let Some((id, sel)) = self.ds()?.vector_edit.clone() else { return Ok(()) }; + let doc = self.doc()?.clone(); + let tol = 6.0 / ev.zoom.max(1e-6); + match crate::vectoredit::hit(&doc, id, &sel, ev.p(), tol) { + Some(crate::vectoredit::VHit::Vertex(v)) => { + let verts = if ev.shift { + let mut s2 = sel.clone(); + if let Some(i) = s2.iter().position(|x| *x == v) { + s2.remove(i); + } else { + s2.push(v); + } + s2 + } else if sel.contains(&v) { + sel.clone() + } else { + vec![v] + }; + let ds = self.ds_mut()?; + ds.vector_edit = Some((id, verts.clone())); + self.begin_interaction("Move points")?; + self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts }; + } + Some(crate::vectoredit::VHit::Handle { seg, start }) => { + self.begin_interaction("Edit handle")?; + self.tool.gesture = Gesture::HandleDrag { id, seg, start_end: start }; + } + Some(crate::vectoredit::VHit::Segment { seg, t }) => { + if ev.cmd { + self.begin_interaction("Bend")?; + self.tool.gesture = Gesture::Bend { id, seg, t }; + } else if ev.alt || ev.shift { + // Add a point on the segment. + self.begin_interaction("Add point")?; + let mut v = 0; + self.preview(|doc, _| { + v = crate::vectoredit::split_segment(doc, id, seg, t)?; + Ok(()) + })?; + let ds = self.ds_mut()?; + ds.vector_edit = Some((id, vec![v])); + self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts: vec![v] }; + } else { + // Select the segment's end points and drag them together. + let net_seg = match &doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Vector { network }) => network.segments.get(seg).copied(), + _ => None, + }; + let verts: Vec = net_seg.map(|s| vec![s.a, s.b]).unwrap_or_default(); + let ds = self.ds_mut()?; + ds.vector_edit = Some((id, verts.clone())); + self.begin_interaction("Move segment")?; + self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts }; + } + } + None => { + // Outside the shape entirely (and not shift): leave edit mode. + let inside = doc.world_render_bounds(id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p())); + if !inside && !ev.shift { + crate::vectoredit::end(self)?; + return self.move_down(ev); + } + let base = if ev.shift { sel } else { vec![] }; + let ds = self.ds_mut()?; + ds.vector_edit = Some((id, base.clone())); + ds.revision += 1; + self.tool.gesture = Gesture::VertexMarquee { start: ev.p(), id, base }; + } + } + Ok(()) + } + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect. pub fn escape(&mut self) -> Result<()> { if self.tool.pen.is_some() { @@ -1389,6 +1594,9 @@ impl Session { if self.ds()?.text_edit.is_some() { return textedit::end(self); } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::end(self); + } if self.tool.tool != ToolId::Move { self.set_tool(ToolId::Move); return Ok(()); @@ -1396,7 +1604,8 @@ impl Session { // Escape selects the parent (like Figma), then nothing. let doc = self.doc()?; let sel = self.selection(); - let parents: Vec = sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); if !parents.is_empty() { let mut uniq = Vec::new(); for p in parents { diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs new file mode 100644 index 00000000..f38adb62 --- /dev/null +++ b/crates/engine/src/vectoredit.rs @@ -0,0 +1,481 @@ +//! Vector edit mode: editing a vector network's vertices and bezier handles on the canvas. +//! +//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent). +//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the +//! network again. + +use figcraft_doc::*; +use figcraft_geom::{Affine, Point, Rect, Vec2}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{EngineError, Result, Session, bad, spec}; + +/// What a pointer is over in edit mode. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum VHit { + Vertex(usize), + /// Handle of segment `seg`; `start` = the handle at the segment's `a` end. + Handle { + seg: usize, + start: bool, + }, + Segment { + seg: usize, + t: f64, + }, +} + +/// Convert a shape to an editable vector (keeps paints, transform and look). +pub fn ensure_vector(doc: &mut Document, id: NodeId) -> Result<()> { + let n = doc.get(id)?.clone(); + if matches!(n.kind, NodeKind::Vector { .. }) { + return Ok(()); + } + if !n.kind.is_shape() && !matches!(n.kind, NodeKind::BooleanOp { .. }) { + return Err(EngineError::Other(format!("{} layers can't be edited as vectors", n.kind.label()))); + } + let (path, rule) = figcraft_render::node_outline(doc, &n).ok_or_else(|| EngineError::Other("no outline".into()))?; + let mut net = VectorNetwork::from_path(&path); + if matches!(n.kind, NodeKind::Line) { + net.regions.clear(); + } + if rule == figcraft_render::FillRule::EvenOdd { + for r in &mut net.regions { + r.winding = WindingRule::EvenOdd; + } + } + let node = doc.get_mut(id)?; + node.kind = NodeKind::Vector { network: net }; + node.children.clear(); + if matches!(n.kind, NodeKind::Line) { + node.stroke.align = StrokeAlign::Center; + } + node.corners = Corners::default(); + refit(doc, id)?; + Ok(()) +} + +/// Make the node's box wrap its network again (moving the node so nothing shifts on screen). +pub fn refit(doc: &mut Document, id: NodeId) -> Result<()> { + let n = doc.get_mut(id)?; + let NodeKind::Vector { network } = &mut n.kind else { return Ok(()) }; + let Some(b) = network.bounds() else { return Ok(()) }; + if !(b.x0.is_finite() && b.y0.is_finite()) { + return Ok(()); + } + if b.x0.abs() > 1e-9 || b.y0.abs() > 1e-9 { + network.translate(-b.x0, -b.y0); + n.transform *= Affine::translate((b.x0, b.y0)); + } + n.width = b.width(); + n.height = b.height(); + Ok(()) +} + +fn network(doc: &Document, id: NodeId) -> Option<&VectorNetwork> { + match &doc.node(id)?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> { + match &mut doc.node_mut(id)?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +/// Hit test in page coordinates with a tolerance in page units. Handles of selected vertices win, +/// then vertices, then segments. +pub fn hit(doc: &Document, id: NodeId, selected: &[usize], p: Point, tol: f64) -> Option { + let net = network(doc, id)?; + let w = doc.world_transform(id); + let lp = w.inverse() * p; + // Tolerance in local units (approximate for non-uniform scale). + let s = (w.determinant().abs()).sqrt().max(1e-9); + let tl = tol / s; + for (si, seg) in net.segments.iter().enumerate() { + for (start, vi, t) in [(true, seg.a, seg.ta), (false, seg.b, seg.tb)] { + if !selected.contains(&vi) || t == (0.0, 0.0) { + continue; + } + let Some(v) = net.vertices.get(vi) else { continue }; + if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl { + return Some(VHit::Handle { seg: si, start }); + } + } + } + for (vi, v) in net.vertices.iter().enumerate() { + if (v.point() - lp).hypot() <= tl { + return Some(VHit::Vertex(vi)); + } + } + use figcraft_geom::kurbo::{CubicBez, ParamCurveNearest}; + for (si, seg) in net.segments.iter().enumerate() { + let (Some(a), Some(b)) = (net.vertices.get(seg.a), net.vertices.get(seg.b)) else { continue }; + let c = CubicBez::new(a.point(), Point::new(a.x + seg.ta.0, a.y + seg.ta.1), Point::new(b.x + seg.tb.0, b.y + seg.tb.1), b.point()); + let near = c.nearest(lp, 0.01); + if near.distance_sq.sqrt() <= tl { + return Some(VHit::Segment { seg: si, t: near.t }); + } + } + None +} + +/// Move vertices by `d` (local units); their handles move with them. +pub fn move_vertices(doc: &mut Document, id: NodeId, idx: &[usize], d: Vec2) -> Result<()> { + let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?; + for i in idx { + if let Some(v) = net.vertices.get_mut(*i) { + v.x += d.x; + v.y += d.y; + } + } + Ok(()) +} + +/// Set a handle to absolute local position `p`; the opposite handle at the same vertex mirrors +/// (angle and length) unless `independent`. +pub fn set_handle(doc: &mut Document, id: NodeId, seg: usize, start: bool, p: Point, independent: bool) -> Result<()> { + let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?; + let Some(s) = net.segments.get(seg).copied() else { return Err(EngineError::Other("no such segment".into())) }; + let vi = if start { s.a } else { s.b }; + let Some(v) = net.vertices.get(vi).copied() else { return Ok(()) }; + let rel = (p.x - v.x, p.y - v.y); + if let Some(sm) = net.segments.get_mut(seg) { + if start { + sm.ta = rel; + } else { + sm.tb = rel; + } + } + if !independent { + // Mirror on the other segment meeting at this vertex (smooth node). + let others: Vec<(usize, bool)> = net + .segments + .iter() + .enumerate() + .filter(|(i, _)| *i != seg) + .filter_map(|(i, o)| { + if o.a == vi { + Some((i, true)) + } else if o.b == vi { + Some((i, false)) + } else { + None + } + }) + .collect(); + if let [(oi, ostart)] = others.as_slice() + && let Some(o) = net.segments.get_mut(*oi) + { + let m = (-rel.0, -rel.1); + if *ostart { + o.ta = m; + } else { + o.tb = m; + } + } + } + Ok(()) +} + +/// Bend a segment so its curve passes through `p` at parameter `t` (the Bend tool). +pub fn bend(doc: &mut Document, id: NodeId, seg: usize, t: f64, p: Point) -> Result<()> { + let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?; + let Some(s) = net.segments.get(seg).copied() else { return Ok(()) }; + let (Some(a), Some(b)) = (net.vertices.get(s.a).copied(), net.vertices.get(s.b).copied()) else { return Ok(()) }; + let t = t.clamp(0.05, 0.95); + // Controls at the chord's thirds plus a shared offset: B(t) = L(t) + 3t(1-t)·off. + let (a, b) = (a.point(), b.point()); + let k = 3.0 * t * (1.0 - t); + let line = a.lerp(b, t); + let off = (p - line) / k; + let c1 = a + (b - a) / 3.0 + off; + let c2 = b + (a - b) / 3.0 + off; + if let Some(sm) = net.segments.get_mut(seg) { + sm.ta = (c1.x - a.x, c1.y - a.y); + sm.tb = (c2.x - b.x, c2.y - b.y); + } + Ok(()) +} + +/// Delete vertices, joining their neighbours where a vertex had exactly two segments ("heal"). +pub fn delete_vertices(doc: &mut Document, id: NodeId, idx: &[usize], heal: bool) -> Result<()> { + let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?; + let mut idx: Vec = idx.to_vec(); + idx.sort_unstable(); + idx.dedup(); + for &vi in idx.iter().rev() { + if vi >= net.vertices.len() { + continue; + } + let touching: Vec = net.segments.iter().enumerate().filter(|(_, s)| s.a == vi || s.b == vi).map(|(i, _)| i).collect(); + let mut new_seg = None; + if heal && let [s1, s2] = touching.as_slice() { + let (Some(x), Some(y)) = (net.segments.get(*s1).copied(), net.segments.get(*s2).copied()) else { continue }; + let o1 = if x.a == vi { (x.b, x.tb) } else { (x.a, x.ta) }; + let o2 = if y.a == vi { (y.b, y.tb) } else { (y.a, y.ta) }; + new_seg = Some(Segment { a: o1.0, b: o2.0, ta: o1.1, tb: o2.1 }); + } + // Remove touching segments (descending) and fix region loops. + let mut seg_map: Vec> = (0..net.segments.len()).map(Some).collect(); + for &si in touching.iter().rev() { + net.segments.remove(si); + if let Some(m) = seg_map.get_mut(si) { + *m = None; + } + } + for (k, m) in seg_map.iter_mut().flatten().enumerate() { + *m = k; + } + let healed_index = new_seg.map(|s| { + net.segments.push(s); + net.segments.len() - 1 + }); + for r in &mut net.regions { + for lp in &mut r.loops { + let mut out = Vec::new(); + let mut inserted = false; + for si in lp.iter() { + match seg_map.get(*si).copied().flatten() { + Some(n) => out.push(n), + None => { + if let (Some(h), false) = (healed_index, inserted) { + out.push(h); + inserted = true; + } + } + } + } + *lp = out; + } + r.loops.retain(|l| l.len() >= 2); + } + net.regions.retain(|r| !r.loops.is_empty()); + // Remove the vertex and shift indices. + net.vertices.remove(vi); + for s in &mut net.segments { + if s.a > vi { + s.a -= 1; + } + if s.b > vi { + s.b -= 1; + } + } + } + net.sanitize(); + Ok(()) +} + +/// Split segment `seg` at `t`, inserting a vertex; returns its index. +pub fn split_segment(doc: &mut Document, id: NodeId, seg: usize, t: f64) -> Result { + use figcraft_geom::kurbo::{CubicBez, ParamCurve}; + let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?; + let s = net.segments.get(seg).copied().ok_or_else(|| EngineError::Other("no such segment".into()))?; + let (Some(a), Some(b)) = (net.vertices.get(s.a).copied(), net.vertices.get(s.b).copied()) else { + return Err(EngineError::Other("bad segment".into())); + }; + let c = CubicBez::new(a.point(), Point::new(a.x + s.ta.0, a.y + s.ta.1), Point::new(b.x + s.tb.0, b.y + s.tb.1), b.point()); + let t = t.clamp(0.01, 0.99); + let l = c.subsegment(0.0..t); + let r = c.subsegment(t..1.0); + net.vertices.push(Vertex::new(l.p3.x, l.p3.y)); + let mid = net.vertices.len() - 1; + let straight = s.is_straight(); + if let Some(sm) = net.segments.get_mut(seg) { + sm.b = mid; + if straight { + sm.ta = (0.0, 0.0); + sm.tb = (0.0, 0.0); + } else { + sm.ta = (l.p1.x - l.p0.x, l.p1.y - l.p0.y); + sm.tb = (l.p2.x - l.p3.x, l.p2.y - l.p3.y); + } + } + let new_seg = if straight { + Segment::line(mid, s.b) + } else { + Segment { a: mid, b: s.b, ta: (r.p1.x - r.p0.x, r.p1.y - r.p0.y), tb: (r.p2.x - r.p3.x, r.p2.y - r.p3.y) } + }; + net.segments.push(new_seg); + let ni = net.segments.len() - 1; + for reg in &mut net.regions { + for lp in &mut reg.loops { + if let Some(pos) = lp.iter().position(|x| *x == seg) { + lp.insert(pos + 1, ni); + } + } + } + Ok(mid) +} + +/// Vertices inside a page-space rectangle. +pub fn vertices_in(doc: &Document, id: NodeId, r: Rect) -> Vec { + let Some(net) = network(doc, id) else { return vec![] }; + let w = doc.world_transform(id); + net.vertices.iter().enumerate().filter(|(_, v)| r.contains(w * v.point())).map(|(i, _)| i).collect() +} + +/// Page-space geometry of the network for drawing the edit overlay: segments (as cubic control +/// points), vertices, and handles of the selected vertices. +pub fn overlay(doc: &Document, id: NodeId, selected: &[usize]) -> Option { + let net = network(doc, id)?; + let w = doc.world_transform(id); + let p = |x: f64, y: f64| { + let q = w * Point::new(x, y); + json!([q.x, q.y]) + }; + let segs: Vec = net + .segments + .iter() + .filter_map(|s| { + let (a, b) = (net.vertices.get(s.a)?, net.vertices.get(s.b)?); + Some(json!([p(a.x, a.y), p(a.x + s.ta.0, a.y + s.ta.1), p(b.x + s.tb.0, b.y + s.tb.1), p(b.x, b.y)])) + }) + .collect(); + let verts: Vec = net.vertices.iter().map(|v| p(v.x, v.y)).collect(); + let mut handles = Vec::new(); + for s in &net.segments { + for (vi, t) in [(s.a, s.ta), (s.b, s.tb)] { + if selected.contains(&vi) + && t != (0.0, 0.0) + && let Some(v) = net.vertices.get(vi) + { + handles.push(json!([p(v.x, v.y), p(v.x + t.0, v.y + t.1)])); + } + } + } + Some(json!({"segments": segs, "vertices": verts, "handles": handles, "selected": selected})) +} + +/// Start editing `id` as a vector. +pub fn begin(s: &mut Session, id: NodeId) -> Result<()> { + let before = s.ds()?.doc.clone(); + { + let doc = s.doc_mut()?; + ensure_vector(doc, id)?; + } + let ds = s.ds_mut()?; + if !std::sync::Arc::ptr_eq(&before, &ds.doc) { + ds.history.undo.push(crate::HistoryEntry { label: "Edit vector".into(), doc: before, selection: ds.selection.clone(), page: ds.page }); + ds.history.redo.clear(); + } + ds.vector_edit = Some((id, vec![])); + ds.selection = vec![id]; + ds.text_edit = None; + ds.revision += 1; + Ok(()) +} + +pub fn end(s: &mut Session) -> Result<()> { + let ds = s.ds_mut()?; + ds.vector_edit = None; + ds.revision += 1; + Ok(()) +} + +fn editing(s: &Session) -> Result<(NodeId, Vec)> { + s.ds()?.vector_edit.clone().ok_or_else(|| EngineError::Disabled("vector".into(), "not in vector edit mode".into())) +} + +pub fn specs() -> Vec { + vec![ + spec!("vector.edit.begin", "Edit vector", "{id?} (converts shapes to vectors)", false, |s, p| { + let id = targets(s, p).first().copied().ok_or_else(|| bad("vector.edit.begin", "select a shape"))?; + begin(s, id)?; + Ok(json!({"id": id})) + }), + spec!("vector.edit.end", "Done editing", "{}", false, |s, _| { + end(s)?; + Ok(json!({"ok": true})) + }), + spec!("vector.edit.state", "Vector edit state", "{}", false, |s, _| { + let (id, sel) = editing(s)?; + Ok(overlay(s.doc()?, id, &sel).unwrap_or(Value::Null)) + }), + spec!("vector.edit.select", "Select vertices", "{vertices: [i, ...]}", false, |s, p| { + let (id, _) = editing(s)?; + let v: Vec = match get(p, "vertices") { + Some(Value::Array(a)) => a.iter().filter_map(f64_of).map(|x| x.max(0.0) as usize).collect(), + _ => vec![], + }; + let ds = s.ds_mut()?; + ds.vector_edit = Some((id, v)); + ds.revision += 1; + Ok(json!({"ok": true})) + }), + spec!("vector.edit.moveVertices", "Move vertices", "{vertices?, dx, dy} (local units; default the selected vertices)", true, |s, p| { + let (id, sel) = editing(s)?; + let v: Vec = match get(p, "vertices") { + Some(Value::Array(a)) => a.iter().filter_map(f64_of).map(|x| x.max(0.0) as usize).collect(), + _ => sel, + }; + let d = Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + let doc = s.doc_mut()?; + move_vertices(doc, id, &v, d)?; + refit(doc, id)?; + Ok(json!({"ok": true})) + }), + spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| { + let (id, _) = editing(s)?; + let seg = req_num("vector.edit.setHandle", p, "segment")?.max(0.0) as usize; + let start = boolean(p, "start").unwrap_or(true); + let pt = Point::new(req_num("vector.edit.setHandle", p, "x")?, req_num("vector.edit.setHandle", p, "y")?); + let doc = s.doc_mut()?; + set_handle(doc, id, seg, start, pt, boolean(p, "independent").unwrap_or(false))?; + refit(doc, id)?; + Ok(json!({"ok": true})) + }), + spec!("vector.edit.addVertex", "Add point", "{segment, t (0..1)}", true, |s, p| { + let (id, _) = editing(s)?; + let seg = req_num("vector.edit.addVertex", p, "segment")?.max(0.0) as usize; + let t = num_or(p, "t", 0.5); + let v = split_segment(s.doc_mut()?, id, seg, t)?; + let ds = s.ds_mut()?; + ds.vector_edit = Some((id, vec![v])); + Ok(json!({"vertex": v})) + }), + spec!("vector.deleteHeal", "Delete and heal selection", "{vertices?}", true, |s, p| { + let (id, sel) = editing(s)?; + let v: Vec = match get(p, "vertices") { + Some(Value::Array(a)) => a.iter().filter_map(f64_of).map(|x| x.max(0.0) as usize).collect(), + _ => sel, + }; + let doc = s.doc_mut()?; + delete_vertices(doc, id, &v, true)?; + refit(doc, id)?; + let ds = s.ds_mut()?; + ds.vector_edit = Some((id, vec![])); + Ok(json!({"ok": true})) + }), + spec!("vector.edit.delete", "Delete points", "{vertices?}", true, |s, p| { + let (id, sel) = editing(s)?; + let v: Vec = match get(p, "vertices") { + Some(Value::Array(a)) => a.iter().filter_map(f64_of).map(|x| x.max(0.0) as usize).collect(), + _ => sel, + }; + let doc = s.doc_mut()?; + delete_vertices(doc, id, &v, false)?; + refit(doc, id)?; + let ds = s.ds_mut()?; + ds.vector_edit = Some((id, vec![])); + Ok(json!({"ok": true})) + }), + spec!("vector.edit.cornerRadius", "Point corner radius", "{radius, vertices?}", true, |s, p| { + let (id, sel) = editing(s)?; + let r = req_num("vector.edit.cornerRadius", p, "radius")?.max(0.0); + if let Some(net) = network_mut(s.doc_mut()?, id) { + for i in sel { + if let Some(v) = net.vertices.get_mut(i) { + v.radius = r; + } + } + } + Ok(json!({"ok": true})) + }), + ] +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3cf3726e..01f495de 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -130,7 +130,8 @@ pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec), FormatError> continue; } let hash = name.trim_start_matches("images/").to_string(); - let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0)); + let (w, h) = + image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0)); doc.images.insert(hash, ImageData { bytes: Arc::new(data), width: w, height: h }); } Ok((doc, issues)) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a31e8e50..73cab139 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -11,7 +11,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use figcraft_doc::{ - AutoLayout, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize, VConstraint, + AutoLayout, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize, + VConstraint, }; use figcraft_geom::{Affine, Point, Rect}; @@ -158,7 +159,8 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let (w, h) = outer_size(doc, *c); let cl = &n.child_layout; let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) }; - let (min_main, max_main, min_cross, max_cross) = if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; + let (min_main, max_main, min_cross, max_cross) = + if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; items.push(Item { id: *c, main: if horizontal { w } else { h }, @@ -308,7 +310,9 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) { - let is_text_height = doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.auto_resize != TextAutoResize::None && t.auto_resize != TextAutoResize::Truncate)); + let is_text_height = doc + .node(id) + .is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.auto_resize != TextAutoResize::None && t.auto_resize != TextAutoResize::Truncate)); if is_text_height { if let Some(n) = doc.node_mut(id) && let NodeKind::Text(t) = &mut n.kind diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 379e4dce..80de15fe 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -95,7 +95,15 @@ fn grid_mode() { let mut d = Document::new("t"); let p = d.first_page().unwrap(); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 210.0, 110.0).unwrap(); - d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 2, gap: 10.0, cross_gap: 10.0, padding: [0.0; 4], ..Default::default() }); + d.get_mut(f).unwrap().layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 2, + grid_rows: 2, + gap: 10.0, + cross_gap: 10.0, + padding: [0.0; 4], + ..Default::default() + }); let ids: Vec = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect(); layout_frame(&mut d, f, &NoMeasure); assert_eq!(pos(&d, ids[3]).0, 110.0); diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..13c2696d --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "figcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "FigCraft MCP server: JSON-RPC 2.0 over stdio, driving a headless engine session or a running FigCraft app" + +[lints] +workspace = true + +[dependencies] +figcraft-engine = { workspace = true } +figcraft-doc = { workspace = true } +figcraft-geom = { workspace = true } +figcraft-render = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +image = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 00000000..47f4efd3 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,175 @@ +//! Where MCP tool calls end up: a control-channel method call, answered either by an in-process +//! engine session ([`crate::Headless`]) or by a running FigCraft app ([`Attached`]). + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use base64::Engine as _; +use serde_json::{Value, json}; + +/// Something that answers the FigCraft control-channel methods (`engine.execute`, +/// `engine.commands`, `document.inspect`, `node.get`, `ui.pointer`, `ui.key`, `ui.type`, +/// `ui.render`, `ui.screenshot`, `ui.inspect`). See `docs/control-protocol.md`. +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 window is attached (`ui.screenshot` captures the window). + fn has_ui(&self) -> bool; + + /// Short human description ("headless", "attached to 127.0.0.1:7979"). + fn describe(&self) -> String; + + /// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the + /// current page's content. The default goes through `ui.render` and a temporary file. + fn render_png(&mut self, ids: &[u64], scale: f64) -> Result, String> { + let path = temp_png("render"); + let mut params = json!({"path": path, "scale": scale}); + if !ids.is_empty() { + params["ids"] = json!(ids); + } else { + // `ui.render` without ids exports the selection; pass the page's children instead. + let kids = page_children(self)?; + if kids.is_empty() { + return Err("the current page is empty: nothing to render".into()); + } + params["ids"] = json!(kids); + } + self.call("ui.render", params)?; + read_and_remove(&path) + } + + /// PNG bytes of what the user sees: the app window when attached, the current viewport when + /// headless. + fn screenshot_png(&mut self) -> Result, String> { + let path = temp_png("screenshot"); + self.call("ui.screenshot", json!({"path": path}))?; + read_and_remove(&path) + } +} + +/// The ids of the current page's top-level layers. +pub fn page_children(b: &mut B) -> Result, String> { + let doc = b.call("document.inspect", json!({"depth": 1}))?; + let current = doc.get("currentPage").cloned().unwrap_or(Value::Null); + let pages = doc.get("pages").and_then(Value::as_array).cloned().unwrap_or_default(); + let page = pages.iter().find(|p| p.get("id") == Some(¤t)).or_else(|| pages.first()); + Ok(page + .and_then(|p| p.get("children")) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| c.get("id").and_then(Value::as_u64)).collect()) + .unwrap_or_default()) +} + +fn temp_png(what: &str) -> String { + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + std::env::temp_dir().join(format!("figcraft-mcp-{what}-{}-{n}.png", std::process::id())).to_string_lossy().into_owned() +} + +fn read_and_remove(path: &str) -> Result, String> { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let _ = std::fs::remove_file(path); + Ok(bytes) +} + +/// Decode a `data` field (base64, optionally a data: URL) from an export result. +pub fn decode_data(v: &Value) -> Result, String> { + let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?; + let d = d.split_once(',').map(|(_, b)| b).unwrap_or(d); + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| format!("bad base64 from the app: {e}")) +} + +/// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`). +pub struct Attached { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Attached { + /// Connect to `addr` (e.g. `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) + } + + /// Connect to `127.0.0.1:port`. + pub fn port(port: u16) -> std::io::Result { + Self::connect(&format!("127.0.0.1:{port}")) + } + + 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_millis(800)) { + Ok(s) => { + s.set_nodelay(true).ok(); + // The app answers within 60 s (its own timeout); leave headroom. + s.set_read_timeout(Some(Duration::from_secs(90))).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 to the app")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + if reader.read_line(&mut reply)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + } + Ok(reply) + } +} + +impl Backend for Attached { + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 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!("FigCraft app at {} is not reachable: {e} (start it with `figcraft --control PORT`)", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from the 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!("attached to the FigCraft app at {}", self.addr) + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 00000000..e78dc07a --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,309 @@ +//! An in-process engine session that answers the control-channel methods itself, so agents can +//! design, render and export without a window. + +use base64::Engine as _; +use figcraft_doc::{ExportFormat, NodeId}; +use figcraft_engine::{PointerEvent, PointerKind, Session, ToolId, textedit}; +use serde_json::{Value, json}; + +use crate::backend::Backend; + +/// Headless backend: a [`Session`] with one untitled document. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +/// Largest side of a headless page render (pixels), so screenshots stay a sensible size. +const MAX_SIDE: f64 = 4096.0; + +fn b(p: &Value, k: &str) -> bool { + figcraft_engine::params::boolean(p, k).unwrap_or(false) +} + +fn f(p: &Value, k: &str) -> Option { + figcraft_engine::params::num(p, k) +} + +fn ids_of(p: &Value) -> Vec { + p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default() +} + +/// Does a catalog shortcut like `Shift+Cmd+I` match `key` with these modifiers (any order)? +fn shortcut_matches(shortcut: &str, key: &str, shift: bool, alt: bool, cmd: bool, ctrl: bool) -> bool { + let parts: Vec<&str> = shortcut.split('+').collect(); + let Some((k, mods)) = parts.split_last() else { return false }; + let has = |m: &str| mods.iter().any(|x| x.eq_ignore_ascii_case(m)); + k.eq_ignore_ascii_case(key) && has("shift") == shift && (has("alt") || has("option")) == alt && has("cmd") == cmd && has("ctrl") == ctrl +} + +impl Headless { + /// A session with one new, empty document. + pub fn new() -> Self { + Self { session: Session::new() } + } + + /// Wrap an existing session. + pub fn with_session(session: Session) -> Self { + Self { session } + } + + fn execute(&mut self, cmd: &str, params: &Value) -> Result { + let r = self.session.execute(cmd, params).map_err(|e| e.to_string()); + // No UI: dialogs and panels requested by commands are dropped. + self.session.ui_requests.clear(); + r + } + + fn pointer(&mut self, p: &Value) -> Result { + let zoom = self.session.ds().map(|d| d.view.zoom).unwrap_or(1.0).max(1e-6); + let (x, y) = match (f(p, "x"), f(p, "y")) { + (Some(x), Some(y)) => (x, y), + _ => match (f(p, "sx"), f(p, "sy"), self.session.ds()) { + (Some(sx), Some(sy), Ok(ds)) => ds.view.screen_to_page(sx, sy), + _ => return Err("pass x, y (page coordinates) or sx, sy (screen)".into()), + }, + }; + let mk = |kind: PointerKind, x: f64, y: f64| PointerEvent { + kind, + x, + y, + sx: x * zoom, + sy: y * zoom, + shift: b(p, "shift"), + alt: b(p, "alt"), + cmd: b(p, "cmd"), + ctrl: b(p, "ctrl"), + zoom, + }; + let kind = p.get("kind").and_then(Value::as_str).unwrap_or("click"); + let mut events = Vec::new(); + match kind { + "down" => events.push(mk(PointerKind::Down, x, y)), + "move" => events.push(mk(PointerKind::Move, x, y)), + "up" => events.push(mk(PointerKind::Up, x, y)), + "hover" => events.push(mk(PointerKind::Hover, x, y)), + "double" | "doubleclick" | "dblclick" => events.push(mk(PointerKind::DoubleClick, x, y)), + "click" => { + events.push(mk(PointerKind::Down, x, y)); + events.push(mk(PointerKind::Up, x, y)); + } + "drag" => { + let to = p.get("to").and_then(Value::as_array).ok_or("drag needs `to: [x, y]`")?; + let tx = to.first().and_then(figcraft_engine::params::f64_of).ok_or("bad `to`: expected [x, y]")?; + let ty = to.get(1).and_then(figcraft_engine::params::f64_of).ok_or("bad `to`: expected [x, y]")?; + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + events.push(mk(PointerKind::Down, x, y)); + for i in 1..=steps { + let t = i as f64 / steps as f64; + events.push(mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t)); + } + events.push(mk(PointerKind::Up, tx, ty)); + } + other => return Err(format!("unknown pointer kind `{other}` (down|move|up|click|double|hover|drag)")), + } + for ev in &events { + let r = figcraft_engine::guard::run("ui.pointer", || self.session.pointer(ev)); + self.session.ui_requests.clear(); + r.map_err(|e| e.to_string())?; + } + Ok( + json!({"selection": self.session.selection(), "tool": self.session.tool.tool.id(), "textEdit": self.session.ds().ok().and_then(|d| d.text_edit.clone())}), + ) + } + + fn key(&mut self, p: &Value) -> Result { + let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?; + let (shift, alt, cmd, ctrl) = (b(p, "shift"), b(p, "alt"), b(p, "cmd"), b(p, "ctrl")); + // Text editing keys go to the editor. + if self.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) && !(cmd && key.len() == 1) { + textedit::key(&mut self.session, key, shift, alt, cmd).map_err(|e| e.to_string())?; + return Ok(json!({"ok": true, "textEdit": true})); + } + if let Some(item) = figcraft_engine::catalog::MENU + .iter() + .find(|m| !m.shortcut.is_empty() && !m.id.is_empty() && shortcut_matches(m.shortcut, key, shift, alt, cmd, ctrl)) + { + if item.id == "select.none" { + self.session.escape().map_err(|e| e.to_string())?; + } else { + self.execute(item.id, &json!({}))?; + } + return Ok(json!({"command": item.id})); + } + if !cmd + && !alt + && !ctrl + && let Some(t) = + ToolId::ALL.into_iter().find(|t| !t.shortcut().is_empty() && shortcut_matches(t.shortcut(), key, shift, false, false, false)) + { + self.session.set_tool(t); + return Ok(json!({"tool": t.id()})); + } + match key { + "Escape" | "Esc" => self.session.escape().map(|_| json!({"ok": true})).map_err(|e| e.to_string()), + "Delete" | "Backspace" => self.execute("edit.delete", &json!({})), + "Enter" | "Return" => { + // Enter on a text layer starts editing it; otherwise finish the pen path. + let sel = self.session.selection(); + let is_text = sel + .first() + .and_then(|i| self.session.doc().ok().and_then(|d| d.node(*i).map(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))))) + .unwrap_or(false); + if sel.len() == 1 && is_text { + self.execute("text.edit.begin", &json!({"id": sel.first()})) + } else { + let _ = self.session.pen_finish(); + Ok(json!({"ok": true})) + } + } + "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" | "Left" | "Right" | "Up" | "Down" => { + let (dx, dy) = match key { + "ArrowLeft" | "Left" => (-1, 0), + "ArrowRight" | "Right" => (1, 0), + "ArrowUp" | "Up" => (0, -1), + _ => (0, 1), + }; + self.execute("transform.nudge", &json!({"dx": dx, "dy": dy, "big": shift})) + } + _ => Err(format!("no shortcut for `{key}` with these modifiers")), + } + } + + fn render_to(&mut self, p: &Value) -> Result { + let scale = f(p, "scale").unwrap_or(1.0); + let ids: Vec = ids_of(p).into_iter().map(|i| i.0).collect(); + let bytes = self.render_png(&ids, scale)?; + let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0)); + match p.get("path").and_then(Value::as_str) { + Some(path) => { + std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?; + Ok(json!({"path": path, "width": w, "height": h, "bytes": bytes.len()})) + } + None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } + } + + fn inspect_ui(&self) -> Value { + let view = self.session.ds().ok().map(|d| serde_json::to_value(&d.view).unwrap_or(Value::Null)); + json!({ + "backend": "headless", + "status": self.session.status(), + "tool": self.session.tool_info(), + "view": view, + "message": self.session.message, + }) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, p: Value) -> Result { + match method { + "engine.execute" => { + let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string(); + let params = match p.get("params") { + None | Some(Value::Null) => json!({}), + Some(v) => v.clone(), + }; + self.execute(&cmd, ¶ms) + } + "engine.commands" => Ok(Value::Array( + figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(), + )), + "document.inspect" => { + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; + Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages"))) + } + "node.get" => { + let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?; + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1).min(64) as usize; + let doc = self.session.doc().map_err(|e| e.to_string())?; + if !doc.contains(id) { + return Err(format!("no such node {id}")); + } + Ok(figcraft_engine::inspect::node_json(doc, id, depth)) + } + "ui.inspect" => Ok(self.inspect_ui()), + "ui.pointer" => self.pointer(&p), + "ui.key" => self.key(&p), + "ui.type" => { + let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?; + if !self.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) { + return Err("not editing text: double-click a text layer, press Enter on it, or run text.edit.begin {id}".into()); + } + textedit::insert(&mut self.session, text).map_err(|e| e.to_string())?; + Ok(json!({"ok": true})) + } + "ui.render" => self.render_to(&p), + "ui.screenshot" => { + let bytes = self.screenshot_png()?; + let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0)); + match p.get("path").and_then(Value::as_str) { + Some(path) => { + std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?; + Ok(json!({"path": path, "width": w, "height": h})) + } + None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } + } + "app.quit" => Ok(json!({"ok": true})), + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine session; no window)".into() + } + + fn render_png(&mut self, ids: &[u64], scale: f64) -> Result, String> { + let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 }; + let page = self.session.page().map_err(|e| e.to_string())?; + let doc = self.session.doc().map_err(|e| e.to_string())?; + if !ids.is_empty() { + let ids: Vec = ids.iter().map(|i| NodeId(*i)).collect(); + for i in &ids { + if !doc.contains(*i) { + return Err(format!("no such node {i}")); + } + } + return figcraft_engine::cmd::file::export_bytes(doc, &ids, ExportFormat::Png, scale).map_err(|e| e.to_string()); + } + let bounds = doc.page_content_bounds(page).ok_or("the current page is empty: nothing to render")?; + let side = bounds.width().max(bounds.height()).max(1.0); + let scale = scale.min(MAX_SIDE / side); + let view = figcraft_render::View { + origin: (bounds.x0, bounds.y0), + scale, + width: (bounds.width() * scale).ceil().max(1.0) as u32, + height: (bounds.height() * scale).ceil().max(1.0) as u32, + }; + let pix = figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or("render failed")?; + figcraft_render::encode_png(&pix) + } + + fn screenshot_png(&mut self) -> Result, String> { + // No window: the page with a margin, zoomed to fit 1600 px. + let page = self.session.page().map_err(|e| e.to_string())?; + let doc = self.session.doc().map_err(|e| e.to_string())?; + let b = doc.page_content_bounds(page).unwrap_or(figcraft_geom::Rect::new(0.0, 0.0, 800.0, 600.0)); + let b = b.inflate(32.0, 32.0); + let scale = (1600.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let pix = figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or("render failed")?; + figcraft_render::encode_png(&pix) + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..63a7548a --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,38 @@ +//! FigCraft'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 FigCraft as MCP tools, resources and +//! prompts, and forwards everything to a [`Backend`]: +//! +//! - [`Headless`] hosts an in-process [`figcraft_engine::Session`] and answers the control-channel +//! methods itself (rendering with `figcraft-render`), so agents can design without a window. +//! - [`Attached`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`figcraft --control PORT`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out. +//! +//! 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)] + +mod backend; +mod headless; +mod prompts; +mod resources; +mod server; +mod tools; + +pub use backend::{Attached, Backend, decode_data, page_children}; +pub use headless::Headless; +pub use prompts::{PROMPTS, PromptArg, PromptDef}; +pub use resources::{COMMAND_TEMPLATE, COMMANDS_URI, DOC_URI, NODE_TEMPLATE}; +pub use server::{INSTRUCTIONS, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server}; +pub use tools::{TOOL_NAMES, ToolResult, call_tool, tool_definitions}; + +/// Width and height of a PNG from its IHDR chunk. +pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.get(..8)? != b"\x89PNG\r\n\x1a\n" || bytes.get(12..16)? != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?); + Some((w, h)) +} diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs new file mode 100644 index 00000000..9ca6e39f --- /dev/null +++ b/crates/mcp/src/prompts.rs @@ -0,0 +1,114 @@ +//! MCP prompts: ready-made design workflows that tell an agent which tools and commands to use. + +use serde_json::{Value, json}; + +pub struct PromptArg { + pub name: &'static str, + pub description: &'static str, + pub required: bool, +} + +pub struct PromptDef { + pub name: &'static str, + pub title: &'static str, + pub description: &'static str, + pub args: &'static [PromptArg], + /// Template; `{arg}` is replaced by the argument (or its default). + pub template: &'static str, + pub defaults: &'static [(&'static str, &'static str)], +} + +const VERIFY: &str = "\n\nVerify as you go: after each major step call inspect_document (or get_node on the frame) and check sizes, fills, layout and text; call screenshot at the end and look at it. Fix anything that is off before finishing, then report the ids of the frames you made."; + +pub const PROMPTS: &[PromptDef] = &[ + PromptDef { + name: "mobile_screen", + title: "Design a mobile screen", + description: "Build one mobile app screen in a phone frame with auto layout, real text and a clear hierarchy.", + args: &[ + PromptArg { name: "screen", description: "What the screen is, e.g. \"login\", \"settings\", \"order summary\"", required: true }, + PromptArg { name: "device", description: "Frame preset (default \"iPhone 16\"; see run_command frame.presets)", required: false }, + PromptArg { name: "brand_color", description: "Primary colour (default #3B5BDB)", required: false }, + ], + template: "Design a {screen} screen for a mobile app in FigCraft.\n\n1. run_command frame.create {\"preset\": \"{device}\", \"x\": 0, \"y\": 0, \"name\": \"{screen}\"} and give it a white fill.\n2. set_properties on the frame: {\"layout\": \"vertical\", \"padding\": [64, 24, 32, 24], \"gap\": 16, \"sizingV\": \"fixed\"}.\n3. Add content with create_node using parent = the frame id: a title (text, fontSize 28, fontStyle Bold), supporting text (fontSize 16, colour #555555), inputs or list rows (frames with layout horizontal, padding 16, cornerRadius 12, stroke #E0E0E0, sizingH fill) and a primary button (frame, layout horizontal, mainAlign center, padding [14,24,14,24], cornerRadius 12, fill {brand_color}, sizingH fill, white Bold text inside).\n4. Keep spacing on an 8-point grid and text left-aligned.{verify}", + defaults: &[("device", "iPhone 16"), ("brand_color", "#3B5BDB")], + }, + PromptDef { + name: "design_system", + title: "Start a design system", + description: "Create colour and text styles, Light/Dark colour variables, and a button component with variants.", + args: &[ + PromptArg { name: "name", description: "Design system name (default \"Core\")", required: false }, + PromptArg { name: "primary", description: "Primary colour (default #3B5BDB)", required: false }, + ], + template: "Set up the \"{name}\" design system in FigCraft.\n\n1. Variables: run_command variable.createCollection {\"name\": \"{name} colours\", \"modes\": [\"Light\", \"Dark\"]}, then variable.create for color/bg, color/fg and color/primary with values per mode ({\"values\": {\"Light\": …, \"Dark\": …}}); primary is {primary} in Light.\n2. Paint styles: create a swatch rectangle per colour and run style.create {\"type\": \"paint\", \"name\": \"{name}/Primary\", \"from\": }; text styles likewise from text layers (Heading 32 Bold, Body 16 Regular, Caption 12).\n3. Button component: a frame with auto layout (horizontal, padding [12,20,12,20], cornerRadius 8, mainAlign center) containing a text \"Button\"; style.apply the primary paint style; component.create on it. Make a secondary variant (duplicate, outline style), then component.combineVariants on both.\n4. Bind the button fill to color/primary with variable.bind {\"ids\": [..], \"field\": \"fill\", \"variable\": } and check both modes by variable.setMode on a frame.\n5. list_commands with filter \"style\" or \"variable\" shows the exact params.{verify}", + defaults: &[("name", "Core"), ("primary", "#3B5BDB")], + }, + PromptDef { + name: "landing_page", + title: "Design a landing page", + description: "A desktop marketing page: nav, hero, feature grid, call to action and footer, all in auto layout.", + args: &[ + PromptArg { name: "product", description: "What the page sells", required: true }, + PromptArg { name: "width", description: "Page width (default 1440)", required: false }, + PromptArg { name: "accent", description: "Accent colour (default #7048E8)", required: false }, + ], + template: "Design a landing page for {product} in FigCraft.\n\n1. create_node a frame {\"type\": \"frame\", \"x\": 0, \"y\": 0, \"width\": {width}, \"height\": 2400, \"name\": \"Landing\", \"props\": {\"fill\": \"#FFFFFF\", \"layout\": \"vertical\", \"gap\": 0}}.\n2. Sections are child frames with sizingH fill and their own auto layout: Nav (horizontal, spaceBetween, padding [20,80,20,80]); Hero (vertical, center, padding 120, gap 24: a 64px Bold headline, an 20px subhead, a CTA button filled {accent}); Features (horizontal, gap 32, padding 80: three cards with cornerRadius 16, padding 32, a soft dropShadow effect, title + body text, each sizingH fill); CTA band filled {accent} with white text; Footer (padding 40, small grey text).\n3. Use batch to apply repeated styling to several ids in one undo step.\n4. Finish with sizingV hug on the page frame so it fits its content.{verify}", + defaults: &[("width", "1440"), ("accent", "#7048E8")], + }, + PromptDef { + name: "prototype_flow", + title: "Wire a prototype flow", + description: "Connect screens with click interactions, add a flow starting point and check it.", + args: &[ + PromptArg { + name: "screens", + description: "Comma-separated screen names, in flow order (default \"Home, Details, Checkout\")", + required: false, + }, + PromptArg { + name: "transition", + description: "Transition: instant|dissolve|smartAnimate|moveIn|push|slideIn (default smartAnimate)", + required: false, + }, + ], + template: "Build a clickable prototype in FigCraft with these screens, in order: {screens}.\n\n1. If the frames don't exist, create one phone frame per screen (run_command frame.create {\"preset\": \"iPhone 16\", …}) side by side 100 units apart, each with a title and a \"Next\" button (a frame with text inside).\n2. For each screen except the last, add an interaction on its button: run_command proto.interaction.add {\"id\":