diff --git a/.cargo/config.toml b/.cargo/config.toml
index acebfe9f..30d819a2 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
+# The Windows UI Automation adapter can be asked about a node that disappeared in the
+# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
+# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
+# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
+# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
+[target.'cfg(all(windows, target_env = "gnu"))']
+rustflags = ["--cfg=windows_slim_errors"]
+
[alias]
xtask = "run -q -p xtask --"
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..2408084c
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 00000000..2a4cd2cb
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,3 @@
+# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
+# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
+nextest-version = "0.9.150"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..d39962e1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,32 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directories:
+ - "/"
+ - "/plugins"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e613617a..b79af502 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,9 +1,7 @@
-# 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
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -13,76 +11,20 @@ on:
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.
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
- group: ci-${{ github.ref }}
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
- test:
- timeout-minutes: 35
- 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:
- timeout-minutes: 30
- 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
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Combined Latin presentation and core UI catalog. Required web builds enforce
+ # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+ require-craft-fonts: true
+ packages: >-
+ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
+ libgtk-3-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index ca0cd49f..80adf16a 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -4,12 +4,10 @@
# 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.
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
- schedule:
- - cron: "17 2 * * *"
push:
branches: [release]
pull_request:
@@ -17,6 +15,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
+ schedule:
+ - cron: "17 2 * * *"
workflow_dispatch:
concurrency:
@@ -31,6 +31,5 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
- freebsd-release: "14.3"
run-package-check: true
- craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587..01cb9cd6 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 75f7a5fa..f93219fa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,8 +24,13 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
- craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+ # Combined Latin presentation and core UI catalog. Required web builds enforce
+ # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
+ # The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
+ # crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
+ web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 00000000..e78fe1ea
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index 708da805..b2384311 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 1f72aeb7..46b789a8 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -25,8 +25,18 @@ Release builds embed fonts from the optional craft-fonts build input
| Family | Licence | Upstream |
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
-| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
+| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
+| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
-| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+
+The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
+FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
+
+## egui dependency font icons
+
+| Asset | Author | Source | Licence | Use |
+|---|---|---|---|---|
+| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
diff --git a/CLAUDE.md b/CLAUDE.md
index 708da805..b2384311 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/Cargo.lock b/Cargo.lock
index aead39d1..413011ab 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,21 +4,21 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
"phf",
"serde",
@@ -27,43 +27,37 @@ dependencies = [
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -71,9 +65,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -89,13 +83,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -103,11 +97,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -244,9 +239,9 @@ dependencies = [
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
@@ -258,7 +253,8 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.52.0",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -391,7 +387,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -426,7 +422,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -490,15 +486,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -509,10 +496,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -520,6 +510,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -581,6 +581,30 @@ dependencies = [
"piper",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -604,7 +628,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -671,9 +695,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.6.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -714,10 +738,19 @@ dependencies = [
]
[[package]]
-name = "clipboard-win"
-version = "5.4.1"
+name = "chrono"
+version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -765,9 +798,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
-version = "4.6.8"
+version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"memchr",
@@ -798,6 +831,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -811,8 +854,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-graphics-types 0.2.0",
"foreign-types",
"libc",
]
@@ -824,10 +880,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"libc",
]
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "libc",
+]
+
+[[package]]
+name = "core-text"
+version = "22.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-graphics 0.25.0",
+ "foreign-types",
+]
+
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -855,11 +939,49 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+
+[[package]]
+name = "craft-update"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip 9.1.1",
+]
+
+[[package]]
+name = "craft-window"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+dependencies = [
+ "wayland-client",
+]
+
[[package]]
name = "crc32fast"
-version = "1.5.2"
+version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@@ -945,9 +1067,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "defmt"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
@@ -955,25 +1077,31 @@ dependencies = [
[[package]]
name = "defmt-macros"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
name = "defmt-parser"
-version = "1.0.0"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
@@ -982,9 +1110,15 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1032,7 +1166,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1066,10 +1200,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
-name = "ecolor"
-version = "0.36.2"
+name = "dunce"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecolor"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1077,9 +1216,8 @@ dependencies = [
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1101,6 +1239,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
+ "send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1112,9 +1251,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1132,9 +1270,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1152,30 +1289,33 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
"objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1187,9 +1327,8 @@ dependencies = [
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1207,9 +1346,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1234,22 +1372,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
-version = "2.7.3"
+version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
-version = "0.17.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
@@ -1298,16 +1436,15 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
"ecolor",
"emath",
"epaint_default_fonts",
- "font-types 0.12.6",
+ "font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1316,22 +1453,23 @@ dependencies = [
"self_cell",
"skrifa 0.44.0",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -1340,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -1407,9 +1545,11 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"arboard",
+ "craft-update",
+ "craft-window",
"eframe",
"egui",
"env_logger",
@@ -1426,12 +1566,14 @@ dependencies = [
"wat",
"webbrowser",
"windows",
+ "winit",
"winresource",
+ "zbus",
]
[[package]]
name = "figcraft-cli"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1445,9 +1587,10 @@ dependencies = [
[[package]]
name = "figcraft-collab"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
+ "chrono",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
@@ -1460,7 +1603,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1483,7 +1626,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"kurbo",
"serde",
@@ -1492,7 +1635,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1504,9 +1647,11 @@ dependencies = [
[[package]]
name = "figcraft-engine"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
+ "craft-docpath",
+ "deunicode",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1519,19 +1664,22 @@ dependencies = [
"figcraft-render",
"figcraft-svg",
"figcraft-text",
+ "figcraft-vectordrawable",
"image",
"log",
"regex",
+ "same-file",
"serde",
"serde_json",
"thiserror 2.0.21",
"wat",
"web-time",
+ "zip 2.6.1",
]
[[package]]
name = "figcraft-format"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1540,13 +1688,14 @@ dependencies = [
"ruzstd",
"serde",
"serde_json",
+ "sha1",
"sha2",
- "zip",
+ "zip 2.6.1",
]
[[package]]
name = "figcraft-geom"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"kurbo",
"serde",
@@ -1554,7 +1703,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1562,7 +1711,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1578,7 +1727,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1587,7 +1736,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1599,12 +1748,12 @@ dependencies = [
"krilla",
"log",
"thiserror 2.0.21",
- "tiny-skia",
+ "tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-plugin"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-color",
"serde",
@@ -1617,7 +1766,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1630,12 +1779,12 @@ dependencies = [
"moxcms",
"png",
"rayon",
- "tiny-skia",
+ "tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-server"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1654,12 +1803,12 @@ dependencies = [
"toml",
"tungstenite",
"wat",
- "zip",
+ "zip 2.6.1",
]
[[package]]
name = "figcraft-svg"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1675,12 +1824,14 @@ dependencies = [
[[package]]
name = "figcraft-text"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
+ "core-text",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
+ "objc2 0.6.5",
"png",
"skrifa 0.47.0",
"unicode-bidi",
@@ -1689,9 +1840,11 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"base64 0.22.1",
+ "chrono",
+ "craft-update",
"egui",
"egui_extras",
"figcraft-color",
@@ -1713,9 +1866,24 @@ dependencies = [
]
[[package]]
-name = "figcraft-web"
-version = "0.6.0"
+name = "figcraft-vectordrawable"
+version = "0.11.2"
dependencies = [
+ "figcraft-color",
+ "figcraft-doc",
+ "figcraft-geom",
+ "figcraft-pathops",
+ "figcraft-render",
+ "figcraft-text",
+ "roxmltree 0.21.2",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "figcraft-web"
+version = "0.11.2"
+dependencies = [
+ "base64 0.22.1",
"eframe",
"egui",
"figcraft-collab-net",
@@ -1723,16 +1891,33 @@ dependencies = [
"figcraft-ui-egui",
"js-sys",
"log",
+ "serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
-name = "find-msvc-tools"
-version = "0.1.14"
+name = "filetime"
+version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
@@ -1794,9 +1979,9 @@ dependencies = [
[[package]]
name = "font-types"
-version = "0.12.6"
+version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
dependencies = [
"bytemuck",
]
@@ -1841,7 +2026,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1892,7 +2077,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1964,9 +2149,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -1992,9 +2179,9 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
@@ -2212,9 +2399,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
dependencies = [
"displaydoc",
"potential_utf",
@@ -2226,9 +2413,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [
"displaydoc",
"litemap",
@@ -2239,9 +2426,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -2253,15 +2440,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
[[package]]
name = "icu_properties"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
dependencies = [
"displaydoc",
"icu_collections",
@@ -2274,15 +2461,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
[[package]]
name = "icu_provider"
-version = "2.3.1"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -2544,8 +2731,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
+source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2563,7 +2749,7 @@ dependencies = [
"skrifa 0.42.1",
"smallvec",
"subsetter",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
@@ -2590,9 +2776,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.190"
+version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
@@ -2619,7 +2805,7 @@ dependencies = [
"bitflags 2.13.2",
"libc",
"plain",
- "redox_syscall 0.9.4",
+ "redox_syscall 0.9.5",
]
[[package]]
@@ -2661,9 +2847,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
-version = "1.0.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
@@ -2688,9 +2874,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
-version = "0.9.11"
+version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
@@ -2820,6 +3006,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3175,6 +3376,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3187,9 +3394,9 @@ dependencies = [
[[package]]
name = "ordered-float"
-version = "5.5.0"
+version = "5.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
dependencies = [
"num-traits",
]
@@ -3204,6 +3411,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3265,10 +3482,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "phf"
-version = "0.13.1"
+name = "petgraph"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
+[[package]]
+name = "phf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
@@ -3277,9 +3505,9 @@ dependencies = [
[[package]]
name = "phf_generator"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -3287,9 +3515,9 @@ dependencies = [
[[package]]
name = "phf_macros"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
@@ -3300,9 +3528,9 @@ dependencies = [
[[package]]
name = "phf_shared"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3362,6 +3590,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "plist"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap",
+ "quick-xml 0.42.0",
+ "serde",
+ "time",
+]
+
[[package]]
name = "png"
version = "0.18.1"
@@ -3447,13 +3688,10 @@ dependencies = [
]
[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
+name = "powerfmt"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "presser"
@@ -3487,16 +3725,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -3531,6 +3770,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -3552,16 +3800,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -3573,24 +3811,11 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
[[package]]
name = "rand_core"
@@ -3600,11 +3825,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3677,7 +3902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
- "font-types 0.12.6",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3688,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
dependencies = [
"bytemuck",
- "font-types 0.12.6",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3699,7 +3924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
- "font-types 0.12.6",
+ "font-types 0.12.7",
"once_cell",
]
@@ -3723,9 +3948,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.9.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
+checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"bitflags 2.13.2",
]
@@ -3814,9 +4039,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
-version = "0.21.1"
+version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
+checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -3865,14 +4090,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
name = "rustls"
-version = "0.23.45"
+version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
@@ -3883,6 +4108,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -3893,10 +4130,37 @@ dependencies = [
]
[[package]]
-name = "rustls-webpki"
-version = "0.103.15"
+name = "rustls-platform-verifier"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3969,6 +4233,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3981,6 +4254,40 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "log",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia 0.11.4",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -3992,6 +4299,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -4020,14 +4337,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -4044,7 +4361,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4178,9 +4495,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.16.2"
+version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -4344,9 +4661,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.6"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -4361,7 +4678,17 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
]
[[package]]
@@ -4371,10 +4698,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -4423,7 +4750,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4440,6 +4767,50 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path 0.11.4",
+]
+
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -4452,7 +4823,18 @@ dependencies = [
"cfg-if",
"log",
"png",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
]
[[package]]
@@ -4468,9 +4850,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"zerovec",
@@ -4484,9 +4866,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.2"
+version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"pin-project-lite",
]
@@ -4574,6 +4956,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4594,7 +4987,7 @@ dependencies = [
"http",
"httparse",
"log",
- "rand 0.10.3",
+ "rand",
"sha1",
"thiserror 2.0.21",
]
@@ -4608,6 +5001,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4657,9 +5056,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-properties"
@@ -4697,6 +5096,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
+dependencies = [
+ "base64 0.23.1",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "ureq-proto",
+ "utf8-zero",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -4724,19 +5152,25 @@ dependencies = [
"kurbo",
"log",
"pico-args",
- "roxmltree 0.21.1",
+ "roxmltree 0.21.2",
"simplecss",
"siphasher",
"skrifa 0.44.0",
"strict-num",
"svgtypes",
- "tiny-skia-path",
+ "tiny-skia-path 0.12.0",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4751,9 +5185,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.27.0"
+version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"js-sys",
"serde_core",
@@ -4762,9 +5196,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -4777,9 +5211,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -4787,6 +5221,16 @@ dependencies = [
"vello_common",
]
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -4870,7 +5314,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"wasm-bindgen-shared",
]
@@ -4885,12 +5329,12 @@ dependencies = [
[[package]]
name = "wasm-encoder"
-version = "0.261.0"
+version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
+checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
- "wasmparser 0.261.0",
+ "wasmparser 0.262.0",
]
[[package]]
@@ -4945,9 +5389,9 @@ dependencies = [
[[package]]
name = "wasmparser"
-version = "0.261.0"
+version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
+checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags 2.13.2",
"indexmap",
@@ -4956,9 +5400,9 @@ dependencies = [
[[package]]
name = "wast"
-version = "261.0.0"
+version = "262.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
+checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
dependencies = [
"bumpalo",
"leb128fmt",
@@ -4969,9 +5413,9 @@ dependencies = [
[[package]]
name = "wat"
-version = "1.261.0"
+version = "1.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
+checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
dependencies = [
"wast",
]
@@ -5095,7 +5539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml",
+ "quick-xml 0.41.0",
"quote",
]
@@ -5133,9 +5577,9 @@ dependencies = [
[[package]]
name = "webbrowser"
-version = "1.2.4"
+version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
+checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7"
dependencies = [
"jni",
"log",
@@ -5148,10 +5592,19 @@ dependencies = [
]
[[package]]
-name = "webpki-roots"
-version = "1.0.9"
+name = "webpki-root-certs"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -5361,7 +5814,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -5482,7 +5935,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
@@ -5491,16 +5944,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -5518,31 +5962,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -5560,101 +5987,52 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -5665,8 +6043,8 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
- "core-foundation",
- "core-graphics",
+ "core-foundation 0.9.4",
+ "core-graphics 0.23.2",
"cursor-icon",
"dpi",
"js-sys",
@@ -5683,6 +6061,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
+ "sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -5711,21 +6090,55 @@ dependencies = [
]
[[package]]
-name = "winresource"
-version = "0.1.31"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
]
+[[package]]
+name = "winsafe"
+version = "0.0.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -5822,7 +6235,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.6.0"
+version = "0.11.2"
dependencies = [
"figcraft-engine",
"serde_json",
@@ -5830,9 +6243,9 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -5847,7 +6260,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -5919,7 +6332,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -5927,9 +6340,9 @@ dependencies = [
[[package]]
name = "zbus_names"
-version = "4.3.4"
+version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [
"serde",
"winnow",
@@ -5959,18 +6372,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.62"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.62"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
@@ -5994,7 +6407,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure",
]
@@ -6017,9 +6430,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.8"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"yoke",
"zerofrom",
@@ -6028,29 +6441,44 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.6"
+version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "zip"
-version = "2.4.2"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
- "displaydoc",
"flate2",
"indexmap",
"memchr",
- "thiserror 2.0.21",
+ "zopfli",
+]
+
+[[package]]
+name = "zip"
+version = "9.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
"zopfli",
]
@@ -6068,9 +6496,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
@@ -6095,9 +6523,9 @@ dependencies = [
[[package]]
name = "zvariant"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
dependencies = [
"endi",
"enumflags2",
@@ -6110,14 +6538,14 @@ dependencies = [
[[package]]
name = "zvariant_derive"
-version = "5.15.0"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"zvariant_utils",
]
@@ -6130,6 +6558,26 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
- "syn 3.0.6",
+ "syn 3.0.9",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_kittest"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index 8f125ac3..f6fca285 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
-version = "0.6.0"
+version = "0.11.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
+figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
@@ -32,6 +33,16 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
+# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
+# Which paths stored in a document may be followed (craft-libs).
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
+# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
+# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
+# pull a second egui family version).
+craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
+
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
@@ -39,6 +50,9 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
+same-file = "1.0.6"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
@@ -55,11 +69,15 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
+# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
+# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
+deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
+sha1 = "0.11"
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
@@ -71,9 +89,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
-egui = "0.36"
-egui_extras = { version = "0.36", default-features = false }
-eframe = { version = "0.36", default-features = false }
+egui = "0.37"
+egui_extras = { version = "0.37", default-features = false }
+eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -95,11 +113,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
-[profile.dev.package."*"]
-opt-level = 2
-
+# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
+# the test build faster and the target dir smaller.
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
+
+[profile.dev.package."*"]
+opt-level = 2
+debug = false
[profile.release]
lto = "thin"
@@ -110,9 +132,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
-opt-level = "s"
+# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
+opt-level = "z"
lto = "fat"
codegen-units = 1
debug = false
strip = true
panic = "abort"
+
+# Keep the complete egui family and native event bridge on reviewed fork commits.
+[patch.crates-io]
+ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+
+# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
+# panic while drawing unwinds without aborting.
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
diff --git a/NOTICE b/NOTICE
index 9aaba825..9970631d 100644
--- a/NOTICE
+++ b/NOTICE
@@ -20,3 +20,51 @@ licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.
+
+Linux Wayland window decorations
+-------------------------------
+sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
+commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
+for vector window controls. Its title-font features are disabled: no Cantarell
+font is embedded by this dependency and no font file is added to this repo.
+
+sctk-adwaita's MIT licence:
+
+MIT License
+
+Copyright (c) 2022 Bartłomiej Maryńczak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+egui icon font subset (dependency asset)
+----------------------------------------
+epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
+emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
+uses the same MIT license. Upstream license text follows unchanged.
+
+MIT License
+
+Copyright (c) 2014 John Slegers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index 96bef198..75328de1 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,14 @@
+
+
+
+
FigCraft
- Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.
+ Interface design and prototyping: open source, collaborative and built in pure Rust.
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,
in a native app that opens instantly and works offline. Open source, and yours.
@@ -20,6 +24,9 @@
+
+
+
@@ -62,7 +69,7 @@
🎛️ Familiar by design
- The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
+ The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
⚡ Native and fast
@@ -114,6 +121,16 @@
Live sessions: three apps on one figcraft-server file, with cursors, selections, cursor chat and spotlight
+
+
+
+
Boards: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions
+
+
+
+
Dev Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft
+
+
## Made in FigCraft
@@ -191,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
@@ -227,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
-- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
-- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
+- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
+- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
@@ -262,7 +279,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
-> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
+> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +365,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9a3fe224..30a2caf7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flutter code generation; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,19 +13,19 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
-| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
+| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
-| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
+| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
-| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
+| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
-native speakers' reviews of the six language drafts need people.
+native speakers' reviews of the nine language drafts need people.
## By dimension
@@ -63,9 +63,9 @@ native speakers' reviews of the six language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
-| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
+| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
-| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
+| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -86,38 +86,39 @@ native speakers' reviews of the six language drafts need people.
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
-| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
+| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
-| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
+| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
+| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
-| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
-| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
+| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
+| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
-| French | fr | 100% | draft (needs native review) |
-| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
-| Indonesian | id | 0% | none |
-| Japanese | ja | 100% | draft (needs native review) |
-| German | de | 100% | draft (needs native review) |
-| Korean | ko | 100% | draft (needs native review) |
-| Vietnamese | vi | 0% | none |
+| French | fr | 95% | draft (needs native review) |
+| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
+| Indonesian | id | 99% | draft (needs native review) |
+| Japanese | ja | 99% | draft (needs native review) |
+| German | de | 96% | draft (needs native review) |
+| Korean | ko | 95% | draft (needs native review) |
+| Vietnamese | vi | 100% | draft (needs native review) |
-Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
+Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
-French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
+French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
-IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
+IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
@@ -126,11 +127,45 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
-4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
+4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
+- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
+- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
+- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
+- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
+- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
+- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
+- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
+- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
+- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
+ unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
+ into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
+ can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
+ (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
+ 65%.
+- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
+- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
+- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
+- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
+- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
+- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
+- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
+- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
+- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
+- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
+- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
+- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
+ filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
+ Binary regression coverage opens five such native files and checks their JSON info.
+
+- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
+- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
+- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
+- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
+- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -313,11 +348,89 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
+| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
+| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
+| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
+| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
+| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
+| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
+| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
+| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
+| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
+| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
+| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
+| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
+| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
+| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
+| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
+| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
+| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
+| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
+| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
+| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
+| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
+| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
+| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
+| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
+| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
+| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
+| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
+| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
+| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
+| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
+| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
+| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
+| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
+| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
+| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
+| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
+| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
+| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
+| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
+| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
+| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
+| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
+| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
+| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
+| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
+| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
+| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
+| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
+| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
+| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
+| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
+| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
+| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
+| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
+| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
+| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
+| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
+| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
+| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
+| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
+| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
+| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
+| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
+| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
+| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
+| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
+| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
+| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
+| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
+| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
+| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
+| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
+| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
+| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs
index 372082c2..740e885b 100644
--- a/apps/figcraft-cli/src/args.rs
+++ b/apps/figcraft-cli/src/args.rs
@@ -1,5 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
-//! options, and boolean `--flag`s. Unknown options are usage errors.
+//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
+//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
use std::collections::BTreeMap;
@@ -8,15 +9,21 @@ use crate::{CliError, CliResult};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec,
- values: BTreeMap,
+ values: BTreeMap>,
flags: Vec,
}
impl Args {
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult {
let mut a = Args::default();
+ // The first bad option, reported once the rest of the line is checked for `--help`.
+ let mut bad: Option = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
+ if arg == "--" {
+ a.positional.extend(it.cloned());
+ break;
+ }
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())) {
@@ -29,23 +36,36 @@ impl Args {
Some((n, v)) => (n, Some(v.to_string())),
None => (name, None),
};
+ // `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
+ if inline.is_none() && matches!(name, "help" | "h") {
+ return Err(CliError::Help);
+ }
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);
+ match inline.or_else(|| it.next().cloned()) {
+ Some(v) => a.values.entry(name.to_string()).or_default().push(v),
+ None => {
+ bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
+ }
+ }
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
- return Err(CliError::Usage(format!("unknown option `{arg}`")));
+ bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
}
}
- Ok(a)
+ match bad {
+ Some(e) => Err(e),
+ None => Ok(a),
+ }
}
pub fn value(&self, k: &str) -> Option {
- self.values.get(k).cloned()
+ self.values.get(k).and_then(|v| v.last()).cloned()
+ }
+
+ /// Every value of an option that may be given more than once, in order.
+ pub fn values(&self, k: &str) -> &[String] {
+ self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
@@ -62,17 +82,26 @@ impl Args {
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult {
- let bleed = match self.values.get("bleed") {
+ let bleed = match self.value("bleed") {
None => 0.0,
- Some(s) => figcraft_doc::PrintSettings::parse_length(s)
+ Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
+ /// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
+ pub fn vd_compat(&self) -> CliResult {
+ match self.value("vd-compat") {
+ None => Ok(figcraft_doc::VdCompat::default()),
+ Some(s) => figcraft_doc::VdCompat::from_key(&s)
+ .ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
+ }
+ }
+
pub fn scale(&self) -> CliResult {
- match self.values.get("scale") {
+ match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
@@ -85,7 +114,7 @@ impl Args {
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult> {
- match self.values.get(k) {
+ match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
@@ -110,6 +139,10 @@ mod tests {
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
+ let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
+ assert_eq!(a.values("root"), ["a", "b"]);
+ assert_eq!(a.value("root").as_deref(), Some("b"));
+ assert!(a.values("none").is_empty());
}
#[test]
@@ -125,4 +158,18 @@ mod tests {
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
assert_eq!(a.positional, ["-5"]);
}
+
+ #[test]
+ fn help_is_asked_for_anywhere_but_as_a_value() {
+ // Also after a bad option: help wins over the usage error (#1161 review).
+ for argv in
+ [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
+ {
+ assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
+ }
+ let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
+ assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
+ // `--help=x` is no request for help, just an unknown option.
+ assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
+ }
}
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index 4fb485e1..1ba9d60a 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -1,10 +1,11 @@
//! The file subcommands: info, export, convert, render and run.
+use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
-use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
-use figcraft_engine::Session;
+use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
+use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -19,19 +20,30 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec)> {
}
fn ext(path: &str) -> String {
- Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
+ Path::new(path).extension().and_then(OsStr::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),
+ "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
+/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
+/// confined run can only write inside a write root (#833).
+///
+/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
+/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
+/// writing through it to its target -- a change of behaviour for anyone whose output path is a
+/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
+/// `Roots::check` has already refused a link that leads out of the root.
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())))?;
+ figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
+ }
+ if figcraft_engine::file_access::current().is_some() {
+ return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -155,6 +167,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
+ let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
@@ -164,6 +177,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
+ "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -187,13 +201,20 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
+ for m in &missing {
+ let layers = if m.layers == 1 { "text layer" } else { "text layers" };
+ println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
+ }
Ok(ExitCode::SUCCESS)
}
+/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
+/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
+/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
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() }
+ let s = figcraft_engine::cmd::file::safe_file_name(name);
+ let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
+ if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
}
/// What `export` exports and how.
@@ -204,6 +225,8 @@ pub struct Target {
pub node: Option,
/// PDF only.
pub print: PrintSettings,
+ /// Android Vector Drawable (XML) only.
+ pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
@@ -218,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
- image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
+ image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
}
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]| {
- let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
- figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
+ let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
+ let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
+ // What an Android Vector Drawable changed or left out, like the .fig notes.
+ for w in warnings {
+ eprintln!("note: {w}");
+ }
+ Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
@@ -238,7 +266,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
- std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
+ figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -251,14 +279,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
+ let mut next_suffix: std::collections::HashMap = std::collections::HashMap::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 base_lower = base.to_lowercase();
+ let next = next_suffix.entry(base_lower).or_insert(1);
+ let name = loop {
+ let k = *next;
+ *next += 1;
+ let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
+ if used.insert(candidate.to_lowercase()) {
+ break candidate;
+ }
+ };
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
@@ -266,8 +299,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
Ok(ExitCode::SUCCESS)
}
+fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
+ if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
+ return Err(CliError::Failed(format!(
+ "{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
+ )));
+ }
+ Ok(())
+}
+
+fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
+ if let Some((input, images_left_out)) = input {
+ refuse_dropping_images(input, out, images_left_out)?;
+ }
+ if let Some(images_left_out) = active.images_lost_saving_to(out) {
+ refuse_dropping_images(out, out, images_left_out)?;
+ }
+ Ok(())
+}
+
pub fn convert(input: &str, out: &str) -> CliResult {
- let doc = load(input)?;
+ let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
+ refuse_dropping_images(input, out, loaded.images_past_budget)?;
+ let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -292,7 +346,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
-pub fn parse_script(text: &str) -> Result, String> {
+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") => {
@@ -317,36 +371,54 @@ pub fn parse_script(text: &str) -> Result, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
- Ok((cmd.to_string(), params))
+ let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
+ Ok(BatchStep { name, ..BatchStep::new(cmd, 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 bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
+ let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
- let mut s = match input {
+ let (mut s, input_images_left_out) = 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
+ let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
+ (s, left_out)
}
- None => Session::new(),
+ None => (Session::new(), 0),
};
- 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));
- }
+ // One undo step and all-or-nothing, the way the same commands behave through `command.batch`
+ // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
+ // steps before it, and a step that fails takes the whole script back instead of leaving the
+ // earlier steps applied. The result lines are held back until the script is through, so none of
+ // them reports work that was rolled back.
+ let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
+ let mut failed_at = 0;
+ let outcome = s.grouped(&format!("run {script}"), |s| {
+ for (i, step) in steps.iter().enumerate() {
+ failed_at = i;
+ s.batch_step(&mut scope, &step.command, &step.params)?;
+ s.ui_requests.clear();
}
+ Ok(())
+ });
+ if let Err(e) = outcome {
+ let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
+ let i = failed_at;
+ println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
+ eprintln!("error: step {i} ({cmd}) failed: {e}");
+ eprintln!("the script's document changes were rolled back");
+ return Ok(ExitCode::from(1));
+ }
+ for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
+ println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
+ refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -378,6 +450,14 @@ mod tests {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
+ // The same characters the app's export keeps (#1154): only what file systems refuse goes.
+ assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
+ assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
+ assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j");
+ assert_eq!(sanitize(" .hidden. "), "hidden");
+ assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
+ assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
+ assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
}
#[test]
@@ -422,4 +502,119 @@ mod tests {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
+
+ fn temp_dir(tag: &str) -> std::path::PathBuf {
+ let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
+ std::fs::create_dir(&dir).unwrap();
+ dir
+ }
+
+ /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
+ /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
+ /// as "no parent" and the layer landed on the page — with every step reporting success.
+ #[test]
+ fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
+ let dir = temp_dir("run-step");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+
+ let mut s = Session::empty();
+ s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
+ let doc = s.doc().unwrap();
+ let page = s.page().unwrap();
+ let top = doc.node(page).unwrap().children.clone();
+ assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
+ assert_eq!(doc.node(top[0]).unwrap().name, "Card");
+ let inner = doc.node(top[0]).unwrap().children.clone();
+ assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
+ assert_eq!(doc.node(inner[0]).unwrap().name, "child");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ /// A script names its steps with `as` and reads paths into their results, like `command.batch`.
+ #[test]
+ fn a_script_refers_to_named_steps_and_paths() {
+ let dir = temp_dir("run-ref");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
+ {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+
+ let mut s = Session::empty();
+ s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
+ let doc = s.doc().unwrap();
+ let top = doc.node(s.page().unwrap()).unwrap().children.clone();
+ let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
+ assert_eq!(names, ["Card", "Copy"]);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ /// Two steps with one name fail before anything runs.
+ #[test]
+ fn a_script_with_a_name_twice_runs_nothing() {
+ let dir = temp_dir("run-dup-name");
+ let script = dir.join("script.json");
+ std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
+ let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
+ assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ /// A step that fails takes the whole script back: nothing is written, and the failure says so.
+ #[test]
+ fn a_failed_step_leaves_no_output() {
+ let dir = temp_dir("run-rollback");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ // The failure is the script's, not the call's: the CLI reports it and exits non-zero.
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+ assert!(!out.exists(), "a script that failed writes nothing");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
+ let dir = temp_dir("script-images-left-out");
+ let input = dir.join("big.figcraft");
+ std::fs::write(&input, b"kept").unwrap();
+ let link = dir.join("link.figcraft");
+ std::fs::hard_link(&input, &link).unwrap();
+ let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
+ let duplicate = Session::new();
+ let active = duplicate.ds().unwrap();
+
+ for out in [input, link] {
+ let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
+ assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
+ }
+ assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
+ assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
}
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
index 590b50f1..971b3905 100644
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -17,37 +17,68 @@ 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|fig] [--scale N] [--page NAME] [--node ID|NAME]
- [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
+ figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
+ [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert
figcraft-cli render -o [--scale N] [--page NAME]
figcraft-cli run [--in ] [--out ]
+ (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
figcraft-cli commands [--json] [--filter TEXT]
- figcraft-cli mcp [--attach PORT]
+ figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
- figcraft-cli help | --help | --version
+ figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
-wasn't imported), .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 (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may
+wasn't imported), .sketch (Sketch 48 or newer, best effort), .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 (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
+`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
+must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
+only inside a write root, everything else refused -- so giving no write root denies writing
+altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
+and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
+subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
+confined by the app it attaches to, not by flags passed to the CLI: start that app with
+`figcraft --control PORT` and the same roots (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
+/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
+/// unconfined, as before; giving either confines every file the subcommand touches (#833).
+const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
+
+fn roots_of(a: &Args) -> CliResult
\n", open("p")));
- }
- _ => {
- let kids = self.ctx.kids(n);
- let slot = self.ctx.param(n, "slot").filter(|_| self.code());
- let leptos = self.lang == CodeLang::Leptos;
- let open = open("div");
- if let Some(p) = slot.filter(|_| kids.is_empty()) {
- let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
- self.markup.push_str(&format!("{open}{{{content}}}\n"));
- } else if kids.is_empty() || self.ctx.too_deep(depth) {
- self.markup.push_str(&format!("{open}\n"));
- } else {
- self.markup.push_str(&format!("{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
- let inner = match slot {
- Some(p) if leptos => {
- self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
- depth + 2
- }
- Some(p) => {
- self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
- depth + 3
- }
- None => depth + 1,
- };
- let mut seen: Vec<&str> = Vec::new();
- // Bottom layer first: later elements paint on top, like the layer order.
- for k in kids {
- let Some(kn) = doc.node(k) else { continue };
- let count = seen.iter().filter(|s| **s == kn.name).count();
- seen.push(&kn.name);
- self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
- }
- match slot {
- Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
- Some(_) => self.markup.push_str(&format!("{p2}>\n{p1})}}\n")),
- None => {}
- }
- self.markup.push_str(&format!("{pad}\n"));
- }
- }
- }
- }
-}
-
-// ------------------------------------------------------------------ SwiftUI
-
-fn swift_color(c: Color) -> String {
- format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a)))
-}
-
-fn swift_weight(w: u16) -> &'static str {
- match w {
- 0..=150 => ".thin",
- 151..=250 => ".ultraLight",
- 251..=350 => ".light",
- 351..=450 => ".regular",
- 451..=550 => ".medium",
- 551..=650 => ".semibold",
- 651..=750 => ".bold",
- 751..=850 => ".heavy",
- _ => ".black",
- }
-}
-
-/// SwiftUI for a layer and its subtree at `depth`; `extra` modifiers go last (a free child's
-/// offset). A layer a boolean parameter shows sits in an `if`.
-pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) {
- let Some(n) = ctx.doc.node(id) else { return };
- if id != ctx.root
- && let Some(p) = ctx.param(n, "visible")
- {
- let pad = indent(depth);
- out.push_str(&format!("{pad}if {} {{\n", p.ident));
- swift_layer(ctx, n, depth + 1, out, extra);
- out.push_str(&format!("{pad}}}\n"));
- return;
- }
- swift_layer(ctx, n, depth, out, extra);
-}
-
-fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", n.name));
- return;
- }
- // Nested instances are calls; an instance swap parameter is a view.
- let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
- Some(p) => Some(format!("{pad}{}\n", p.ident)),
- None => ctx.call(n, CodeLang::SwiftUi, depth),
- };
- if let Some(call) = call {
- out.push_str(&call);
- for m in extra {
- out.push_str(&format!("{pad} {m}\n"));
- }
- return;
- }
- let mut mods: Vec = Vec::new();
- match &n.kind {
- NodeKind::Text(t) => {
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone());
- out.push_str(&format!("{pad}Text({text})\n"));
- mods.push(format!(
- ".font(.custom({}, size: {}).weight({}))",
- literal(&t.style.font_family, false),
- num(t.style.font_size),
- swift_weight(t.style.weight())
- ));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".foregroundColor({})", swift_color(c)));
- }
- }
- NodeKind::Ellipse { .. } => {
- out.push_str(&format!("{pad}Ellipse()\n"));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- NodeKind::Rectangle => {
- out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n))));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = ctx.kids(n);
- let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)),
- Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)),
- None => "ZStack(alignment: .topLeading) {".to_string(),
- };
- out.push_str(&format!("{pad}{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let slot = ctx.param(n, "slot");
- let inner = match slot {
- Some(p) => {
- out.push_str(&format!("{pad} if let {0} {{\n{pad} {0}\n{pad} }} else {{\n", p.ident));
- depth + 2
- }
- None => depth + 1,
- };
- for c in &children {
- // Free children sit at their offset inside a ZStack.
- let offset: Vec = match doc.node(*c).filter(|_| n.layout.is_none()) {
- Some(cn) => {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- vec![format!(".offset(x: {}, y: {})", num(x), num(y))]
- }
- None => Vec::new(),
- };
- swift(ctx, *c, inner, out, &offset);
- }
- if slot.is_some() {
- out.push_str(&format!("{pad} }}\n"));
- }
- out.push_str(&format!("{pad}}}\n"));
- if let Some(l) = &n.layout {
- let [t, r, b, lft] = l.padding;
- if [t, r, b, lft].iter().any(|v| *v > 0.0) {
- mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r)));
- }
- }
- mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height)));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".background({})", swift_color(c)));
- }
- if radius(n) > 0.0 {
- mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
- }
- }
- _ => {
- out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false)));
- mods.push(".resizable()".into());
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- }
- if let Some(c) = stroke_color(n) {
- mods.push(format!(
- ".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))",
- num(radius(n)),
- swift_color(c),
- num(n.stroke.weight)
- ));
- }
- if let Some(s) = drop_shadow(n) {
- mods.push(format!(
- ".shadow(color: {}, radius: {}, x: {}, y: {})",
- swift_color(s.color),
- num(s.radius / 2.0),
- num(s.offset.0),
- num(s.offset.1)
- ));
- }
- if n.opacity < 1.0 {
- mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
- }
- mods.extend(extra.iter().cloned());
- for m in mods {
- out.push_str(&format!("{pad} {m}\n"));
- }
-}
-
-// ------------------------------------------------------------------ Jetpack Compose
-
-/// A colour literal as `Color(0xAARRGGBB)` (Kotlin and Dart).
-fn argb_color(c: Color) -> String {
- let [r, g, b, a] = c.to_rgba8();
- format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
-}
-
-/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
-fn modifier(n: &Node, shape: &str, padding: bool) -> String {
- let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
- if let Some(s) = drop_shadow(n) {
- m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
- }
- if let Some(c) = first_fill(n) {
- m.push_str(&format!(".background({}, {shape})", argb_color(c)));
- }
- if let Some(c) = stroke_color(n) {
- m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), argb_color(c)));
- }
- if n.opacity < 1.0 {
- m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
- }
- if padding && let Some(l) = &n.layout {
- let [t, r, b, lft] = l.padding;
- if [t, r, b, lft].iter().any(|v| *v > 0.0) {
- m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
- }
- }
- m
-}
-
-/// Jetpack Compose for a layer and its subtree at `depth`. A layer a boolean parameter shows sits
-/// in an `if`.
-pub(crate) fn compose(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String) {
- let Some(n) = ctx.doc.node(id) else { return };
- if id != ctx.root
- && let Some(p) = ctx.param(n, "visible")
- {
- let pad = indent(depth);
- out.push_str(&format!("{pad}if ({}) {{\n", p.ident));
- compose_layer(ctx, n, depth + 1, out);
- out.push_str(&format!("{pad}}}\n"));
- return;
- }
- compose_layer(ctx, n, depth, out);
-}
-
-fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", n.name));
- return;
- }
- // Nested instances are calls; an instance swap parameter is a composable.
- let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
- Some(p) => Some(format!("{pad}{}()\n", p.ident)),
- None => ctx.call(n, CodeLang::Compose, depth),
- };
- if let Some(call) = call {
- out.push_str(&call);
- return;
- }
- let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
- match &n.kind {
- NodeKind::Text(t) => {
- let color = first_fill(n).map(argb_color).unwrap_or_else(|| "Color.Unspecified".into());
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
- out.push_str(&format!(
- "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
- num(t.style.font_size),
- (t.style.weight().clamp(100, 900) / 100) * 100
- ));
- }
- NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))),
- NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))),
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = ctx.kids(n);
- let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!(
- "Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{",
- modifier(n, &shape, true),
- num(l.gap)
- ),
- Some((_, l)) => format!(
- "Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
- modifier(n, &shape, true),
- num(l.gap)
- ),
- None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
- };
- out.push_str(&format!("{pad}{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let slot = ctx.param(n, "slot");
- let base = match slot {
- Some(p) => {
- out.push_str(&format!("{pad} if ({0} != null) {{\n{pad} {0}()\n{pad} }} else {{\n", p.ident));
- depth + 1
- }
- None => depth,
- };
- for c in &children {
- if n.layout.is_none()
- && let Some(cn) = doc.node(*c)
- {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(base + 1), num(x), num(y)));
- compose(ctx, *c, base + 2, out);
- out.push_str(&format!("{}}}\n", indent(base + 1)));
- } else {
- compose(ctx, *c, base + 1, out);
- }
- }
- if slot.is_some() {
- out.push_str(&format!("{pad} }}\n"));
- }
- out.push_str(&format!("{pad}}}\n"));
- }
- _ => out.push_str(&format!(
- "{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n",
- n.kind.label(),
- literal(&n.name, true),
- num(n.width),
- num(n.height)
- )),
- }
-}
-
-// ------------------------------------------------------------------ Flutter
-
-/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
-pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
-
-/// Dart indents by two spaces.
-pub(crate) fn flutter_indent(depth: usize) -> String {
- " ".repeat(depth)
-}
-
-/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
-/// in a Row or Column, or wherever the parent puts it.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub(crate) enum Place {
- Flow,
- At(f64, f64),
- Fill,
-}
-
-/// Dart's `FontWeight.w600` (a weight is hundreds).
-fn flutter_weight(w: u16) -> String {
- format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
-}
-
-/// A Row or Column's main-axis alignment (`None` for the default, the start).
-fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
- match a {
- MainAlign::Min => None,
- MainAlign::Center => Some("MainAxisAlignment.center"),
- MainAlign::Max => Some("MainAxisAlignment.end"),
- MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
- MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
- MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
- }
-}
-
-/// A Row or Column's cross-axis alignment; a child that fills it stretches the line instead.
-fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
- if stretch {
- return "CrossAxisAlignment.stretch";
- }
- match a {
- CrossAlign::Min => "CrossAxisAlignment.start",
- CrossAlign::Center => "CrossAxisAlignment.center",
- CrossAlign::Max => "CrossAxisAlignment.end",
- CrossAlign::Baseline => "CrossAxisAlignment.baseline",
- }
-}
-
-/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
-fn flutter_padding(n: &Node) -> Option {
- let l = n.layout.as_ref()?;
- let [t, r, b, left] = l.padding;
- if ![t, r, b, left].iter().any(|v| *v > 0.0) {
- return None;
- }
- Some(if t == r && r == b && b == left {
- format!("EdgeInsets.all({})", num(t))
- } else if t == b && left == r {
- format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
- } else {
- format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
- })
-}
-
-/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
-/// none of them. `pad` is the indentation of the constructor itself.
-fn box_decoration(n: &Node, pad: &str) -> Option {
- let mut parts: Vec = Vec::new();
- if let Some(c) = first_fill(n) {
- parts.push(format!("color: {}", argb_color(c)));
- }
- if matches!(n.kind, NodeKind::Ellipse { .. }) {
- parts.push("shape: BoxShape.circle".into());
- } else if radius(n) > 0.0 {
- parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
- }
- if let Some(c) = stroke_color(n) {
- parts.push(format!("border: Border.all(color: {}, width: {})", argb_color(c), num(n.stroke.weight)));
- }
- if let Some(s) = drop_shadow(n) {
- parts.push(format!(
- "boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
- argb_color(s.color),
- num(s.radius / 2.0),
- num(s.offset.0),
- num(s.offset.1)
- ));
- }
- if parts.is_empty() {
- return None;
- }
- let inner = format!("{pad} ");
- Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::()))
-}
-
-/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
-/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
-pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
- let Some(n) = ctx.doc.node(id) else { return };
- if id != ctx.root
- && let Some(p) = ctx.param(n, "visible")
- {
- out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
- flutter_placed(ctx, n, depth + 1, out, place);
- return;
- }
- flutter_placed(ctx, n, depth, out, place);
-}
-
-/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
-/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
-fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
- if ctx.too_deep(depth) {
- let pad = flutter_indent(depth);
- out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", n.name));
- return;
- }
- let mut depth = depth;
- let mut close: Vec = Vec::new();
- match place {
- Place::Flow => {}
- Place::At(x, y) => {
- let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
- out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
- close.push(format!("\n{pad})"));
- depth += 1;
- }
- Place::Fill => {
- let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
- out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
- close.push(format!("\n{pad})"));
- depth += 1;
- }
- }
- if n.opacity < 1.0 {
- let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
- out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
- close.push(format!("\n{pad})"));
- depth += 1;
- }
- // A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
- let mut body = String::new();
- flutter_widget(ctx, n, depth, &mut body);
- out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
- for c in close.iter().rev() {
- out.push_str(c);
- }
-}
-
-/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
-/// closing parenthesis sits at `depth`.
-fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
- let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
- // Nested instances are calls; an instance swap parameter is a widget.
- let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
- Some(p) => Some(p.ident.clone()),
- None => ctx.call(n, CodeLang::Flutter, depth),
- };
- if let Some(call) = call {
- out.push_str(call.trim_end_matches('\n'));
- return;
- }
- match &n.kind {
- NodeKind::Text(t) => {
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
- out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
- match t.align_h {
- TextAlignH::Left => {}
- TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
- TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
- TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
- }
- let s = &t.style;
- let mut style: Vec = Vec::new();
- if !s.font_family.is_empty() {
- style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
- }
- style.push(format!("fontSize: {}", num(s.font_size)));
- style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
- if let Some(c) = first_fill(n) {
- style.push(format!("color: {}", argb_color(c)));
- }
- if s.italic() {
- style.push("fontStyle: FontStyle.italic".into());
- }
- match s.line_height {
- // Dart's height is a multiple of the font size, not a length.
- LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
- LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
- _ => {}
- }
- match s.letter_spacing {
- LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
- LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
- _ => {}
- }
- match s.decoration {
- TextDecoration::None => {}
- TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
- TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
- }
- let sp = flutter_indent(depth + 2);
- out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::()));
- }
- NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
- out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
- if let Some(d) = box_decoration(n, &inner) {
- out.push_str(&format!("{inner}decoration: {d},\n"));
- }
- out.push_str(&format!("{pad})"));
- }
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = ctx.kids(n);
- let slot = ctx.param(n, "slot");
- out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
- if let Some(p) = flutter_padding(n) {
- out.push_str(&format!("{inner}padding: {p},\n"));
- }
- if let Some(d) = box_decoration(n, &inner) {
- out.push_str(&format!("{inner}decoration: {d},\n"));
- }
- if children.is_empty() && slot.is_none() {
- out.push_str(&format!("{pad})"));
- return;
- }
- out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
- let mut kids = String::new();
- flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
- out.push_str(kids.trim_start());
- out.push_str(&format!(",\n{pad})"));
- }
- _ => out.push_str(&format!(
- "{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
- n.kind.label(),
- literal(&format!("{}.png", n.name), true),
- num(n.width),
- num(n.height)
- )),
- }
-}
-
-/// The Row, Column or Stack holding a frame-like layer's children, at `depth`.
-fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) {
- let doc = ctx.doc;
- let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
- // Where each child sits, and whether one of them asks the line to stretch.
- let mut placed: Vec<(NodeId, Place)> = Vec::new();
- let mut stretch = false;
- for c in children {
- let Some(cn) = doc.node(*c) else { continue };
- let place = match n.layout.as_ref() {
- Some(l) => {
- let vertical = l.mode == LayoutMode::Vertical;
- let (main, cross) = if vertical {
- (cn.child_layout.sizing_v, cn.child_layout.sizing_h)
- } else {
- (cn.child_layout.sizing_h, cn.child_layout.sizing_v)
- };
- stretch |= cross == Sizing::Fill;
- if main == Sizing::Fill && !cn.child_layout.absolute { Place::Fill } else { Place::Flow }
- }
- // Free children sit at their position in the frame.
- None => {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- Place::At(x, y)
- }
- };
- placed.push((*c, place));
- }
- match n.layout.as_ref() {
- Some(l) => {
- out.push_str(&format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" }));
- if let Some(m) = flutter_main_align(l.main_align) {
- out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
- }
- out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
- if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
- out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
- }
- // The gap between the children; the space distributions already spread them.
- if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
- out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
- }
- }
- None => out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n")),
- }
- out.push_str(&format!("{inner}children: [\n"));
- let list = flutter_indent(depth + 2);
- // A slot shows the instance's content, or else the main's.
- if let Some(p) = slot {
- let name = &p.ident;
- out.push_str(&format!("{list}...?{name},\n"));
- out.push_str(&format!("{list}if ({name} == null) ...[\n"));
- }
- let kid = depth + 2 + usize::from(slot.is_some());
- for (id, place) in placed {
- flutter(ctx, id, kid, out, place);
- out.push_str(",\n");
- }
- if slot.is_some() {
- out.push_str(&format!("{list}],\n"));
- }
- out.push_str(&format!("{inner}],\n{pad})"));
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::Session;
- use crate::params::id_of;
- use serde_json::json;
-
- fn card(s: &mut Session) -> NodeId {
- let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap();
- s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap();
- s.execute("text.create", &json!({"text": "Say \"hi\" $5\nnow", "x": 0, "y": 0, "parent": f})).unwrap();
- s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
- s.execute("layout.add", &json!({"ids": [f]})).unwrap();
- f
- }
-
- #[test]
- fn swiftui_and_compose_for_an_auto_layout_card() {
- let mut s = Session::new();
- let f = card(&mut s);
- let doc = s.doc().unwrap();
- let sw = code_for(doc, f, CodeLang::SwiftUi);
- assert!(sw.contains("Stack(alignment:"), "{sw}");
- assert!(sw.contains("Text(\"Say \\\"hi\\\" $5\\nnow\")"), "escaped: {sw}");
- assert!(sw.contains("Ellipse()"));
- assert!(sw.contains(".clipShape(RoundedRectangle(cornerRadius: 12))"));
- let kt = code_for(doc, f, CodeLang::Compose);
- assert!(kt.contains("Arrangement.spacedBy("), "{kt}");
- assert!(kt.contains("\\$5"), "Kotlin escapes $: {kt}");
- assert!(kt.contains("CircleShape"));
- assert!(kt.contains("RoundedCornerShape(12.dp)"));
- let css = code_for(doc, f, CodeLang::Css);
- assert!(css.contains("display: flex;") && css.contains("border-radius: 12px;"));
- // Through the command.
- let v = s.execute("inspect.code", &json!({"id": f, "language": "compose"})).unwrap();
- assert_eq!(v["code"].as_str().unwrap(), kt);
- assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err());
- }
-
- #[test]
- fn free_children_get_offsets_and_deep_trees_are_capped() {
- let mut s = Session::new();
- let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
- let root = parent;
- s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
- for _ in 0..20 {
- parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
- }
- let doc = s.doc().unwrap();
- let sw = code_for(doc, root, CodeLang::SwiftUi);
- assert!(sw.contains(".offset(x: 7, y: 9)"), "{sw}");
- assert!(sw.contains("…"), "depth capped");
- let kt = code_for(doc, root, CodeLang::Compose);
- assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
- assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
- }
-
- #[test]
- fn flutter_builds_a_widget_tree() {
- let mut s = Session::new();
- let f = card(&mut s);
- let doc = s.doc().unwrap();
- let dart = code_for(doc, f, CodeLang::Flutter);
- // The card hugs its content, so its width is the layout's, not the 200 it was created with.
- let w = doc.node(f).map(|n| n.width).unwrap_or_default();
- assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
- assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
- assert!(dart.contains("child: Row(\n"), "{dart}");
- assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
- assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
- assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
- assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
- // Through the command: `dart` names the same language, and rem is for the web only.
- let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
- assert_eq!(v["code"].as_str().unwrap(), dart);
- assert_eq!(v["language"], "flutter");
- assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
- let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
- assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
- }
-
- #[test]
- fn flutter_positions_free_children_and_bounds_the_tree() {
- let mut s = Session::new();
- let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
- s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
- let mut parent = root;
- for _ in 0..20 {
- parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
- }
- let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
- assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
- assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
- // A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
- assert!(dart.contains(" )\n ),\n"), "{dart}");
- assert!(dart.contains("…"), "the depth is capped: {dart}");
- assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
- }
-
- #[test]
- fn flutter_expands_and_stretches_fill_children() {
- let mut s = Session::new();
- let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
- let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
- s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
- s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
- s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
- let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
- assert!(dart.contains("child: Row(\n"), "{dart}");
- assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
- assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
- }
-}
diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs
index 5b759391..d53eeff2 100644
--- a/crates/engine/src/collab_live_tests.rs
+++ b/crates/engine/src/collab_live_tests.rs
@@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() {
let (f, r) = frame_and_rect(room.server.document());
let start = room.server.replica().clone();
let cmds = vec![
- ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
- ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
- ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
+ crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})),
+ crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})),
+ crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
];
room.s(0).batch("Three", &cmds).unwrap();
room.pump();
diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs
index ffe59057..176ee474 100644
--- a/crates/engine/src/component_codegen_tests.rs
+++ b/crates/engine/src/component_codegen_tests.rs
@@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
(set, variants[0], variants[1])
}
+#[test]
+fn strict_mode_reserved_words_are_not_parameter_names() {
+ let mut s = Session::new();
+ let (c, title, _) = card(&mut s);
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title}));
+ let react = code(&mut s, c, "react");
+ assert!(react.contains("protectedValue"), "React renames the property:\n{react}");
+ for bad in [" protected?", " protected =", "{protected}"] {
+ assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}");
+ }
+}
+
#[test]
fn components_take_their_properties_as_parameters() {
let mut s = Session::new();
@@ -71,6 +83,17 @@ fn components_take_their_properties_as_parameters() {
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
}
+ let ark = code(&mut s, c, "arkui");
+ for want in [
+ "@Component\nexport struct Card {",
+ " @Prop showDot: boolean = true",
+ " @Prop title: string = \"Hello\"",
+ " build() {",
+ " if (this.showDot) {",
+ "Text(this.title)",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, c, "leptos");
for want in [
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
@@ -129,6 +152,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
// Text that needs escaping is an expression in JSX and escaped per language.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
@@ -136,7 +160,19 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
+ // A quoted title becomes a binding whose attribute value is escaped: the expression itself
+ // keeps its JS quotes, and the attribute's own quotes are entities.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
+ let vue = code(&mut s, i, "vue");
+ assert!(vue.starts_with("\n"), "{vue}");
+ // A plain attribute keeps HTML entities intact: a literal ampersand is written as `&`,
+ // and a literal `&` as `&`.
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
}
#[test]
@@ -192,6 +228,9 @@ fn variant_sets_share_one_definition() {
assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
+ let ark = code(&mut s, set, "arkui");
+ assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}");
+ assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}");
let leptos = code(&mut s, set, "leptos");
for want in [
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
@@ -209,6 +248,7 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Button({ state: ButtonState.Hover })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
}
@@ -236,16 +276,30 @@ fn instance_swap_properties_take_views() {
assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
+ let ark = code(&mut s, chip, "arkui");
+ for want in [
+ " @BuilderParam icon: () => void = this.defaultIconBuilder",
+ " @Builder\n defaultIconBuilder() {\n HeartIcon()\n }",
+ " this.icon()",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
+ let vue = code(&mut s, chip, "vue");
+ assert!(vue.contains(" icon?: unknown;"), "{vue}");
+ assert!(vue.contains(" icon: HeartIcon,"), "the swap defaults to its component: {vue}");
+ assert!(vue.contains(""), "a swap is a component, not an element: {vue}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " }} />\n");
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
}
#[test]
@@ -263,6 +317,14 @@ fn slots_take_optional_content() {
let dart = code(&mut s, panel, "flutter");
assert!(dart.contains(" final List? content;\n") && dart.contains(" this.content,\n"), "{dart}");
assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
+ let ark = code(&mut s, panel, "arkui");
+ for want in [
+ " @BuilderParam content: () => void = this.defaultContentBuilder",
+ " @Builder\n defaultContentBuilder() {\n",
+ " this.content()",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, panel, "leptos");
assert!(leptos.contains(" #[prop(optional, into)] content: Option,"), "{leptos}");
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
@@ -279,6 +341,8 @@ fn slots_take_optional_content() {
assert!(usage.starts_with("") && usage.ends_with("}} />\n"), "{usage}");
let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
+ let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
+ assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
#[test]
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
deleted file mode 100644
index da0b2d13..00000000
--- a/crates/engine/src/components.rs
+++ /dev/null
@@ -1,1926 +0,0 @@
-//! Components and instances.
-//!
-//! An instance's layers are real nodes copied from its main component; each copy records the
-//! main-component node it mirrors (`source`) and which property groups were changed locally
-//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer
-//! ids and re-applying overrides (matched by source, then by layer name path, so switching
-//! variants keeps compatible overrides). Variants are components inside a component set, named
-//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through
-//! `prop_refs` on the layers they control.
-//!
-//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
-//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
-//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
-//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
-//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
-//! settings and interactions are its own, like its place. `size` and `position` overrides of
-//! layers inside an instance are still read and kept, but new ones are refused
-//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
-//! main; the instance itself resizes.
-//!
-//! Switching variants keeps an override where the layer's name path matches and the two variants
-//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
-//! only text layers' overrides ([`keep_text_overrides`]).
-//!
-//! Instances never gain or lose layers of their own: commands that would add, remove or reorder
-//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A
-//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An
-//! instance's copy of it shows the main's default content until its layers are edited; from then
-//! on (override group `slot`) its children are the instance's own content: free to edit, kept
-//! across syncs and variant switches, and outside the instance's structure (instances placed in it
-//! stand alone).
-
-use std::collections::{BTreeMap, BTreeSet};
-
-use figcraft_doc::*;
-use figcraft_geom::Affine;
-use serde_json::Value;
-
-/// Nesting limit for instances inside instances.
-const MAX_DEPTH: usize = 16;
-/// The override group (and `prop_refs` field) of slots.
-pub const SLOT: &str = "slot";
-/// Every override group a layer can carry.
-pub const OVERRIDE_GROUPS: &[&str] = &[
- "fills",
- "strokes",
- "effects",
- "opacity",
- "visible",
- "name",
- "corners",
- "characters",
- "textStyle",
- "padding",
- "gap",
- "alignment",
- "grids",
- "exports",
- "interactions",
- "size",
- "position",
- "component",
- "props",
- SLOT,
-];
-
-fn is_instance(doc: &Document, id: NodeId) -> bool {
- doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))
-}
-
-/// A slot (inside an instance) whose children are the instance's own content.
-fn holds_content(n: &Node) -> bool {
- n.overrides.contains(SLOT)
-}
-
-/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content:
-/// content belongs to no instance (it edits freely, and instances placed in it stand alone).
-fn scope(doc: &Document, id: NodeId) -> Vec {
- let mut out = vec![id];
- for a in doc.ancestors(id) {
- if doc.node(a).is_some_and(holds_content) {
- break;
- }
- out.push(a);
- }
- out
-}
-
-/// The component a node belongs to (itself, or the nearest component ancestor).
-pub fn owning_component(doc: &Document, id: NodeId) -> Option {
- let mut chain = vec![id];
- chain.extend(doc.ancestors(id));
- chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
-}
-
-/// The innermost instance containing `id` (`id` itself when it is an instance).
-pub fn owning_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().find(|c| is_instance(doc, *c))
-}
-
-/// The outermost instance containing `id` (`id` itself when it is a top-level instance).
-pub fn root_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c))
-}
-
-/// The innermost instance `id` is a layer of (never `id` itself).
-fn enclosing_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c))
-}
-
-/// Whether `id` is a layer inside an instance (not the outermost instance itself).
-pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
- enclosing_instance(doc, id).is_some()
-}
-
-/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
-/// a slot property of its component, or an instance's copy of one (followed through its sources).
-pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
- let mut cur = id;
- for _ in 0..=MAX_DEPTH {
- let n = doc.node(cur)?;
- if !matches!(n.kind, NodeKind::Frame) {
- return None;
- }
- if let Some(name) = n.prop_refs.get(SLOT) {
- let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None };
- return match props.get(name) {
- Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())),
- _ => None,
- };
- }
- cur = n.source?;
- }
- None
-}
-
-/// The name of the slot property frame `id` stands for ([`slot_def`]).
-pub fn slot_name(doc: &Document, id: NodeId) -> Option {
- slot_def(doc, id).map(|(name, _)| name.to_string())
-}
-
-/// Whether `id` is a slot (in a main component, or an instance's copy of one).
-pub fn is_slot(doc: &Document, id: NodeId) -> bool {
- slot_def(doc, id).is_some()
-}
-
-/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's
-/// default content.
-pub fn slot_edited(doc: &Document, id: NodeId) -> bool {
- doc.node(id).is_some_and(holds_content)
-}
-
-/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and
-/// reordered freely.
-pub fn slot_scope(doc: &Document, id: NodeId) -> Option {
- let mut chain = vec![id];
- chain.extend(doc.ancestors(id));
- for c in chain {
- if is_instance(doc, c) {
- return None;
- }
- if is_slot(doc, c) {
- return Some(c);
- }
- }
- None
-}
-
-/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an
-/// instance, outside its slots.
-pub fn structure_locked(doc: &Document, id: NodeId) -> bool {
- owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none()
-}
-
-/// For tree walks that look for where layers can go: whether the children of `id` are locked (in
-/// an instance, outside its slots), given whether `id` itself is (`locked`).
-pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool {
- is_instance(doc, id) || (locked && !is_slot(doc, id))
-}
-
-/// Whether layer `id`'s place (parent and position among its siblings) changed between two
-/// versions only where instance structure allows it.
-pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool {
- let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id)));
- let (was, is) = (place(before), place(after));
- was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p)))
-}
-
-/// The slots of a component or instance (its own: not those of instances nested in it, nor of the
-/// content put into its slots), with their property names, top-most first.
-pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
- let mut seen = 0usize;
- while let Some(c) = stack.pop() {
- seen += 1;
- if seen > 1_000_000 {
- break;
- }
- let Some(n) = doc.node(c) else { continue };
- if let Some(name) = slot_name(doc, c) {
- out.push((c, name));
- }
- if !is_instance(doc, c) && !holds_content(n) {
- stack.extend(n.children.iter().rev().copied());
- }
- }
- out
-}
-
-/// All instances of `component` in the document.
-pub fn instances_of(doc: &Document, component: NodeId) -> Vec {
- doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
-}
-
-/// The component set a variant belongs to.
-pub fn variant_set(doc: &Document, component: NodeId) -> Option {
- doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
-}
-
-/// The layer name a fresh instance of `component` gets: the set's name for variants.
-pub fn default_instance_name(doc: &Document, component: NodeId) -> String {
- let set = variant_set(doc, component).and_then(|s| doc.node(s));
- set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into())
-}
-
-/// Default property values of a component (slots hold content, not values: they have none).
-pub fn default_props(comp: &Node) -> BTreeMap {
- let mut out = BTreeMap::new();
- if let NodeKind::Component { props, .. } = &comp.kind {
- for (k, d) in props {
- if takes_value(d) {
- out.insert(k.clone(), prop_default_value(d));
- }
- }
- }
- out
-}
-
-/// Whether instances store a value for the property (every kind but slots).
-pub fn takes_value(d: &PropDef) -> bool {
- !matches!(d, PropDef::Slot { .. })
-}
-
-/// A property definition's default as a JSON value.
-pub fn prop_default_value(d: &PropDef) -> Value {
- match d {
- PropDef::Boolean { default } => Value::Bool(*default),
- PropDef::Text { default } => Value::String(default.clone()),
- PropDef::InstanceSwap { default, .. } => Value::from(default.0),
- PropDef::Variant { default, .. } => Value::String(default.clone()),
- PropDef::Slot { .. } => Value::Null,
- }
-}
-
-/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
-pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result {
- create_instance_at(doc, component, parent, x, y, None)
-}
-
-/// [`create_instance`] at child `index` of `parent` (None: on top).
-pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result {
- let comp = doc.get(component)?.clone();
- if !matches!(comp.kind, NodeKind::Component { .. }) {
- return Err(DocError::Invalid(format!("{component} is not a component")));
- }
- if doc.is_ancestor(component, parent) {
- return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
- }
- let id = doc.alloc_id();
- let mut root = comp.clone();
- root.id = id;
- root.name = default_instance_name(doc, component);
- root.kind = NodeKind::Instance { component, props: default_props(&comp) };
- root.children.clear();
- root.parent = None;
- root.source = Some(component);
- root.overrides.clear();
- root.transform = Affine::translate((x, y));
- root.prop_refs.clear();
- root.annotations.clear();
- root.code_links.clear();
- // The main's dev resources show on its instances without being copied.
- root.dev_resources.clear();
- // The main's interactions play on its instances in prototypes; the instance's own start empty.
- root.interactions.clear();
- doc.insert(root, parent, index)?;
- sync_instance(doc, id);
- Ok(id)
-}
-
-/// Which part of the carried overrides to apply.
-#[derive(Clone, Copy, PartialEq)]
-enum Carry {
- /// Paint, text, visibility, … (everything but geometry).
- Values,
- /// Values plus size (instance roots: their size decides how children follow constraints).
- Root,
- /// Size and position (children, after constraints ran).
- Geometry,
-}
-
-/// Copy the overridden property groups of `from` onto `to`.
-fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
- for g in &from.overrides {
- let geo = matches!(g.as_str(), "size" | "position");
- match what {
- Carry::Values if geo => continue,
- Carry::Root if g == "position" => continue,
- Carry::Geometry if !geo => continue,
- _ => {}
- }
- copy_group(from, to, g);
- }
- if what != Carry::Geometry {
- to.overrides = from.overrides.clone();
- }
-}
-
-/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
-pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
- if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
- to.bound.retain(|k, _| binding_group(k) != Some(g));
- to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone())));
- }
- match g {
- "fills" => {
- to.fills = from.fills.clone();
- to.styles.fill = from.styles.fill.clone();
- }
- "strokes" => {
- to.strokes = from.strokes.clone();
- to.stroke = from.stroke.clone();
- to.styles.stroke = from.styles.stroke.clone();
- }
- "effects" => {
- to.effects = from.effects.clone();
- to.styles.effect = from.styles.effect.clone();
- }
- "opacity" => {
- to.opacity = from.opacity;
- to.blend = from.blend;
- }
- "visible" => to.visible = from.visible,
- "name" => to.name = from.name.clone(),
- "corners" => to.corners = from.corners,
- "characters" => {
- if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
- b.characters = a.characters.clone();
- b.runs = a.runs.clone();
- b.paragraphs = a.paragraphs.clone();
- }
- }
- "textStyle" => {
- if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
- b.style = a.style.clone();
- b.runs = a.runs.clone();
- b.auto_resize = a.auto_resize;
- b.align_h = a.align_h;
- b.align_v = a.align_v;
- b.paragraph_spacing = a.paragraph_spacing;
- b.paragraph_indent = a.paragraph_indent;
- b.list = a.list;
- b.paragraphs = a.paragraphs.clone();
- b.list_spacing = a.list_spacing;
- b.hanging_punctuation = a.hanging_punctuation;
- b.hanging_list = a.hanging_list;
- b.max_lines = a.max_lines;
- b.features = a.features.clone();
- }
- to.styles.text = from.styles.text.clone();
- }
- "padding" | "gap" | "alignment" => {
- if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) {
- match g {
- "padding" => b.padding = a.padding,
- "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap),
- _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align),
- }
- }
- }
- "grids" => {
- to.grids = from.grids.clone();
- to.styles.grid = from.styles.grid.clone();
- }
- "exports" => to.exports = from.exports.clone(),
- "interactions" => to.interactions = from.interactions.clone(),
- "size" => {
- to.width = from.width;
- to.height = from.height;
- to.child_layout = from.child_layout.clone();
- if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
- b.auto_resize = a.auto_resize;
- }
- }
- "position" => to.transform = from.transform,
- "component" => {
- if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
- *b = *a;
- }
- }
- "props" => {
- if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) {
- *b = a.clone();
- }
- carry_prop_bindings(from, to);
- }
- _ => {}
- }
-}
-
-/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With
-/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames
-/// don't break matching), and the content of slots holding the instance's own is left out.
-fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap {
- let mut out = BTreeMap::new();
- let mut stack = vec![(root, String::new())];
- while let Some((id, prefix)) = stack.pop() {
- let Some(n) = doc.node(id) else { continue };
- let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
- for c in &n.children {
- let Some(cn) = doc.node(*c) else { continue };
- // A nested instance linked only to its own component so far (an imported one) mirrors no
- // layer yet: it goes by its own name, like the main's layer it will be matched with.
- let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component));
- let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s));
- let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str());
- let k = seen.entry(name).or_insert(0);
- let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") };
- *k += 1;
- let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") };
- out.insert(*c, path.clone());
- if out.len() > 1_000_000 {
- return out;
- }
- if !(src_names && holds_content(cn)) {
- stack.push((*c, path));
- }
- }
- }
- out
-}
-
-/// The binding key prefix of an instance's component property value
-/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a
-/// string variable.
-pub const PROP_BINDING: &str = "componentProperties/";
-
-/// Give `to` the component property bindings of instance `from` (they go with its property
-/// values, which an instance keeps across rebuilds).
-fn carry_prop_bindings(from: &Node, to: &mut Node) {
- to.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
- to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone())));
-}
-
-/// The override group a property-bound field belongs to.
-fn field_group(field: &str) -> &'static str {
- match field {
- "visible" => "visible",
- "characters" => "characters",
- "mainComponent" => "component",
- _ => "",
- }
-}
-
-/// Apply one component-property value to a layer.
-fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
- match field {
- "visible" => {
- if let Some(b) = val.as_bool() {
- n.visible = b;
- }
- }
- "characters" => {
- if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
- && t.characters != sv
- {
- let len = t.char_count();
- t.replace_range(0, len, &sv);
- }
- }
- "mainComponent" => {
- if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val))
- && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }))
- {
- *c = v;
- }
- }
- _ => {}
- }
-}
-
-/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its
-/// slots. An instance whose main component was deleted keeps its layers as they are.
-pub fn sync_instance(doc: &mut Document, inst: NodeId) {
- // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits).
- let old: Vec<(std::sync::Arc, bool)> =
- doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect();
- let kept = slot_content(doc, inst);
- sync_instance_depth(doc, inst, 0, BTreeMap::new());
- restore_slot_content(doc, kept);
- for (a, was_dirty) in old {
- doc.keep_if_unchanged(a, was_dirty);
- }
-}
-
-/// The slots in instance `inst` (nested instances included) holding the instance's own content:
-/// each with its content (the subtrees of its children, pre-order).
-fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
- while let Some(c) = stack.pop() {
- let Some(n) = doc.node(c) else { continue };
- if holds_content(n) {
- let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect();
- out.push((c, nodes));
- } else {
- stack.extend(n.children.iter().copied());
- }
- }
- out
-}
-
-/// Put slot content back after a rebuild, into the slots that still hold the instance's own
-/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints
-/// and geometry overrides ran before, so the content keeps its place.
-fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) {
- for (slot, nodes) in kept {
- let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id));
- if !keep {
- continue;
- }
- let ids: Vec = nodes.iter().map(|n| n.id).collect();
- for n in nodes {
- // Pre-order: every parent is back before its children.
- let _ = insert_linked(doc, n);
- }
- // Never leave links to a layer that couldn't come back.
- for id in ids.into_iter().chain([slot]) {
- let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c)));
- if dangling {
- let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default();
- if let Some(n) = doc.node_mut(id) {
- n.children = live;
- }
- }
- }
- }
-}
-
-/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested
-/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides.
-fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) {
- if depth > MAX_DEPTH {
- return;
- }
- let Some(mut root) = doc.node(inst).cloned() else { return };
- let NodeKind::Instance { component, props: root_props } = &root.kind else { return };
- let component = *component;
- let Some(comp) = doc.node(component).cloned() else { return };
- if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) {
- // A missing main, or an instance inside its own main (would recurse forever).
- return;
- }
- let mut eff_props = default_props(&comp);
- for (k, v) in root_props {
- if eff_props.contains_key(k) {
- eff_props.insert(k.clone(), v.clone());
- }
- }
- // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
- // one layer mirrors it) and, only when those don't match, by name path.
- let mut old_ids = old_layers(doc, inst);
- old_ids.extend(spare.keys().copied());
- old_ids.sort();
- old_ids.dedup();
- let mut old: BTreeMap> = spare;
- let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
- let mut by_src: BTreeMap> = BTreeMap::new();
- for i in &old_ids {
- let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
- if let Some(s) = n.source {
- if let Some(p) = n.parent {
- by_source.entry((p, s)).or_insert(*i);
- }
- by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
- }
- old.insert(*i, n);
- }
- let mut by_path: Option> = None;
- let mut main_paths: Option> = None;
- // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
- // parent keeps its id and overrides).
- let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
- let mut map: BTreeMap = BTreeMap::new();
- map.insert(component, inst);
- let mut used: BTreeSet = BTreeSet::new();
- // Slots holding the instance's own content (new ids), and main layers not copied: the
- // default content of those slots.
- let mut edited: BTreeSet = BTreeSet::new();
- let mut skipped: BTreeSet = BTreeSet::new();
- let mut fresh_ids = Vec::new();
- for s in &src {
- let Some(sn) = doc.node(*s) else { continue };
- if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
- skipped.insert(*s);
- continue;
- }
- let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
- let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
- if found.is_none() {
- let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
- let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false));
- found = mains.get(s).and_then(|p| paths.get(p)).copied();
- }
- let nid = match found {
- Some(o) if used.insert(o) => o,
- _ => {
- fresh_ids.push(*s);
- continue;
- }
- };
- if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
- old.insert(nid, std::sync::Arc::new(m));
- }
- if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
- edited.insert(nid);
- }
- map.insert(*s, nid);
- }
- for c in root.children.clone() {
- let _ = doc.remove(c);
- }
- for s in fresh_ids {
- let nid = doc.alloc_id();
- map.insert(s, nid);
- }
- // Build the copies (pre-order: parents first).
- let mut enclosing: BTreeMap> = BTreeMap::new();
- enclosing.insert(inst, vec![inst]);
- let mut inst_props: BTreeMap> = BTreeMap::new();
- inst_props.insert(inst, eff_props);
- let mut nested = Vec::new();
- let mut new_nodes = Vec::new();
- // Text layers showing other text or style than their main layer (to re-measure).
- let mut remeasure = Vec::new();
- for s in &src {
- let Some(orig) = doc.node(*s) else { continue };
- let Some(&nid) = map.get(s) else { continue };
- let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
- let mut n = orig.clone();
- n.id = nid;
- n.parent = Some(parent);
- n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
- n.source = Some(*s);
- n.overrides.clear();
- n.prop_refs.clear();
- let prev = old.get(&nid);
- // Annotations belong to the layer they were added to, not to its copies.
- n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default();
- if let Some(o) = prev {
- carry_overrides(o, &mut n, Carry::Values);
- keep_laid_out(doc, orig, o, &mut n);
- }
- if !edited.contains(&nid) {
- // No longer a slot in the main: it mirrors the main again.
- n.overrides.remove(SLOT);
- }
- // Component properties: the main layer's bindings use this instance's values; layers of
- // nested instances use the nested instance's values (one level per source hop).
- let enc = enclosing.get(&parent).cloned().unwrap_or_default();
- let mut chain: Vec<&Node> = vec![orig];
- while chain.len() < enc.len() {
- let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
- chain.push(next);
- }
- for (j, level) in chain.iter().enumerate().rev() {
- let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
- for (field, prop) in &level.prop_refs {
- let g = field_group(field);
- if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
- continue;
- }
- if let Some(val) = props.get(prop) {
- apply_prop(doc, &mut n, field, val);
- }
- }
- }
- if let NodeKind::Instance { component: c, props } = &n.kind {
- inst_props.insert(nid, props.clone());
- let mut e = enc;
- e.push(nid);
- enclosing.insert(nid, e);
- if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
- nested.push(nid);
- }
- } else {
- enclosing.insert(nid, enc);
- }
- if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
- && a != b
- {
- remeasure.push(nid);
- }
- new_nodes.push(n);
- }
- for n in new_nodes {
- let (id, parent) = (n.id, n.parent);
- if insert_linked(doc, n).is_err()
- && let Some(p) = parent.and_then(|p| doc.node_mut(p))
- {
- p.children.retain(|c| *c != id);
- }
- }
- // Root: follows the main except its own placement, name, size and overrides.
- let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
- let mut fresh = comp.clone();
- fresh.id = inst;
- fresh.parent = root.parent;
- fresh.children = order;
- fresh.transform = root.transform;
- fresh.kind = root.kind.clone();
- carry_prop_bindings(&root, &mut fresh);
- // A nested instance keeps mirroring its layer of the outer main.
- let nested_copy = inside_instance(doc, inst);
- fresh.source = if nested_copy { root.source } else { Some(component) };
- fresh.constraints = root.constraints;
- fresh.child_layout = root.child_layout.clone();
- fresh.interactions = root.interactions.clone();
- fresh.exports = root.exports.clone();
- fresh.prop_refs = root.prop_refs.clone();
- fresh.plugin_data = root.plugin_data.clone();
- fresh.annotations = root.annotations.clone();
- // Code links belong to the main component; instances read them from it.
- fresh.code_links.clear();
- // An instance's own dev resources (its main's are listed with them, not copied).
- fresh.dev_resources = root.dev_resources.clone();
- fresh.locked = root.locked;
- fresh.visible = root.visible;
- fresh.var_modes = root.var_modes.clone();
- fresh.is_mask = root.is_mask;
- fresh.exposed = root.exposed;
- fresh.guides = root.guides.clone();
- fresh.dev_status = root.dev_status;
- fresh.dev_changed = root.dev_changed;
- fresh.dev_note = root.dev_note.clone();
- fresh.constrain_proportions = root.constrain_proportions;
- // How the instance combines in a boolean group it sits in is its own.
- fresh.bool_op = root.bool_op;
- // A swapped nested instance keeps the name its layer was given in the outer main.
- let renamed = nested_copy
- && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {
- NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c),
- _ => false,
- });
- fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
- // Switched to another variant: its own overrides stay where both variants agreed.
- if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
- let differs = diff_groups(prev, &comp);
- root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
- }
- carry_overrides(&root, &mut fresh, Carry::Root);
- if let Some(r) = doc.node_mut(inst) {
- *r = fresh;
- }
- // A resized instance: children follow their constraints (auto layout re-flows later).
- let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
- if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
- figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
- remeasure = map.values().copied().collect();
- }
- // Geometry overrides of children win over constraints.
- for s in &src {
- let Some(nid) = map.get(s).copied() else { continue };
- let Some(o) = old.get(&nid) else { continue };
- if !o.overrides.contains("size") && !o.overrides.contains("position") {
- continue;
- }
- remeasure.push(nid);
- let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
- if o.overrides.contains("size") {
- // Through set_size so vector networks scale with the box.
- figcraft_layout::set_size(doc, nid, o.width, o.height);
- if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
- figcraft_layout::apply_constraints(doc, nid, ow, oh);
- }
- }
- if let Some(n) = doc.node_mut(nid) {
- carry_overrides(o, n, Carry::Geometry);
- }
- }
- // Text whose characters or style differ from the main re-measures.
- for nid in remeasure {
- if let Some(n) = doc.node(nid)
- && let Some((w, h)) = figcraft_text::auto_size(n)
- && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
- {
- figcraft_layout::set_size(doc, nid, w, h);
- }
- }
- // Nested instances showing another main than the main's copy (swapped): rebuild from it,
- // with the old layers below them this rebuild didn't reuse.
- let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect();
- for n in nested {
- let below: BTreeMap> = unused
- .iter()
- .filter(|(_, x)| {
- let mut cur = x.parent;
- for _ in 0..=MAX_DEPTH * 4 {
- match cur {
- Some(p) if p == n => return true,
- Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
- None => return false,
- }
- }
- false
- })
- .map(|(i, x)| (*i, x.clone()))
- .collect();
- sync_instance_depth(doc, n, depth + 1, below);
- }
-}
-
-/// Whether main components `a` and `b` are two variants of one set.
-fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
- a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
-}
-
-/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched
-/// variants): `o` without the overrides of properties the two variants' layers differ in (`None`
-/// when it keeps them all). Text layers keep every override.
-fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option {
- let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?;
- if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) {
- return None;
- }
- if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) {
- return None;
- }
- let differs = diff_groups(from, doc.node(s)?);
- let mut m = o.clone();
- m.overrides.retain(|g| !differs.contains(&g.as_str()));
- (m.overrides.len() != o.overrides.len()).then_some(m)
-}
-
-/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of
-/// its slots): what swapping it from the Assets panel keeps.
-pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) {
- for d in old_layers(doc, inst).into_iter().chain([inst]) {
- let Some(n) = doc.node(d) else { continue };
- let text = matches!(n.kind, NodeKind::Text(_));
- let keep = |g: &String| text || g == SLOT || (d == inst && g == "size");
- if !n.overrides.iter().all(keep)
- && let Some(m) = doc.node_mut(d)
- {
- m.overrides.retain(keep);
- }
- }
-}
-
-/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old
-/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually
-/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever
-/// something that sizes or places layers did change.
-fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) {
- if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") {
- return;
- }
- let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
- if flows {
- // The main's rotation and skew, the old place.
- let [a, b, c, d, _, _] = n.transform.as_coeffs();
- let [.., e, f] = o.transform.as_coeffs();
- n.transform = Affine::new([a, b, c, d, e, f]);
- }
- // Only layers whose shape is their box (a vector's points scale with it).
- let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. });
- let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows));
- if derived(n.child_layout.sizing_h) {
- n.width = o.width;
- }
- if derived(n.child_layout.sizing_v) {
- n.height = o.height;
- }
-}
-
-/// The layers of instance `inst` a rebuild matches against (every layer below it but the content
-/// of slots holding its own), ascending.
-fn old_layers(doc: &Document, inst: NodeId) -> Vec {
- let mut out = Vec::new();
- let mut stack = vec![inst];
- while let Some(id) = stack.pop() {
- let Some(n) = doc.node(id) else { continue };
- for c in &n.children {
- out.push(*c);
- if out.len() > 1_000_000 {
- break;
- }
- if doc.node(*c).is_some_and(|cn| !holds_content(cn)) {
- stack.push(*c);
- }
- }
- }
- out.sort();
- out.dedup();
- out
-}
-
-/// Insert a node whose parent/children links are already set (used when rebuilding instances).
-fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> {
- let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?;
- let id = n.id;
- let mut n = n;
- let kids = std::mem::take(&mut n.children);
- doc.insert(n, parent, None)?;
- if let Some(m) = doc.node_mut(id) {
- m.children = kids;
- }
- // The parent may already list us (its children were copied with ids): keep the first.
- if let Some(p) = doc.node_mut(parent) {
- let mut seen = BTreeSet::new();
- p.children.retain(|c| seen.insert(*c));
- }
- Ok(())
-}
-
-/// The property groups whose value layer `n` takes from the same variables as its main layer
-/// `src` (they differ only where the two resolve in different variable modes).
-pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> {
- let mut out = Vec::new();
- for (k, v) in &n.bound {
- let Some(g) = binding_group(k) else { continue };
- if src.bound.get(k) == Some(v) && !out.contains(&g) {
- out.push(g);
- }
- }
- out
-}
-
-/// The override group a variable binding key feeds (`None` for component property and layout
-/// grid bindings, which follow other rules).
-fn binding_group(key: &str) -> Option<&'static str> {
- use crate::cmd::lists::{BoundList, list_binding};
- if key.starts_with(PROP_BINDING) {
- return None;
- }
- match list_binding(key).map(|(l, _, _)| l) {
- Some(BoundList::Fills) => Some("fills"),
- Some(BoundList::Strokes) => Some("strokes"),
- Some(BoundList::Effects) => Some("effects"),
- Some(BoundList::Grids) => None,
- None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"),
- }
-}
-
-/// The variable bindings of `n` that feed group `g`.
-fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a {
- n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g))
-}
-
-/// Property groups that differ between two versions of a node.
-pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
- let mut out = Vec::new();
- if a.fills != b.fills {
- out.push("fills");
- }
- if a.strokes != b.strokes || a.stroke != b.stroke {
- out.push("strokes");
- }
- if a.effects != b.effects {
- out.push("effects");
- }
- if a.opacity != b.opacity || a.blend != b.blend {
- out.push("opacity");
- }
- if a.visible != b.visible {
- out.push("visible");
- }
- if a.name != b.name {
- out.push("name");
- }
- if a.corners != b.corners {
- out.push("corners");
- }
- if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
- out.push("size");
- }
- if a.transform != b.transform {
- out.push("position");
- }
- if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
- if x.characters != y.characters {
- out.push("characters");
- }
- if text_style_differs(x, y) {
- out.push("textStyle");
- }
- }
- if let (Some(x), Some(y)) = (&a.layout, &b.layout) {
- if x.padding != y.padding {
- out.push("padding");
- }
- if x.gap != y.gap || x.cross_gap != y.cross_gap {
- out.push("gap");
- }
- if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align {
- out.push("alignment");
- }
- }
- if a.grids != b.grids || a.styles.grid != b.styles.grid {
- out.push("grids");
- }
- if a.exports != b.exports {
- out.push("exports");
- }
- if a.interactions != b.interactions {
- out.push("interactions");
- }
- if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
- if x != y {
- out.push("component");
- }
- if px != py {
- out.push("props");
- }
- }
- out
-}
-
-/// Whether two texts differ in anything but their characters (the `textStyle` group).
-fn text_style_differs(x: &TextData, y: &TextData) -> bool {
- x.style != y.style
- || x.runs != y.runs
- || x.auto_resize != y.auto_resize
- || x.align_h != y.align_h
- || x.align_v != y.align_v
- || x.paragraph_spacing != y.paragraph_spacing
- || x.paragraph_indent != y.paragraph_indent
- || x.list != y.list
- || x.paragraphs != y.paragraphs
- || x.list_spacing != y.list_spacing
- || x.hanging_punctuation != y.hanging_punctuation
- || x.hanging_list != y.hanging_list
- || x.max_lines != y.max_lines
- || x.features != y.features
-}
-
-/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
-/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text.
-fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool {
- for a in doc.ancestors(id) {
- let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue };
- if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 {
- return true;
- }
- }
- let Some(n) = doc.node(id) else { return true };
- if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute {
- return true;
- }
- if group == "size"
- && let NodeKind::Text(t) = &n.kind
- {
- let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6);
- return match t.auto_resize {
- TextAutoResize::WidthAndHeight => true,
- TextAutoResize::Height => !wchanged,
- _ => false,
- };
- }
- false
-}
-
-/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
-/// whose properties or main were switched, or whose overrides were reset.
-pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) {
- // 0. A slot whose layers were added, removed or reordered holds the instance's own content
- // from now on; copies of instance layers placed elsewhere stop mirroring their main.
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- if n.children == b.children || owning_instance(doc, id).is_none() {
- continue;
- }
- if let Some(slot) = slot_scope(doc, id)
- && !slot_edited(doc, slot)
- {
- own_slot_content(doc, slot);
- }
- }
- for &id in changed {
- if !before.contains(id) {
- relink_copy(doc, id);
- }
- }
- let mut resync: BTreeSet = BTreeSet::new();
- // 1. Overrides for layers edited inside instances.
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- let Some(inst) = owning_instance(doc, id) else { continue };
- let top_root = inst == id && !inside_instance(doc, id);
- if top_root
- && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
- && (c1 != c2 || p1 != p2)
- {
- // Switching the main or a property value of an instance isn't an override.
- resync.insert(id);
- }
- let raw = diff_groups(b, n);
- let mut groups = raw.clone();
- // An instance's own place, export settings and interactions are its own, not overrides.
- if top_root {
- groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
- }
- if groups.iter().any(|g| matches!(*g, "size" | "position")) {
- groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
- }
- // A value equal to the main's isn't an override (setting it back clears the override).
- let mut cleared = Vec::new();
- if let Some(src) = n.source.and_then(|s| doc.node(s)) {
- let mut differs = diff_groups(src, n);
- if inst == id {
- differs.retain(|g| *g != "name");
- if n.name != default_instance_name(doc, src.id) {
- differs.push("name");
- }
- }
- // A value from the same variable as the main's (resolved in this layer's modes) isn't an
- // override either.
- let from_variables = variable_groups(n, src);
- groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
- // Binding another variable (or none) where the main binds one is an override.
- for (k, _) in b.bound.iter().chain(n.bound.iter()) {
- let Some(g) = binding_group(k) else { continue };
- let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
- if rebound && !groups.contains(&g) {
- groups.push(g);
- }
- }
- cleared =
- raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
- if top_root {
- cleared.retain(|g| !matches!(*g, "component" | "props"));
- }
- }
- let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
- let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
- if (!groups.is_empty() || !cleared.is_empty())
- && let Some(m) = doc.node_mut(id)
- {
- for g in groups {
- m.overrides.insert(g.to_string());
- }
- for g in cleared {
- m.overrides.remove(g);
- }
- }
- if (lost || nested_switch)
- && let Some(r) = root_instance(doc, id)
- {
- resync.insert(r);
- }
- }
- // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
- // visibility, …) are carried to the copies directly; anything else rebuilds the instances.
- let mut comps: BTreeSet = BTreeSet::new();
- let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new();
- // Main layers that only gained new layers or lost some: their copies take the same change.
- let mut restructured: Vec = Vec::new();
- let mut covered: BTreeSet = BTreeSet::new();
- for &id in changed {
- if let (Some(b), Some(n)) = (before.node(id), doc.node(id))
- && b.children != n.children
- && owning_instance(doc, id).is_none()
- && owning_component(doc, id).is_some()
- && let Some(layers) = restructurable(doc, before, b, n)
- {
- restructured.push(id);
- covered.extend(layers);
- }
- }
- for &id in changed {
- if covered.contains(&id) || restructured.contains(&id) {
- continue;
- }
- let Some(c) = owning_component(doc, id) else { continue };
- let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else {
- comps.insert(c);
- continue;
- };
- if b == n {
- continue;
- }
- match patchable(doc, b, n) {
- Some(groups) => patches.push((id, groups)),
- None => {
- comps.insert(c);
- }
- }
- }
- for &id in touched {
- if !doc.contains(id)
- && !covered.contains(&id)
- && let Some(c) = owning_component(before, id)
- && doc.contains(c)
- {
- comps.insert(c);
- }
- }
- for r in resync {
- if doc.contains(r) {
- sync_instance(doc, r);
- if let Some(c) = owning_component(doc, r) {
- comps.insert(c);
- }
- }
- }
- // A copy the patch can't follow exactly rebuilds instead (as does any other change).
- let patched = comps.is_empty()
- && !full_rebuild()
- && (restructured.is_empty() || patch_structure(doc, before, &restructured))
- && (patches.is_empty() || patch_copies(doc, &patches));
- #[cfg(test)]
- if patched && (!restructured.is_empty() || !patches.is_empty()) {
- PATCHED.with(|c| c.set(c.get() + 1));
- }
- if !patched {
- comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id)));
- }
- // 3. Instances of changed components follow; an instance inside another component makes that
- // one change too.
- rebuild_instances(doc, &comps);
-}
-
-#[cfg(test)]
-thread_local! {
- /// Tests: rebuild instances even for edits that could be patched (to compare both ways).
- pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) };
- /// Tests: edits carried to the copies without rebuilding instances.
- pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) };
-}
-
-#[cfg(test)]
-fn full_rebuild() -> bool {
- FULL_REBUILD.with(std::cell::Cell::get)
-}
-
-#[cfg(not(test))]
-fn full_rebuild() -> bool {
- false
-}
-
-/// The property groups of a main-component layer edited from `b` to `n` when the edit changed
-/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without
-/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed.
-fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> {
- const VALUES: [&str; 17] = [
- "fills",
- "strokes",
- "effects",
- "opacity",
- "visible",
- "name",
- "corners",
- "characters",
- "textStyle",
- "padding",
- "gap",
- "alignment",
- "grids",
- "exports",
- "interactions",
- "size",
- "position",
- ];
- if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() {
- return None;
- }
- let groups = diff_groups(b, n);
- if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) {
- return None;
- }
- // Exactly those groups (and the layer's own overrides) changed.
- let mut probe = n.clone();
- for g in &groups {
- copy_group(b, &mut probe, g);
- }
- probe.overrides = b.overrides.clone();
- if probe != *b {
- return None;
- }
- // Values a component property sets (on this layer or a main it mirrors) come from instances.
- let mut cur = Some(n);
- for _ in 0..=MAX_DEPTH {
- let Some(x) = cur else { break };
- if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) {
- return None;
- }
- cur = x.source.and_then(|s| doc.node(s));
- }
- Some(groups)
-}
-
-/// Copies by the layer they mirror; nested instances swapped to a main also follow it.
-fn copies_index(doc: &Document) -> BTreeMap> {
- let mut copies: BTreeMap> = BTreeMap::new();
- for n in doc.nodes() {
- let Some(s) = n.source else { continue };
- copies.entry(s).or_default().push(n.id);
- if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind))
- && component != other
- {
- copies.entry(*component).or_default().push(n.id);
- }
- }
- copies
-}
-
-/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances,
-/// components or property bindings in them) or lost layers that were deleted: those layers, which
-/// [`patch_structure`] carries to the copies. `None` for anything else.
-fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> {
- let mut probe = n.clone();
- probe.children = b.children.clone();
- probe.overrides = b.overrides.clone();
- if probe != *b {
- return None;
- }
- let mut layers = Vec::new();
- for c in n.children.iter().filter(|c| !b.children.contains(c)) {
- for d in doc.subtree(*c) {
- let x = doc.node(d)?;
- let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
- if before.contains(d) || !plain || !x.prop_refs.is_empty() {
- return None;
- }
- layers.push(d);
- }
- }
- for c in b.children.iter().filter(|c| !n.children.contains(c)) {
- if doc.contains(*c) {
- return None;
- }
- layers.extend(before.subtree(*c));
- }
- Some(layers)
-}
-
-/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy,
-/// through copies of copies (slots holding an instance's own content keep it). Returns false
-/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly.
-fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool {
- let copies = copies_index(doc);
- let mut work: Vec = seeds.to_vec();
- let mut steps = 0usize;
- while let Some(p) = work.pop() {
- steps += 1;
- if steps > 10_000_000 {
- return false;
- }
- let Some(pn) = doc.node(p).cloned() else { continue };
- for c in copies.get(&p).cloned().unwrap_or_default() {
- let Some(cn) = doc.node(c).cloned() else { continue };
- if holds_content(&cn) {
- continue;
- }
- if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
- && a != b
- {
- continue;
- }
- // The copy's layers by the layer of `p` they mirror.
- let mut mirrors: BTreeMap = BTreeMap::new();
- for k in &cn.children {
- let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false };
- if mirrors.insert(src, *k).is_some() {
- return false;
- }
- }
- let mut order = Vec::with_capacity(pn.children.len());
- for pc in &pn.children {
- if let Some(k) = mirrors.remove(pc) {
- order.push(k);
- continue;
- }
- // A layer the copy lacks must be new in this edit, placed as in the main.
- let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute);
- if before.contains(*pc) || !child_follows_main(doc, c, absolute) {
- return false;
- }
- match copy_layers(doc, *pc, c) {
- Some(k) => order.push(k),
- None => return false,
- }
- }
- // Copies of layers the main lost.
- for (src, k) in mirrors {
- if doc.contains(src) || doc.remove(k).is_err() {
- return false;
- }
- }
- if doc.node(c).is_some_and(|x| x.children != order)
- && let Some(m) = doc.node_mut(c)
- {
- m.children = order;
- }
- work.push(c);
- }
- }
- true
-}
-
-/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's
-/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]).
-fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
- let Some(p) = doc.node(parent) else { return false };
- let constrains = p.layout.is_none() || absolute;
- let resized = p.overrides.contains("size")
- || match &p.kind {
- NodeKind::Instance { component, .. } => {
- doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
- }
- _ => false,
- };
- !(constrains && resized) && follows_main_geometry(doc, parent)
-}
-
-/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the
-/// new copy (the caller places it among `parent`'s layers).
-fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option {
- let sub = doc.subtree(src);
- let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect();
- for d in &sub {
- let mut n = doc.node(*d)?.clone();
- n.id = *map.get(d)?;
- n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) };
- n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
- n.source = Some(*d);
- n.overrides.clear();
- n.prop_refs.clear();
- n.annotations.clear();
- insert_linked(doc, n).ok()?;
- }
- map.get(&src).copied()
-}
-
-/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through
-/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial
-/// patch for the caller to rebuild over) when a copy can't follow exactly.
-fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool {
- let copies = copies_index(doc);
- let text = |g: &str| matches!(g, "characters" | "textStyle");
- let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec();
- let mut steps = 0usize;
- while let Some((p, groups)) = work.pop() {
- steps += 1;
- if steps > 10_000_000 {
- return false;
- }
- let Some(pn) = doc.node(p).cloned() else { continue };
- for c in copies.get(&p).cloned().unwrap_or_default() {
- let Some(cn) = doc.node(c) else { continue };
- // A swapped nested instance shows its own main, not the layer it stands in for.
- if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
- && a != b
- {
- continue;
- }
- // An instance keeps its own visibility, place, export settings and interactions; its
- // name follows the main's unless renamed; a resized one lays its layers out against
- // the main's size.
- let root = matches!(pn.kind, NodeKind::Component { .. });
- if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) {
- return false;
- }
- let gs: Vec<&'static str> = groups
- .iter()
- .copied()
- .filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
- .collect();
- if gs.is_empty() {
- continue;
- }
- let geometry = gs.iter().any(|g| matches!(*g, "size" | "position"));
- if geometry && !follows_main_geometry(doc, c) {
- return false;
- }
- // Text content and style share their runs: a copy overriding one keeps its runs when the
- // other follows the main (as a rebuild carries them).
- let runs = match &cn.kind {
- NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()),
- _ => None,
- };
- if let Some(m) = doc.node_mut(c) {
- for g in &gs {
- copy_group(&pn, m, g);
- }
- if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) {
- t.runs = r;
- }
- }
- if (geometry || gs.iter().any(|g| text(g)))
- && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size)
- && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6)
- {
- figcraft_layout::set_size(doc, c, w, h);
- }
- work.push((c, gs));
- }
- }
- true
-}
-
-/// Whether a copy's size and place are its main layer's: no layer around it inside its instances
-/// applies constraints on the way down to it (a resized instance or frame without auto layout, or
-/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest.
-fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
- let chain = scope(doc, id);
- chain.windows(2).all(|w| {
- let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
- let constrains = a.layout.is_none() || below.child_layout.absolute;
- if a.overrides.contains("size") && constrains {
- return false;
- }
- match &a.kind {
- NodeKind::Instance { component, .. } => {
- !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9)
- }
- _ => true,
- }
- })
-}
-
-/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
-/// outwards), each outermost instance once, inner components first.
-fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) {
- if comps.is_empty() {
- return;
- }
- // Outermost instances by the components their layers instantiate.
- let mut roots_of: BTreeMap> = BTreeMap::new();
- for n in doc.nodes() {
- if let NodeKind::Instance { component, .. } = &n.kind {
- roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id));
- }
- }
- // Affected components, with how deep their dependencies go (an instance inside component X of
- // component Y: X follows Y).
- let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect();
- let mut outer: BTreeMap> = BTreeMap::new();
- let mut frontier: Vec = comps.iter().copied().collect();
- let mut rounds = 0;
- while !frontier.is_empty() && rounds < 64 {
- rounds += 1;
- let mut next = Vec::new();
- for c in frontier {
- let lc = level.get(&c).copied().unwrap_or(0);
- for r in roots_of.get(&c).into_iter().flatten() {
- let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r));
- if let Some(x) = o
- && x != c
- {
- let lx = level.entry(x).or_insert(0);
- if *lx < lc + 1 {
- *lx = lc + 1;
- next.push(x);
- }
- }
- }
- }
- frontier = next;
- }
- // Instances inside components by level, then the rest (each once).
- let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect();
- order.sort();
- for (_, r) in order {
- if doc.contains(r) {
- sync_instance(doc, r);
- }
- }
-}
-
-/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
-/// main's default content until now) stop mirroring the main, and instances among them stand
-/// alone, linked to their own mains.
-fn own_slot_content(doc: &mut Document, slot: NodeId) {
- if let Some(n) = doc.node_mut(slot) {
- n.overrides.insert(SLOT.into());
- }
- for nested in unlink_layers(doc, slot, None) {
- rebase_instance(doc, nested);
- }
-}
-
-/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the
-/// content of a slot): plain layers forget the main layer they mirrored and their property
-/// bindings. The instances among them are returned, to re-link to their own mains (with
-/// [`rebase_instance`]) once the caller is done.
-pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec {
- let picked: Vec<(NodeId, bool)> =
- doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect();
- let mut nested = Vec::new();
- for (d, instance) in picked {
- let Some(n) = doc.node_mut(d) else { continue };
- n.prop_refs.clear();
- if instance {
- nested.push(d);
- } else {
- n.source = None;
- n.overrides.clear();
- }
- }
- nested
-}
-
-/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it
-/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept).
-fn relink_copy(doc: &mut Document, id: NodeId) {
- let Some(n) = doc.node(id) else { return };
- match &n.kind {
- NodeKind::Instance { component, .. } => {
- let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }));
- if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) {
- rebase_instance(doc, id);
- }
- }
- _ => {
- if (n.source.is_some() || !n.overrides.is_empty())
- && owning_instance(doc, id).is_none()
- && let Some(m) = doc.node_mut(id)
- {
- m.source = None;
- m.overrides.clear();
- }
- }
- }
-}
-
-/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside
-/// its slots.
-pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> {
- for &id in after.dirty() {
- let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue };
- // Library copies change only through library updates (and are read-only otherwise).
- if n.children == b.children || after.is_remote(id) {
- continue;
- }
- if structure_locked(before, id) && structure_locked(after, id) {
- let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default();
- return Err(format!(
- "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first"
- ));
- }
- }
- Ok(())
-}
-
-/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled
-/// back.
-pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> {
- match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) {
- Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")),
- None => Ok(()),
- }
-}
-
-/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
-/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
-/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
-/// instance itself resizes, and its layers follow; text whose box follows its content still
-/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
-/// library update).
-pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
- let (n, b) = (after.node(id)?, before.node(id)?);
- if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
- return None;
- }
- let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?);
- let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode);
- // Auto layout settings other than the overridable ones.
- let settings = |x: &Node| {
- x.layout.clone().map(|mut l| {
- (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min);
- l.content_align = ContentAlign::Auto;
- l
- })
- };
- let size = |x: &Node| (x.width, x.height);
- let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
- if mode(n) != mode(b) && mode(ma) == mode(mb) {
- return Some("The auto layout direction");
- }
- if settings(n) != settings(b) && settings(ma) == settings(mb) {
- return Some("This auto layout setting");
- }
- if !inside_instance(after, id) || !inside_instance(before, id) {
- return None;
- }
- if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
- return Some("A layer's position");
- }
- if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
- return Some("A layer's size");
- }
- if n.constraints != b.constraints && ma.constraints == mb.constraints {
- return Some("A layer's constraints");
- }
- if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
- return Some("A layer's resizing");
- }
- None
-}
-
-/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
-/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
-/// the look is kept exactly. Values a component property sets aren't overrides.
-pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
- let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
- let mine = path_map(doc, inst, true);
- let theirs: BTreeMap =
- if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() };
- let value_groups = |a: &Node, b: &Node| -> Vec {
- diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
- };
- let mut updates = Vec::new();
- for (id, path) in &mine {
- let Some(n) = doc.node(*id) else { continue };
- // Follow the source chain into the main first (exact), then fall back to the name path.
- let mut main = None;
- let mut cur = n.source;
- for _ in 0..MAX_DEPTH {
- let Some(c) = cur else { break };
- if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
- main = Some(c);
- break;
- }
- cur = doc.node(c).and_then(|x| x.source);
- }
- let main = main.or_else(|| theirs.get(path).copied());
- let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
- if let Some(m) = main.and_then(|m| doc.node(m)) {
- let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
- bound.extend(variable_groups(n, m));
- overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
- if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) {
- overrides.insert("component".into());
- }
- }
- updates.push((*id, main, overrides));
- }
- for (id, main, overrides) in updates {
- if let Some(n) = doc.node_mut(id) {
- n.source = main;
- n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
- }
- }
- let root_groups = match (doc.node(component), doc.node(inst)) {
- (Some(m), Some(n)) => {
- let from_variables = variable_groups(n, m);
- let mut groups: Vec = value_groups(m, n)
- .into_iter()
- .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
- .collect();
- // A layer name of its own (a nested instance renamed in its old main) stays.
- if n.name != default_instance_name(doc, component) {
- groups.push("name".into());
- }
- groups
- }
- _ => Vec::new(),
- };
- if let Some(n) = doc.node_mut(inst) {
- n.source = Some(component);
- n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
- n.overrides.extend(root_groups);
- }
-}
-
-/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
-/// the left-most).
-pub fn default_variant(doc: &Document, set: NodeId) -> Option {
- let corner = |c: &NodeId| {
- doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x))
- };
- let kids = &doc.node(set)?.children;
- kids.iter()
- .copied()
- .filter_map(|c| corner(&c).map(|k| (k, c)))
- .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
- .map(|(_, c)| c)
-}
-
-/// Variants of a set sharing one combination of property values (each group two or more, in
-/// layer order): a conflict to resolve by giving each a unique combination.
-pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> {
- let mut by: BTreeMap, Vec> = BTreeMap::new();
- for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
- if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
- by.entry(variant_props(&n.name)).or_default().push(c);
- }
- }
- by.into_values().filter(|v| v.len() > 1).collect()
-}
-
-/// Parse a variant name "Size=Large, State=Hover" into pairs.
-pub fn variant_props(name: &str) -> BTreeMap {
- name.split(',')
- .filter_map(|kv| {
- let (k, v) = kv.split_once('=')?;
- Some((k.trim().to_string(), v.trim().to_string()))
- })
- .collect()
-}
-
-/// Format variant pairs as a layer name, in the given property order (others after, sorted).
-pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String {
- let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect();
- keys.extend(pairs.keys().filter(|k| !order.contains(k)));
- keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ")
-}
-
-/// Variant properties of a set: property → values, in first-seen order.
-pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> {
- let mut out: Vec<(String, Vec)> = Vec::new();
- for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
- let Some(cn) = doc.node(c) else { continue };
- for kv in cn.name.split(',') {
- let Some((k, v)) = kv.split_once('=') else { continue };
- let (k, v) = (k.trim().to_string(), v.trim().to_string());
- match out.iter_mut().find(|(p, _)| *p == k) {
- Some((_, vals)) => {
- if !vals.contains(&v) {
- vals.push(v);
- }
- }
- None => out.push((k, vec![v])),
- }
- }
- }
- out
-}
-
-/// The variant in `set` matching `wanted` best: all given properties equal, or else the most.
-pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option {
- let s = doc.node(set)?;
- let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count());
- if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) {
- return Some(exact);
- }
- s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0))
-}
-
-/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
-/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
-/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
-pub fn mirror_issues(doc: &Document) -> Vec {
- doc.nodes()
- .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id))
- .flat_map(|n| instance_issues(doc, n.id))
- .collect()
-}
-
-/// [`mirror_issues`] for one instance.
-fn instance_issues(doc: &Document, id: NodeId) -> Vec {
- fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) {
- if depth > MAX_DEPTH || holds_content(inst) {
- return;
- }
- if inst.children.len() != main.children.len() {
- out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len()));
- return;
- }
- for (a, b) in inst.children.iter().zip(&main.children) {
- let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue };
- if an.source != Some(bn.id) {
- out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id));
- continue;
- }
- match (&an.kind, &bn.kind) {
- (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => {
- if let Some(m) = doc.node(*x) {
- walk(doc, an, m, depth + 1, out);
- }
- }
- _ => walk(doc, an, bn, depth + 1, out),
- }
- }
- }
- let mut out = Vec::new();
- if let Some(n) = doc.node(id)
- && let NodeKind::Instance { component, .. } = &n.kind
- && let Some(m) = doc.node(*component)
- {
- walk(doc, n, m, 0, &mut out);
- }
- out
-}
-
-#[cfg(test)]
-#[path = "components_tests.rs"]
-pub(crate) mod tests;
-
-/// The nested instance copies inside instance `id` whose main-component counterpart is exposed
-/// (top-most first; an exposed instance's own exposed nested instances follow it).
-pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
- let mut seen = 0usize;
- while let Some(c) = stack.pop() {
- seen += 1;
- if seen > 100_000 {
- break;
- }
- let Some(n) = doc.node(c) else { continue };
- // A hidden exposed instance hides its properties too.
- let exposed =
- n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
- if exposed {
- out.push(c);
- }
- // Only exposed nested instances pass their own exposed instances up.
- if !matches!(n.kind, NodeKind::Instance { .. }) || exposed {
- stack.extend(n.children.iter().rev().copied());
- }
- }
- out
-}
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 325ccdee..a95e1268 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
+#[test]
+fn copying_a_main_component_pastes_a_linked_instance() {
+ for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
+ let mut s = Session::new();
+ let k = kit(&mut s);
+ ex(&mut s, "select.set", json!({"ids": [k.card]}));
+ ex(&mut s, "edit.copy", json!({}));
+ if command != "edit.paste" {
+ ex(&mut s, "select.set", json!({"ids": [k.inst]}));
+ }
+ let before = undo_len(&s);
+ let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
+ assert_eq!(component_of(&s, pasted), k.card, "{command}");
+ assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
+ assert_eq!(node(&s, pasted).source, Some(k.card));
+ assert_eq!(undo_len(&s), before + 1);
+ ex(&mut s, "edit.undo", json!({}));
+ assert!(s.doc().unwrap().node(pasted).is_none());
+ ex(&mut s, "edit.redo", json!({}));
+ assert_eq!(component_of(&s, pasted), k.card);
+ ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
+ assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
+ healthy(&s);
+ }
+}
+
+#[test]
+fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
+ let mut s = Session::new();
+ let (set, _, hover) = button_set(&mut s);
+ ex(&mut s, "select.set", json!({"ids": [hover]}));
+ ex(&mut s, "edit.copy", json!({}));
+ let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
+ assert_eq!(component_of(&s, pasted), hover);
+ assert_eq!(node(&s, pasted).name, node(&s, set).name);
+ let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
+ assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
+ assert_eq!(props["Show dot"], true);
+ assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
+
+ ex(&mut s, "select.set", json!({"ids": [set]}));
+ ex(&mut s, "edit.copy", json!({}));
+ let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
+ assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
+ assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
+ assert_eq!(kids(&s, copied_set).len(), 2);
+ for variant in kids(&s, copied_set) {
+ assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
+ assert!(!kids(&s, set).contains(&variant));
+ }
+ healthy(&s);
+}
+
+#[test]
+fn cut_components_and_components_copied_to_another_file_remain_components() {
+ for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
+ let mut s = Session::new();
+ let k = kit(&mut s);
+ ex(&mut s, copy, json!({"ids": [k.card]}));
+ if undo_cut {
+ ex(&mut s, "edit.undo", json!({}));
+ }
+ if new_file {
+ ex(&mut s, "file.new", json!({}));
+ }
+ let page = s.page().unwrap();
+ let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
+ assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
+ assert_eq!(kids(&s, pasted).len(), 3);
+ healthy(&s);
+ }
+}
+
+#[test]
+fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
+ let mut s = Session::new();
+ let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
+ let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
+ ex(&mut s, "edit.copy", json!({"ids": [main]}));
+ s.undo().unwrap();
+ s.undo().unwrap();
+ let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
+ ex(&mut s, "component.create", json!({"ids": [other]}));
+ assert_eq!(other, main, "undo makes the original id available again");
+ let page = s.page().unwrap();
+ let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
+ assert_eq!(node(&s, pasted).name, "Copied main");
+ assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
+ assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
+ healthy(&s);
+}
+
+#[test]
+fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
+ let mut s = Session::new();
+ let k = kit(&mut s);
+ ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
+ let original = s.doc().unwrap().world_transform(k.card);
+ let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
+ ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
+ let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
+ assert_eq!(component_of(&s, pasted), k.card);
+ for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
+ assert!((actual - expected).abs() < 1e-9);
+ }
+ assert_eq!(node(&s, pasted).parent, Some(frame));
+ let before = s.doc().unwrap().clone();
+ assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
+ assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
+ healthy(&s);
+}
+
+#[test]
+fn duplicating_a_main_component_makes_a_linked_instance() {
+ for rename in [false, true] {
+ let mut s = Session::new();
+ ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
+ let k = kit(&mut s);
+ let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
+ let before = s.doc().unwrap().clone();
+ let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
+ let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
+ assert_eq!(s.selection(), ids);
+ let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
+ assert_eq!(component_of(&s, copy), k.card);
+ assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
+ assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
+ let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
+ assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
+ let other = *ids.iter().find(|i| **i != copy).unwrap();
+ assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
+ ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
+ assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
+ healthy(&s);
+ }
+}
+
+#[test]
+fn alt_dragging_a_main_component_copies_an_instance() {
+ use crate::tools::{PointerEvent, PointerKind};
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
+ let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
+ s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
+ s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
+ s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
+ s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
+ let [copy] = s.selection()[..] else { panic!("one copy selected") };
+ assert_eq!(component_of(&s, copy), main);
+ assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
+ assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
+ healthy(&s);
+}
+
+#[test]
+fn duplicating_a_variant_adds_a_variant_to_its_set() {
+ let mut s = Session::new();
+ let (set, _, hover) = button_set(&mut s);
+ let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
+ assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
+ assert_eq!(node(&s, copy).parent, Some(set));
+ assert_eq!(kids(&s, set).len(), 3);
+ // A whole set duplicates as a new set of variants.
+ let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
+ assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
+ assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
+ healthy(&s);
+}
+
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
@@ -316,14 +489,6 @@ fn what_instances_cannot_override_is_refused() {
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
- (
- "resize a layer",
- "node.set",
- json!({"id": "$", "props": {"width": 50}}),
- Layer::Bg,
- "not among the overridable properties; the instance resizes",
- ),
- ("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
(
"change constraints",
"node.set",
@@ -331,13 +496,6 @@ fn what_instances_cannot_override_is_refused() {
Layer::Bg,
"\"any constraints applied to the layers\"",
),
- (
- "make a layer fill its auto layout",
- "node.set",
- json!({"id": "$", "props": {"sizingH": "fill"}}),
- Layer::Chip,
- "resizing comes with the layer's size",
- ),
(
"take a layer out of auto layout",
"node.set",
@@ -499,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() {
healthy(&s);
}
+/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide,
+/// "A" filling it). Returns (set, row, stacked).
+fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
+ let mut variants = Vec::new();
+ for (k, (name, props, child)) in [
+ ("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})),
+ (
+ "Layout=Stacked",
+ json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}),
+ json!({"sizingH": "fill"}),
+ ),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props})));
+ ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child}));
+ variants.push(c);
+ }
+ let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
+ (set, variants[0], variants[1])
+}
+
+#[test]
+fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() {
+ let mut s = Session::new();
+ let (set, row, stacked) = layout_set(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
+ assert_eq!(component_of(&s, i), row);
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}}));
+ assert_eq!(component_of(&s, i), stacked);
+ let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400})));
+ let (n, d) = (node(&s, i), node(&s, direct));
+ assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed");
+ assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked");
+ let a = layer(&s, i, "A").unwrap();
+ assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance");
+ // Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override.
+ let col = idv(&ex(
+ &mut s,
+ "node.create",
+ json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}),
+ ));
+ ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col}));
+ ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}}));
+ let a = layer(&s, i, "A").unwrap();
+ assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills");
+ assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's");
+ assert_eq!(node(&s, a).width, node(&s, i).width);
+ // Resizing the instance set itself stays across a switch.
+ let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
+ ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}}));
+ assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays");
+ assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked");
+ healthy(&s);
+}
+
#[test]
fn variant_combinations_order_default_and_conflicts() {
let mut s = Session::new();
@@ -721,3 +937,35 @@ fn auto_layout_instances_follow_their_main() {
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
healthy(&s);
}
+
+#[test]
+fn a_new_component_set_is_named_after_its_first_variant() {
+ // "Button/State=Hover" groups under "Button": the set takes the path before the last '/'.
+ // A bare property name ("State=Default, Size=Small") names no set, and neither does an empty
+ // name: the set gets the next numbered default, like a new component. A plain name is kept.
+ let rows = [
+ ("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"),
+ ("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"),
+ ("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""),
+ ("", "Component 1", "empty name: the numbered default"),
+ ];
+ for (name, expected, why) in rows {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20})));
+ ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}}));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name");
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]})));
+ assert_eq!(node(&s, set).name, expected, "{why}");
+ healthy(&s);
+ }
+
+ // Add variant on a lone component (a variant property on it) wraps it in a set named after it.
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"})));
+ assert_eq!(node(&s, set).name, "Button");
+ assert_eq!(node(&s, c).name, "State=Default");
+ healthy(&s);
+}
diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs
index c796dad8..68465932 100644
--- a/crates/engine/src/components_scale_tests.rs
+++ b/crates/engine/src/components_scale_tests.rs
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
assert!(doc.node(glyph).unwrap().overrides.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
+
+#[test]
+fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
+ // Without unbinding first: the explicit colour detaches the variable on the instance layer only,
+ // and stays through main edits until the override is reset.
+ let mut s = Session::new();
+ let (chip, _) = bound_chip(&mut s);
+ let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
+ let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
+ ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
+ let n = s.doc().unwrap().node(bg).unwrap().clone();
+ assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
+ assert!(!n.bound.contains_key("fill"));
+ assert!(n.overrides.contains("fills"));
+ let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
+ ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
+ ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
+ let doc = s.doc().unwrap();
+ healthy(doc);
+ assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
+ assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
+ ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
+ let n = s.doc().unwrap().node(bg).unwrap();
+ assert!(n.bound.contains_key("fill"));
+ assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
+}
diff --git a/crates/engine/src/content.rs b/crates/engine/src/content.rs
new file mode 100644
index 00000000..cf32c3eb
--- /dev/null
+++ b/crates/engine/src/content.rs
@@ -0,0 +1,5 @@
+pub mod datafill;
+pub mod synthetic;
+pub mod synthetic_ds;
+pub mod templates;
+pub mod textfit;
diff --git a/crates/engine/src/content/datafill.rs b/crates/engine/src/content/datafill.rs
new file mode 100644
index 00000000..b93e5f59
--- /dev/null
+++ b/crates/engine/src/content/datafill.rs
@@ -0,0 +1,219 @@
+//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a
+//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's
+//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with
+//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy.
+
+use figcraft_doc::NodeId;
+use serde_json::{Value, json};
+
+use crate::cmd::CommandSpec;
+use crate::params::{get, id, id_of, num_or, string};
+use crate::{EngineError, Result, Session, bad, spec};
+
+const CMD: &str = "data.fill";
+const MAX_ROWS: usize = 500;
+
+pub fn specs() -> Vec {
+ vec![spec!(
+ "data.fill",
+ "Fill copies with data",
+ "{id (the frame to repeat), rows: [{: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named -; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as .), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}",
+ false,
+ fill
+ )]
+}
+
+/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`.
+fn file_name(name: &str) -> String {
+ let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect();
+ let s = s.trim().to_string();
+ if s.is_empty() { "copy".into() } else { s }
+}
+
+fn fill(s: &mut Session, p: &Value) -> Result {
+ // Refused before any copy or file is made: a rollback afterwards wouldn't take files back.
+ if let Some(why) = s.live_read_only(s.ds()?.uid) {
+ return Err(EngineError::Disabled(CMD.into(), why.into()));
+ }
+ let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?;
+ let rows = match get(p, "rows") {
+ Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(),
+ _ => return Err(bad(CMD, "rows: an array of objects (up to 500)")),
+ };
+ let gap = num_or(p, "gap", 100.0);
+ if !gap.is_finite() {
+ return Err(bad(CMD, "gap: a number"));
+ }
+ let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase();
+ if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) {
+ return Err(bad(CMD, "format: png|jpg|webp|svg|pdf"));
+ }
+ let (width, base) = {
+ let doc = s.doc()?;
+ let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?;
+ (n.width, n.name.clone())
+ };
+ let formats = formats(p)?;
+ let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max);
+ let grid = Grid { src, base, column, gap };
+ let mut missing: Vec = Vec::new();
+ let ids = s.grouped("Fill copies with data", |s| {
+ let mut ids = Vec::with_capacity(rows.len() * formats.len());
+ for (i, row) in rows.iter().enumerate() {
+ let mut dy = 0.0;
+ for fmt in &formats {
+ if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? {
+ ids.push(made);
+ }
+ }
+ }
+ Ok(ids)
+ })?;
+ let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::>(), "missing": missing});
+ // Exports after the edits landed: a failed edit writes no files. A failed export keeps the
+ // copies (one undo step removes them) and is reported with the files that were written.
+ if let Some(dir) = string(p, "dir") {
+ export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out);
+ }
+ Ok(out)
+}
+
+/// A size the copies are made in: its name, width and height.
+type Format = (String, f64, f64);
+
+/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the
+/// frame's own size).
+fn formats(p: &Value) -> Result>> {
+ match get(p, "formats") {
+ None | Some(Value::Null) => Ok(vec![None]),
+ Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(),
+ Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")),
+ }
+}
+
+/// One `formats` entry, named `x` when it has no name.
+fn format_of(f: &Value) -> Result