diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..6856b808
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,29 @@
+# DesignCraft — instructions for agents
+
+DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
+
+## Start every session here
+1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
+2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
+
+`plan/` is gitignored (local only).
+
+## Non-negotiables
+- **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…).
+- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code.
+- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
+- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
+- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
+- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
+
+## Running and looking at the app
+- `cargo run --release -p designcraft -- --sample --control 7979` (sample magazine + control channel).
+- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
+- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/indesign/02-ui-ux.md` / `11-observed-ui.md`. If no frame is presented (screen locked) use `ui.render` or `designcraft-cli run --sample --all-pages DIR`.
+- Headless: `designcraft-cli run --sample --cmd 'frame.create={"rect":[0,0,100,100]}' --export out.png`.
+- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
+- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
+
+## Roadmap
+`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
diff --git a/ASSETS.md b/ASSETS.md
new file mode 100644
index 00000000..b9152602
--- /dev/null
+++ b/ASSETS.md
@@ -0,0 +1,32 @@
+# Asset attribution
+
+Every non-code asset in this repository (fonts, images, example art, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
+
+**Policy (mandatory):** DesignCraft contains **no Adobe iconography, images, artwork, presets, swatch libraries or ICC profiles**. Every asset is original work by DesignCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Adobe software are never committed.
+
+Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), default swatches, and the sample magazine's imagery (`crates/engine/src/sample.rs`).
+
+| Asset | Author | Source | Licence | Notes |
+|---|---|---|---|---|
+| `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
+| `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
+| `assets/fonts/Inter-SemiBold.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
+| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | Open-source typeface |
+| `assets/fonts/OFL-Inter.txt` | (licence text) | upstream project | — | |
+| `assets/fonts/OFL-JetBrainsMono.txt` | (licence text) | upstream project | — | |
+| `assets/fonts/OFL-SourceSans3.txt` | (licence text) | upstream project | — | |
+| `assets/fonts/OFL-SourceSerif4.txt` | (licence text) | upstream project | — | |
+| `assets/fonts/SourceSans3-Bold.ttf` | Paul D. Hunt / Adobe (released as open source) | https://github.com/adobe-fonts/source-sans | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`) | Open-source typeface |
+| `assets/fonts/SourceSans3-It.ttf` | Paul D. Hunt / Adobe (released as open source) | https://github.com/adobe-fonts/source-sans | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`) | Open-source typeface |
+| `assets/fonts/SourceSans3-Regular.ttf` | Paul D. Hunt / Adobe (released as open source) | https://github.com/adobe-fonts/source-sans | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`) | Open-source typeface |
+| `assets/fonts/SourceSans3-Semibold.ttf` | Paul D. Hunt / Adobe (released as open source) | https://github.com/adobe-fonts/source-sans | OFL-1.1 (`assets/fonts/OFL-SourceSans3.txt`) | Open-source typeface |
+| `assets/fonts/SourceSerif4-Bold.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface |
+| `assets/fonts/SourceSerif4-BoldIt.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface |
+| `assets/fonts/SourceSerif4-It.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface |
+| `assets/fonts/SourceSerif4-Regular.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface |
+| `assets/fonts/SourceSerif4-Semibold.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface |
+| `docs/images/page-2.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
+| `docs/images/page-3.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
+| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
+| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
+| `docs/images/ui-typing.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..6856b808
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,29 @@
+# DesignCraft — instructions for agents
+
+DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
+
+## Start every session here
+1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
+2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
+
+`plan/` is gitignored (local only).
+
+## Non-negotiables
+- **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…).
+- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code.
+- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
+- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
+- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
+- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
+
+## Running and looking at the app
+- `cargo run --release -p designcraft -- --sample --control 7979` (sample magazine + control channel).
+- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
+- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/indesign/02-ui-ux.md` / `11-observed-ui.md`. If no frame is presented (screen locked) use `ui.render` or `designcraft-cli run --sample --all-pages DIR`.
+- Headless: `designcraft-cli run --sample --cmd 'frame.create={"rect":[0,0,100,100]}' --export out.png`.
+- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
+- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
+
+## Roadmap
+`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
diff --git a/Cargo.lock b/Cargo.lock
index 6c42baa6..d373ce94 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,18 +2,393 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.36.0",
+ "atspi-common",
+ "phf",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.38.0",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.35.0",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+dependencies = [
+ "accesskit",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.13.2",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.5",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.5",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -26,7 +401,16 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
- "bit-vec",
+ "bit-vec 0.8.0",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
+dependencies = [
+ "bit-vec 0.9.1",
]
[[package]]
@@ -35,12 +419,61 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -67,12 +500,116 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.13.2",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+dependencies = [
+ "bitflags 2.13.2",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop 0.13.0",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop 0.14.4",
+ "rustix 1.1.5",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "cgl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
[[package]]
name = "color"
version = "0.3.3"
@@ -88,6 +625,65 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -137,6 +733,35 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "designcraft"
+version = "0.1.0"
+dependencies = [
+ "designcraft-engine",
+ "designcraft-ui-egui",
+ "eframe",
+ "egui",
+ "log",
+ "rfd",
+ "serde_json",
+]
+
+[[package]]
+name = "designcraft-cli"
+version = "0.1.0"
+dependencies = [
+ "designcraft-doc",
+ "designcraft-engine",
+ "designcraft-render",
+ "serde_json",
+]
+
[[package]]
name = "designcraft-color"
version = "0.1.0"
@@ -146,7 +771,7 @@ dependencies = [
"proptest",
"serde",
"serde_json",
- "thiserror",
+ "thiserror 2.0.21",
]
[[package]]
@@ -170,14 +795,31 @@ dependencies = [
"proptest",
"serde",
"serde_json",
- "thiserror",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "designcraft-engine"
+version = "0.1.0"
+dependencies = [
+ "designcraft-color",
+ "designcraft-compose",
+ "designcraft-doc",
+ "designcraft-fonts",
+ "designcraft-geom",
+ "designcraft-render",
+ "designcraft-tools",
+ "log",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
]
[[package]]
name = "designcraft-fonts"
version = "0.1.0"
dependencies = [
- "harfrust",
+ "harfrust 0.13.3",
"kurbo",
"log",
"skrifa 0.47.0",
@@ -191,7 +833,7 @@ dependencies = [
"proptest",
"serde",
"serde_json",
- "thiserror",
+ "thiserror 2.0.21",
]
[[package]]
@@ -205,8 +847,206 @@ dependencies = [
"designcraft-geom",
"image",
"log",
- "vello_common",
- "vello_cpu",
+ "vello_common 0.2.0",
+ "vello_cpu 0.2.0",
+]
+
+[[package]]
+name = "designcraft-tools"
+version = "0.1.0"
+dependencies = [
+ "designcraft-color",
+ "designcraft-compose",
+ "designcraft-doc",
+ "designcraft-geom",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "designcraft-ui-egui"
+version = "0.1.0"
+dependencies = [
+ "designcraft-color",
+ "designcraft-compose",
+ "designcraft-doc",
+ "designcraft-engine",
+ "designcraft-fonts",
+ "designcraft-geom",
+ "designcraft-render",
+ "designcraft-tools",
+ "egui",
+ "log",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "libc",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "ecolor"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "eframe"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "egui-wgpu",
+ "egui-winit",
+ "egui_glow",
+ "glutin",
+ "glutin-winit",
+ "image",
+ "js-sys",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "pollster 1.0.1",
+ "profiling",
+ "raw-window-handle",
+ "static_assertions",
+ "wasm-bindgen",
+ "web-sys",
+ "web-time",
+ "wgpu",
+ "windows-sys 0.61.2",
+ "winit",
+]
+
+[[package]]
+name = "egui"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+dependencies = [
+ "accesskit",
+ "ahash",
+ "bitflags 2.13.2",
+ "emath",
+ "epaint",
+ "itertools",
+ "log",
+ "nohash-hasher",
+ "profiling",
+ "smallvec",
+ "unicode-segmentation",
+ "web-sys",
+]
+
+[[package]]
+name = "egui-wgpu"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "epaint",
+ "log",
+ "profiling",
+ "thiserror 2.0.21",
+ "type-map",
+ "web-time",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "egui-winit"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+dependencies = [
+ "accesskit_winit",
+ "arboard",
+ "bytemuck",
+ "egui",
+ "log",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+ "objc2-ui-kit 0.3.2",
+ "profiling",
+ "raw-window-handle",
+ "smithay-clipboard",
+ "web-time",
+ "winit",
+]
+
+[[package]]
+name = "egui_glow"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+dependencies = [
+ "bytemuck",
+ "egui",
+ "glow",
+ "log",
+ "profiling",
+ "winit",
]
[[package]]
@@ -215,6 +1055,79 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+[[package]]
+name = "emath"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "epaint"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "font-types",
+ "harfrust 0.12.0",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+ "profiling",
+ "self_cell",
+ "skrifa 0.44.0",
+ "smallvec",
+ "unicode-general-category",
+ "unicode-segmentation",
+ "vello_cpu 0.1.0",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "errno"
version = "0.3.14"
@@ -222,9 +1135,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
+[[package]]
+name = "error-code"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
+
[[package]]
name = "euclid"
version = "0.22.14"
@@ -234,6 +1153,26 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -261,6 +1200,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
[[package]]
name = "flate2"
version = "1.1.10"
@@ -293,6 +1238,98 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.5",
+ "windows-link",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -326,6 +1363,33 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glifo"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+dependencies = [
+ "bytemuck",
+ "foldhash",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "skrifa 0.44.0",
+ "smallvec",
+ "vello_common 0.1.0",
+]
+
[[package]]
name = "glifo"
version = "0.3.0"
@@ -334,13 +1398,105 @@ checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
- "hashbrown",
+ "hashbrown 0.17.1",
"log",
"peniko",
"png",
"skrifa 0.44.0",
"smallvec",
- "vello_common",
+ "vello_common 0.2.0",
+]
+
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg_aliases",
+ "cgl",
+ "dispatch2",
+ "glutin_egl_sys",
+ "glutin_glx_sys",
+ "glutin_wgl_sys",
+ "libloading",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "raw-window-handle",
+ "wayland-sys",
+ "windows-sys 0.52.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin-winit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
+dependencies = [
+ "cfg_aliases",
+ "glutin",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "glutin_egl_sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
+dependencies = [
+ "gl_generator",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "glutin_glx_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
+dependencies = [
+ "gl_generator",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.21",
+ "windows",
]
[[package]]
@@ -360,22 +1516,46 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
+ "num-traits",
"zerocopy",
]
+[[package]]
+name = "harfrust"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "read-fonts 0.41.0",
+ "smallvec",
+]
+
[[package]]
name = "harfrust"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"bytemuck",
"once_cell",
"read-fonts 0.43.3",
"smallvec",
]
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -385,6 +1565,18 @@ dependencies = [
"foldhash",
]
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
[[package]]
name = "image"
version = "0.25.10"
@@ -414,12 +1606,127 @@ dependencies = [
"quick-error 2.0.1",
]
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itertools"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.21",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -439,18 +1746,67 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "bitflags 2.13.2",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.4",
+]
+
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.34"
@@ -463,6 +1819,24 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -493,6 +1867,80 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "naga"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
+dependencies = [
+ "arrayvec",
+ "bit-set 0.10.0",
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting",
+ "half",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "libm",
+ "log",
+ "naga-types",
+ "num-traits",
+ "once_cell",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "thiserror 2.0.21",
+ "unicode-ident",
+]
+
+[[package]]
+name = "naga-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
+dependencies = [
+ "hashbrown 0.17.1",
+ "indexmap",
+ "rustc-hash 1.1.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.2",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -500,6 +1948,340 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core 0.2.2",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core 0.2.2",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
]
[[package]]
@@ -508,6 +2290,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "orbclient"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
[[package]]
name = "ordered-channel"
version = "1.2.0"
@@ -517,6 +2309,54 @@ dependencies = [
"crossbeam-channel",
]
+[[package]]
+name = "ordered-float"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "peniko"
version = "0.6.1"
@@ -530,19 +2370,143 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "pollster"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
+
[[package]]
name = "polycool"
version = "0.4.0"
@@ -552,6 +2516,21 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -561,6 +2540,21 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -570,15 +2564,21 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
- "bit-set",
- "bit-vec",
- "bitflags",
+ "bit-set 0.8.0",
+ "bit-vec 0.8.0",
+ "bitflags 2.13.2",
"num-traits",
"rand",
"rand_chacha",
@@ -607,6 +2607,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -666,6 +2675,30 @@ dependencies = [
"rand_core",
]
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -719,25 +2752,125 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "rfd"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
+dependencies = [
+ "block2 0.6.2",
+ "dispatch2",
+ "js-sys",
+ "libc",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "percent-encoding",
+ "pollster 0.4.0",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"errno",
"libc",
- "linux-raw-sys",
- "windows-sys",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
]
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
[[package]]
name = "rusty-fork"
version = "0.3.1"
@@ -750,6 +2883,39 @@ dependencies = [
"wait-timeout",
]
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.229"
@@ -793,12 +2959,61 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
[[package]]
name = "skrifa"
version = "0.44.0"
@@ -819,6 +3034,21 @@ dependencies = [
"read-fonts 0.44.0",
]
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -828,6 +3058,93 @@ dependencies = [
"serde",
]
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.13.2",
+ "calloop 0.13.0",
+ "calloop-wayland-source 0.3.0",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.13.2",
+ "calloop 0.14.4",
+ "calloop-wayland-source 0.4.1",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit 0.20.0",
+ "wayland-backend",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -859,8 +3176,26 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
- "rustix",
- "windows-sys",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
]
[[package]]
@@ -869,7 +3204,18 @@ version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
@@ -906,18 +3252,153 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.15+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "type-map"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
+dependencies = [
+ "rustc-hash 2.1.3",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "uuid"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vello_common"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+dependencies = [
+ "bytemuck",
+ "fearless_simd",
+ "guillotiere",
+ "log",
+ "peniko",
+ "smallvec",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "vello_common"
version = "0.2.0"
@@ -931,7 +3412,19 @@ dependencies = [
"peniko",
"png",
"smallvec",
- "thiserror",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "vello_cpu"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+dependencies = [
+ "bytemuck",
+ "glifo 0.2.0",
+ "hashbrown 0.17.1",
+ "vello_common 0.1.0",
]
[[package]]
@@ -942,15 +3435,21 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"crossbeam-channel",
- "glifo",
- "hashbrown",
+ "glifo 0.3.0",
+ "hashbrown 0.17.1",
"ordered-channel",
"png",
"rayon",
"thread_local",
- "vello_common",
+ "vello_common 0.2.0",
]
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -960,6 +3459,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -969,18 +3478,543 @@ dependencies = [
"wit-bindgen",
]
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.5",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags 2.13.2",
+ "rustix 1.1.5",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.13.2",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.5",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+[[package]]
+name = "wgpu"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.13.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
+dependencies = [
+ "arrayvec",
+ "bit-set 0.10.0",
+ "bit-vec 0.9.1",
+ "bitflags 2.13.2",
+ "bytemuck",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "log",
+ "naga",
+ "naga-types",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.21",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-wasm",
+ "wgpu-core-deps-windows-linux-android",
+ "wgpu-hal",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-wasm"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set 0.10.0",
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-allocator",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "naga",
+ "naga-types",
+ "ndk-sys",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "raw-window-metal",
+ "renderdoc-sys",
+ "smallvec",
+ "static_assertions",
+ "thiserror 2.0.21",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+ "windows",
+ "windows-core",
+ "windows-result",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
+dependencies = [
+ "naga",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "naga-types",
+ "raw-window-handle",
+ "static_assertions",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -990,12 +4024,273 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "bytemuck",
+ "calloop 0.13.0",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "smithay-client-toolkit 0.19.2",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.5",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+
+[[package]]
+name = "xkbcommon-dl"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
+dependencies = [
+ "bitflags 2.13.2",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
+
[[package]]
name = "xtask"
version = "0.1.0"
@@ -1003,6 +4298,112 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.5",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
+dependencies = [
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep-macros"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_xml"
+version = "5.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
+dependencies = [
+ "serde",
+ "winnow",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.59"
@@ -1049,3 +4450,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.6",
+ "winnow",
+]
diff --git a/Cargo.toml b/Cargo.toml
index b3a041bf..50220d79 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[workspace]
resolver = "3"
-members = ["crates/*", "xtask"]
-default-members = ["crates/*", "xtask"]
+members = ["crates/*", "apps/*", "xtask"]
+default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
@@ -61,6 +61,8 @@ unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
+too_many_arguments = "allow"
+type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..1bf862a4
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,13 @@
+DesignCraft
+Copyright the DesignCraft contributors. Licensed under MIT OR Apache-2.0.
+
+This product bundles third-party material under the following licences. Per-file attribution
+(author, source, licence) for every asset is in ASSETS.md. No Adobe icons, images or artwork are included.
+
+- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt.
+- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
+- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
+- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
+
+Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively
+licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
diff --git a/README.md b/README.md
index 4e5d8a3a..79cb6a35 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,98 @@
-DesignCraft
-============
+
DesignCraft
-By ArtCraft
+
+ Page layout and publishing, rebuilt in pure Rust.
+ A fast, open-source, clean-room take on the Adobe InDesign workflow — native on macOS, Windows and Linux, and in the browser via WebAssembly.
+ By the ArtCraft team.
+
+
+
+
+ Quarterly — Spring Issue : threaded three-column body text, a wrapped pull quote, parent-page folios, all set by DesignCraft's own paragraph composer.
+
+
+
+
+Cover: full-bleed graphic frame, display type
+Styles: kicker rule, headline, deck, justified body
+Threading, columns and text wrap
+
+
+
+Every page above was laid out by DesignCraft from code (`crates/engine/src/sample.rs`) and exported by its own renderer — try it with `File → New → Sample Document` or `--sample`.
+
+### Why DesignCraft
+
+- **Familiar.** InDesign's layout, tools, menus, panels and shortcuts: spreads and parent pages, frames and threaded stories, the Control panel, paragraph and character styles, swatches, text wrap… you already know how to use it.
+- **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), hyphenation, justification, columns, baseline grid, keeps, tabs, rules and shading — and identical line breaks on screen and in export.
+- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, cached composition.
+- **Open.** A documented native format, and IDML / PDF / PNG interchange on the roadmap — no subscription, no licence server, no telemetry.
+- **Agent-native.** Every menu item, tool gesture, panel control and dialog is drivable over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit documents like a designer.
+- **Everywhere.** One Rust codebase for desktop and the web.
+
+## Quick start
+
+```sh
+cargo run --release -p designcraft # desktop app (start screen)
+cargo run --release -p designcraft -- --sample # open the sample magazine
+cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel
+cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG
+cargo run --release -p designcraft-cli -- commands # list every command
+cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
+```
+
+Drive a running app: send JSON lines to `127.0.0.1:7979` — see [`docs/control-protocol.md`](docs/control-protocol.md).
+
+## Architecture
+
+An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); the egui frontend is a separate crate, so the UI can be swapped without touching the engine.
+
+| Layer | Crates |
+|---|---|
+| L0 | `geom` (paths, units & measurement parsing, corner options) · `color` (CMYK/RGB/Lab, swatches, tints, gradients) |
+| L1 | `doc` (spreads, pages, parents, layers, frames, stories, styles) · `fonts` (font DB, shaping, outlines) |
+| L2 | `compose` (the text engine) |
+| L3 | `render` (vello_cpu) |
+| L4 | `tools` (pointer events → commands + overlays) |
+| L5 | `engine` (session, history, command registry) |
+| L6 | `ui-egui` (InDesign-style UI, control channel) |
+| L7 | `apps/designcraft`, `apps/designcraft-cli` |
+
+Status and milestones: **[ROADMAP.md](ROADMAP.md)**. Contributor and agent rules (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
+
+## Crafting Apps
+
+Open-source, pure-Rust, clean-room creative tools — each engine-first, cross-platform, WASM-ready and fully agent-drivable.
+
+
+
+ PhotoCraft
+ Layered raster image editor in the spirit of Photoshop — high-bit-depth pipeline, adjustment layers, brushes, PSD round-trip.
+
+
+ DrawCraft
+ Vector illustration in the spirit of Illustrator — Pen, Pathfinder, live effects, type, SVG/PDF.
+
+
+ DesignCraft
+ Page layout in the spirit of InDesign — spreads, threaded stories, styles, a Knuth–Plass composer. (you are here)
+
+
+ FilmCraft
+ Non-linear video editor in the spirit of Premiere Pro — timeline editing, effects, and export.
+
+
+ LightCraft
+ Photo library and non-destructive raw developer in the spirit of Lightroom — local-first catalog, wide-gamut float pipeline.
+
+
+ PrintCraft
+ PDF viewer and editor in the spirit of Acrobat — rendering, forms, annotations, and document tools.
+
+
+
+## License
+
+MIT OR Apache-2.0. Bundled fonts are OFL; all icons are drawn in code and are original. Per-asset attribution: [`ASSETS.md`](ASSETS.md) (see also [`NOTICE`](NOTICE)).
+
+DesignCraft is an independent project and is not affiliated with or endorsed by Adobe. "Adobe", "InDesign", "Illustrator", "Photoshop", "Premiere Pro", "Lightroom" and "Acrobat" are trademarks of Adobe Inc., used here only to describe compatibility and workflow familiarity.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 00000000..14206957
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,35 @@
+# DesignCraft roadmap
+
+DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
+
+## Status (2026-10-01)
+
+**Working today**
+- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
+- Text engine: Knuth–Plass paragraph composer and single-line composer, hyphenation, justification, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
+- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
+- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes.
+- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
+- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
+
+**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
+
+## Milestones
+
+| # | Milestone | State |
+|---|---|---|
+| M0 | Skeleton + vertical slice | ✅ |
+| M1 | Selection, transform, layers, pages, MCP | in progress |
+| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | in progress |
+| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started |
+| M4 | Color & effects | started |
+| M5 | Graphics & links | started |
+| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | next |
+| M7 | Long documents (sections, TOC, index, footnotes, books) | |
+| M8 | Tables | |
+| M9 | Performance (MT composition, tiles) | |
+| M10 | Find/Change, spelling, Preflight | |
+| M11 | Layout power features (liquid/alternate layouts, data merge) | |
+| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | |
+| M13 | Automation (scripts, batch) | |
+| M14 | 1.0 polish & packaging | |
diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml
new file mode 100644
index 00000000..51b78bb2
--- /dev/null
+++ b/apps/designcraft-cli/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "designcraft-cli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "designcraft-cli"
+path = "src/main.rs"
+
+[dependencies]
+designcraft-engine = { workspace = true }
+designcraft-render = { workspace = true }
+designcraft-doc = { workspace = true }
+serde_json = { workspace = true }
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
new file mode 100644
index 00000000..03977eed
--- /dev/null
+++ b/apps/designcraft-cli/src/main.rs
@@ -0,0 +1,113 @@
+//! Headless DesignCraft.
+//!
+//! ```text
+//! designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.designcraft] [--all-pages DIR]
+//! designcraft-cli commands # list every command (JSON)
+//! designcraft-cli mcp [--connect PORT] # MCP server over stdio
+//! ```
+use std::process::ExitCode;
+
+use designcraft_engine::Session;
+use serde_json::{Value, json};
+
+fn main() -> ExitCode {
+ let args: Vec = std::env::args().skip(1).collect();
+ match args.first().map(String::as_str) {
+ Some("run") => report(run(&args[1..])),
+ Some("commands") => {
+ let s = Session::new();
+ println!("{}", serde_json::to_string_pretty(&s.commands()).unwrap_or_default());
+ ExitCode::SUCCESS
+ }
+ Some("mcp") => report(mcp(&args[1..])),
+ _ => {
+ eprintln!(
+ "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT]"
+ );
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn report(r: Result<(), String>) -> ExitCode {
+ match r {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("designcraft-cli: {e}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn mcp(_args: &[String]) -> Result<(), String> {
+ Err("the MCP server lands with the designcraft-mcp crate".into())
+}
+
+fn run(args: &[String]) -> Result<(), String> {
+ let mut s = Session::new();
+ let mut page = 0usize;
+ let mut scale = 1.0f64;
+ let mut it = args.iter();
+ let mut opened = false;
+ while let Some(a) = it.next() {
+ let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
+ match a.as_str() {
+ "--in" => {
+ let p = val()?;
+ s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
+ opened = true;
+ }
+ "--sample" => {
+ s.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
+ opened = true;
+ }
+ "--cmd" => {
+ if !opened {
+ s.execute("file.new", &json!({})).map_err(|e| e.to_string())?;
+ opened = true;
+ }
+ let c = val()?;
+ let (id, p) = c.split_once('=').unwrap_or((&c, "{}"));
+ let p: Value = serde_json::from_str(p).map_err(|e| format!("--cmd {id}: {e}"))?;
+ let r = s.execute(id, &p).map_err(|e| e.to_string())?;
+ if !r.is_null() {
+ println!("{}", serde_json::to_string(&r).unwrap_or_default());
+ }
+ }
+ "--page" => page = val()?.parse().map_err(|_| "bad --page")?,
+ "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
+ "--export" => {
+ let out = val()?;
+ export(&mut s, &out, page, scale)?;
+ }
+ "--all-pages" => {
+ let dir = val()?;
+ let n = s.doc().map_err(|e| e.to_string())?.doc.page_count();
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+ for p in 0..n {
+ export(&mut s, &format!("{dir}/page-{:03}.png", p + 1), p, scale)?;
+ }
+ }
+ other => return Err(format!("unknown option {other}")),
+ }
+ }
+ Ok(())
+}
+
+fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
+ if out.ends_with(".designcraft") {
+ s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
+ eprintln!("saved {out}");
+ return Ok(());
+ }
+ let st = s.doc().map_err(|e| e.to_string())?;
+ let mut r = designcraft_render::Renderer::new();
+ let t = std::time::Instant::now();
+ let img = r
+ .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
+ .ok_or("no such page")?;
+ let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
+ std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
+ eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
+ Ok(())
+}
diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml
new file mode 100644
index 00000000..12f455e7
--- /dev/null
+++ b/apps/designcraft/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "designcraft"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+default-run = "designcraft"
+
+[lints]
+workspace = true
+
+[dependencies]
+designcraft-engine = { workspace = true }
+designcraft-ui-egui = { workspace = true }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
+egui = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+rfd = { workspace = true }
diff --git a/apps/designcraft/src/control_server.rs b/apps/designcraft/src/control_server.rs
new file mode 100644
index 00000000..aa4688d5
--- /dev/null
+++ b/apps/designcraft/src/control_server.rs
@@ -0,0 +1,62 @@
+//! Loopback JSON-lines control server: one request per line, one reply per line.
+//! This is the transport the MCP server (`designcraft mcp --connect`) wraps.
+
+use std::io::{BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
+use std::sync::mpsc::{Receiver, Sender, channel};
+use std::time::Duration;
+
+use designcraft_ui_egui::ControlRequest;
+use serde_json::{Value, json};
+
+pub fn start(port: u16, ctx: egui::Context) -> Receiver {
+ let (tx, rx) = channel::();
+ let listener = match TcpListener::bind(("127.0.0.1", port)) {
+ Ok(l) => l,
+ Err(e) => {
+ eprintln!("designcraft: control server failed to bind 127.0.0.1:{port}: {e}");
+ return rx;
+ }
+ };
+ eprintln!("designcraft: control server listening on 127.0.0.1:{port}");
+ std::thread::spawn(move || {
+ for stream in listener.incoming().flatten() {
+ let tx = tx.clone();
+ let ctx = ctx.clone();
+ std::thread::spawn(move || serve(stream, tx, ctx));
+ }
+ });
+ rx
+}
+
+fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
+ let Ok(read) = stream.try_clone() else { return };
+ let mut out = stream;
+ for line in BufReader::new(read).lines() {
+ let Ok(line) = line else { break };
+ if line.trim().is_empty() {
+ continue;
+ }
+ let reply = match serde_json::from_str::(&line) {
+ Ok(msg) => {
+ let id = msg.get("id").cloned().unwrap_or(Value::Null);
+ let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
+ let params = msg.get("params").cloned().unwrap_or(json!({}));
+ let (req, rrx) = ControlRequest::new(method, params);
+ if tx.send(req).is_err() {
+ break;
+ }
+ ctx.request_repaint();
+ let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
+ if let Some(o) = r.as_object_mut() {
+ o.insert("id".into(), id);
+ }
+ r
+ }
+ Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
+ };
+ if writeln!(out, "{reply}").is_err() {
+ break;
+ }
+ }
+}
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
new file mode 100644
index 00000000..e1c2508f
--- /dev/null
+++ b/apps/designcraft/src/main.rs
@@ -0,0 +1,135 @@
+//! DesignCraft desktop app.
+//!
+//! Usage: `designcraft [--control ] [--sample] [files…]`
+//!
+//! `--control ` (or `DESIGNCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
+//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
+//! See `designcraft_ui_egui::control` for the methods.
+
+mod control_server;
+
+use designcraft_engine::Session;
+use designcraft_ui_egui::{DesignApp, Services};
+
+struct App(DesignApp);
+
+impl eframe::App for App {
+ fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ self.0.logic(ctx);
+ }
+ fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
+ self.0.raw_input_hook(raw);
+ }
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ self.0.ui(ui);
+ }
+ fn on_exit(&mut self) {
+ save_prefs(&self.0);
+ }
+}
+
+fn prefs_path() -> Option {
+ let base = if cfg!(target_os = "macos") {
+ std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft"))
+ } else if cfg!(windows) {
+ std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft"))
+ } else {
+ std::env::var_os("XDG_CONFIG_HOME")
+ .map(std::path::PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
+ .map(|c| c.join("designcraft"))
+ };
+ base.map(|b| b.join("ui.json"))
+}
+
+fn load_prefs(app: &mut DesignApp) {
+ if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
+ return;
+ }
+ if let Some(p) = prefs_path()
+ && let Ok(bytes) = std::fs::read(&p)
+ && let Ok(ui) = serde_json::from_slice::(&bytes)
+ {
+ app.ui = ui;
+ }
+}
+
+fn save_prefs(app: &DesignApp) {
+ if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
+ return;
+ }
+ if let Some(p) = prefs_path() {
+ let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
+ if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
+ let _ = std::fs::write(p, bytes);
+ }
+ }
+}
+
+fn services() -> Services {
+ Services {
+ pick_open: Some(Box::new(|purpose: &str| {
+ let d = rfd::FileDialog::new();
+ let d = if purpose == "place" {
+ d.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
+ } else {
+ d.add_filter("DesignCraft", &["designcraft"])
+ };
+ d.pick_file().map(|p| p.to_string_lossy().to_string())
+ })),
+ pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
+ read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
+ write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
+ }
+}
+
+fn main() -> eframe::Result {
+ let mut control_port: Option = std::env::var("DESIGNCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
+ let mut files = Vec::new();
+ let mut sample = false;
+ let mut args = std::env::args().skip(1);
+ while let Some(a) = args.next() {
+ match a.as_str() {
+ "--control" => control_port = args.next().and_then(|p| p.parse().ok()),
+ "--sample" => sample = true,
+ "--version" => {
+ println!("designcraft {}", env!("CARGO_PKG_VERSION"));
+ return Ok(());
+ }
+ _ => files.push(a),
+ }
+ }
+ let options = eframe::NativeOptions {
+ viewport: egui::ViewportBuilder::default()
+ .with_title("DesignCraft")
+ .with_inner_size([1440.0, 900.0])
+ .with_min_inner_size([900.0, 560.0])
+ .with_drag_and_drop(true)
+ .with_fullsize_content_view(true)
+ .with_titlebar_shown(false)
+ .with_title_shown(false),
+ ..Default::default()
+ };
+ eframe::run_native(
+ "DesignCraft",
+ options,
+ Box::new(move |cc| {
+ let mut app = DesignApp::new(Session::new(), services());
+ load_prefs(&mut app);
+ app.integrated_titlebar = cfg!(target_os = "macos");
+ if let Some(port) = control_port {
+ let rx = control_server::start(port, cc.egui_ctx.clone());
+ app = app.with_control(rx);
+ }
+ if sample {
+ let _ = app.run("file.newSample", serde_json::json!({}));
+ }
+ for f in files {
+ if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
+ eprintln!("designcraft: {f}: {e}");
+ }
+ }
+ Ok(Box::new(App(app)))
+ }),
+ )
+}
diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs
index 7ac198cd..e0b32b1f 100644
--- a/crates/color/src/swatch.rs
+++ b/crates/color/src/swatch.rs
@@ -25,12 +25,22 @@ pub enum ColorType {
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SwatchValue {
None,
- Paper { color: Color },
+ Paper {
+ color: Color,
+ },
Registration,
- Color { color: Color, color_type: ColorType },
+ Color {
+ color: Color,
+ color_type: ColorType,
+ },
/// A tint (0..=1) of another colour swatch.
- Tint { base: String, tint: f32 },
- Gradient { gradient: Gradient },
+ Tint {
+ base: String,
+ tint: f32,
+ },
+ Gradient {
+ gradient: Gradient,
+ },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -147,7 +157,12 @@ mod tests {
#[test]
fn resolves_tints_and_specials() {
let mut s = default_swatches();
- s.push(Swatch { name: "Half cyan".into(), value: SwatchValue::Tint { base: "C=100 M=0 Y=0 K=0".into(), tint: 0.5 }, locked: false, named: true });
+ s.push(Swatch {
+ name: "Half cyan".into(),
+ value: SwatchValue::Tint { base: "C=100 M=0 Y=0 K=0".into(), tint: 0.5 },
+ locked: false,
+ named: true,
+ });
assert_eq!(resolve(&s, NONE, 1.0), None);
assert_eq!(resolve(&s, PAPER, 1.0), Some(Color::WHITE));
assert_eq!(resolve(&s, BLACK, 0.4), Some(Color::cmyk(0.0, 0.0, 0.0, 0.4)));
diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs
index 87074862..35392a2a 100644
--- a/crates/compose/src/breaker.rs
+++ b/crates/compose/src/breaker.rs
@@ -33,10 +33,20 @@ pub struct Spacing {
#[derive(Clone, Copy, Debug)]
enum Item {
/// Unbreakable width (a glyph).
- Box { w: f64 },
+ Box {
+ w: f64,
+ },
/// Space glyph `g`.
- Glue { w: f64, st: f64, sh: f64 },
- Penalty { w: f64, p: f64, flagged: bool },
+ Glue {
+ w: f64,
+ st: f64,
+ sh: f64,
+ },
+ Penalty {
+ w: f64,
+ p: f64,
+ flagged: bool,
+ },
}
const INF: f64 = 10000.0;
@@ -54,10 +64,8 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec- , Vec
let n = glyphs.len();
for (i, g) in glyphs.iter().enumerate() {
if is_forced(g.ch) {
- if !sp.justify || true {
- it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 });
- ig.push(i);
- }
+ it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 });
+ ig.push(i);
it.push(Item::Penalty { w: 0.0, p: -INF, flagged: false });
ig.push(i + 1);
continue;
@@ -139,7 +147,15 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
}
#[allow(clippy::too_many_arguments)]
-fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width: &dyn Fn(usize) -> f64, tol: f64, emergency: bool) -> Option
> {
+fn kp_pass(
+ items: &[Item],
+ ig: &[usize],
+ glyphs: &[Glyph],
+ sp: &Spacing,
+ width: &dyn Fn(usize) -> f64,
+ tol: f64,
+ emergency: bool,
+) -> Option> {
let m = items.len();
// Prefix sums (before item i).
let mut sw = vec![0.0; m + 1];
@@ -155,7 +171,8 @@ fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width:
sy[i + 1] = sy[i] + y;
sz[i + 1] = sz[i] + z;
}
- let mut arena: Vec = vec![Node { pos: 0, line: 0, fitness: 1, tw: 0.0, ty: 0.0, tz: 0.0, demerits: 0.0, prev: None, hyphens: 0, flagged: false }];
+ let mut arena: Vec =
+ vec![Node { pos: 0, line: 0, fitness: 1, tw: 0.0, ty: 0.0, tz: 0.0, demerits: 0.0, prev: None, hyphens: 0, flagged: false }];
let mut active: Vec = vec![0];
for b in 0..m {
let (is_break, pw, pp, flagged) = match items[b] {
@@ -188,10 +205,16 @@ fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width:
} else {
last_removed = Some((a, r));
}
- if r >= -1.0 && r <= tol || (forced && r >= -1.0) {
+ if r >= -1.0 && (r <= tol || forced) {
let bad = (100.0 * r.abs().powi(3)).min(INF);
let lp = 10.0 + bad;
- let mut d = if pp >= 0.0 { lp * lp + pp * pp } else if pp > -INF { lp * lp - pp * pp } else { lp * lp };
+ let mut d = if pp >= 0.0 {
+ lp * lp + pp * pp
+ } else if pp > -INF {
+ lp * lp - pp * pp
+ } else {
+ lp * lp
+ };
if flagged && n.flagged {
d += 3000.0;
}
@@ -260,6 +283,9 @@ fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width:
}
}
if active.is_empty() {
+ if std::env::var_os("DC_KP_DEBUG").is_some() {
+ eprintln!("kp tol {tol}: no active nodes at item {b}/{m} (glyph {})", ig[b]);
+ }
return None;
}
}
diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs
index ddb6e0ca..29afcc33 100644
--- a/crates/compose/src/hyphen.rs
+++ b/crates/compose/src/hyphen.rs
@@ -36,6 +36,9 @@ fn onset_pair(a: char, b: char) -> bool {
}
/// Common English suffixes that form their own syllable (break before them).
+/// Common English prefixes (break after them).
+const PREFIXES: &[&str] = &["re", "de", "pre", "un", "dis", "con", "com", "inter", "over", "under", "trans", "sub", "super", "anti", "auto"];
+
const SUFFIXES: &[&str] = &["tion", "sion", "ment", "ness", "less", "ful", "able", "ible", "ing", "ly"];
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char).
@@ -61,14 +64,23 @@ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec {
let sl = s.chars().count();
n - i == sl && lower.ends_with(s) && v[..i].iter().any(|x| *x)
});
+ let prefix_at = PREFIXES.iter().any(|p| p.chars().count() == i && lower.starts_with(p) && n - i >= 4 && c[i..].iter().any(|x| is_vowel(*x)));
let ok = suffix_at
+ || prefix_at
|| if v[i - 1] && !v[i] {
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
} else if !v[i - 1] && !v[i] {
- i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2])
+ i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i]
+ || (c[i - 1] == c[i] && i >= 2 && v[i - 2])
} else {
false
};
+ // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count
+ // ("strai-ned" is wrong, "re-strained" is right).
+ let right: String = c[i..].iter().collect::().to_lowercase();
+ let right_core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right);
+ let sounded = right_core.chars().any(is_vowel) && right_core.len() > 1;
+ let ok = ok && sounded && v[..i].iter().any(|x| *x);
if ok && !out.contains(&i) {
out.push(i);
}
@@ -104,6 +116,9 @@ mod tests {
assert_eq!(h("typography"), "ty-po-gra-phy");
assert!(h("composition").contains("com-"));
assert_eq!(h("cat"), "cat");
+ assert!(!h("restrained").contains("-ned"), "{}", h("restrained"));
+ assert!(!h("changed").contains('-'));
+ assert!(h("restrained").starts_with("re-"), "{}", h("restrained"));
assert_eq!(h("NASA"), "NASA");
}
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index ac6224e8..5b5cbafa 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -365,7 +365,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
}
}
}
- let fits = baseline + desc <= col.y1 + 0.01 || (cur.last_baseline.is_none() && baseline <= col.y1 && false);
+ let fits = baseline + desc <= col.y1 + 0.01;
if !fits {
// Next column / frame; re-break the rest of the paragraph there.
g0 = s;
@@ -428,12 +428,23 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let r = &pp.rule_above;
let y = bl - asc - r.offset;
let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0));
- ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color: r.color.clone(), tint: r.tint });
+ ft.decos.push(Deco {
+ rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y),
+ color: r.color.clone(),
+ tint: r.tint,
+ });
}
if pp.shading_on {
let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect();
if let (Some(a), Some(z)) = (lines.first(), lines.last()) {
- ft.decos.insert(0, Deco { rect: Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent), color: pp.shading_color.clone(), tint: pp.shading_tint });
+ ft.decos.insert(
+ 0,
+ Deco {
+ rect: Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent),
+ color: pp.shading_color.clone(),
+ tint: pp.shading_tint,
+ },
+ );
}
}
}
@@ -521,7 +532,15 @@ impl Cursor {
}
}
-fn line_metrics(line: &[Glyph], all: &[Glyph], s: usize, base_leading: f64, base_size: f64, db: &FontDb, base: &designcraft_doc::CharProps) -> (f64, f64, f64) {
+fn line_metrics(
+ line: &[Glyph],
+ all: &[Glyph],
+ s: usize,
+ base_leading: f64,
+ base_size: f64,
+ db: &FontDb,
+ base: &designcraft_doc::CharProps,
+) -> (f64, f64, f64) {
let _ = db;
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
if src.is_empty() {
@@ -735,11 +754,36 @@ fn layout_line(
}
fn place(g: &Glyph, x: f64) -> PlacedGlyph {
- let visible = !(g.ch == '\t' || g.ch == '\n' || breaker::is_forced(g.ch) || g.ch == story::INDENT_HERE || g.ch == story::RIGHT_INDENT_TAB || g.ch == shape::SOFT_HYPHEN);
- PlacedGlyph { face: g.face.clone(), gid: g.gid, x: x + g.dx, y: -g.shift + g.dy, adv: g.adv, sx: g.sx, sy: g.sy, style: g.style, byte: g.byte, len: g.len, visible }
+ let visible = !(g.ch == '\t'
+ || g.ch == '\n'
+ || breaker::is_forced(g.ch)
+ || g.ch == story::INDENT_HERE
+ || g.ch == story::RIGHT_INDENT_TAB
+ || g.ch == shape::SOFT_HYPHEN);
+ PlacedGlyph {
+ face: g.face.clone(),
+ gid: g.gid,
+ x: x + g.dx,
+ y: -g.shift + g.dy,
+ adv: g.adv,
+ sx: g.sx,
+ sy: g.sy,
+ style: g.style,
+ byte: g.byte,
+ len: g.len,
+ visible,
+ }
}
-fn prepend_label(db: &FontDb, glyphs: &mut Vec, label: &str, at: usize, base: &designcraft_doc::CharProps, pp: &ParaProps, table: &mut StyleTable<'_>) {
+fn prepend_label(
+ db: &FontDb,
+ glyphs: &mut Vec,
+ label: &str,
+ at: usize,
+ base: &designcraft_doc::CharProps,
+ pp: &ParaProps,
+ table: &mut StyleTable<'_>,
+) {
// Shape the label as a tiny standalone story so it uses the paragraph's base character style.
let mut tmp = Story::new(StoryId(0));
tmp.insert(0, label);
diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs
index 9f36ad02..90c55d52 100644
--- a/crates/compose/src/shape.rs
+++ b/crates/compose/src/shape.rs
@@ -152,7 +152,16 @@ fn is_mark(c: char) -> bool {
}
#[allow(clippy::too_many_arguments)]
-fn shape_run(db: &FontDb, text: &str, range: std::ops::Range, p: &CharProps, auto_leading: f64, style: u32, sub: &SubstCtx, out: &mut Vec) {
+fn shape_run(
+ db: &FontDb,
+ text: &str,
+ range: std::ops::Range,
+ p: &CharProps,
+ auto_leading: f64,
+ style: u32,
+ sub: &SubstCtx,
+ out: &mut Vec,
+) {
let primary = db.face(&p.font_family, &p.font_style);
// Split into segments: font coverage changes and special characters (markers, tabs, breaks).
let mut seg_start = range.start;
@@ -201,7 +210,11 @@ fn shape_run(db: &FontDb, text: &str, range: std::ops::Range, p: &CharPro
continue;
}
let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c);
- let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()) };
+ let face = if covered || is_mark(c) {
+ if is_mark(c) { seg_face.clone() } else { primary.clone() }
+ } else {
+ db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone())
+ };
if face.id() != seg_face.id() {
flush(seg_start, i, &seg_face, out);
seg_start = i;
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 05d7dc06..4981ce04 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -134,7 +134,8 @@ fn wrap_pushes_text_aside() {
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
- let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)));
+ let mut it =
+ designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)));
it.wrap.mode = WrapMode::BoundingBox;
it.wrap.offsets = [0.0, 0.0, 6.0, 6.0];
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
@@ -150,7 +151,11 @@ fn paragraph_composer_is_no_worse_than_greedy() {
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
let measure = 180.0;
let mk = |c: designcraft_doc::Composer| {
- let (d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, measure, 4000.0), ParaAttrs { composer: Some(c), hyphenate: Some(false), ..Default::default() });
+ let (d, sid, _) = doc_with(
+ &[LOREM; 2].join(" "),
+ Rect::new(0.0, 0.0, measure, 4000.0),
+ ParaAttrs { composer: Some(c), hyphenate: Some(false), ..Default::default() },
+ );
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let n = lines.len();
@@ -163,10 +168,34 @@ fn paragraph_composer_is_no_worse_than_greedy() {
#[test]
fn tabs_align_to_stops() {
- let mut pa = ParaAttrs::default();
- pa.tabs = Some(vec![designcraft_doc::TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: String::new() }]);
+ let pa = ParaAttrs {
+ tabs: Some(vec![designcraft_doc::TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: String::new() }]),
+ ..Default::default()
+ };
let (d, sid, _) = doc_with("Name\t42", Rect::new(0.0, 0.0, 300.0, 100.0), pa);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
assert!((l.end_x - 100.0).abs() < 0.5, "{}", l.end_x);
}
+
+#[test]
+fn justified_narrow_column_has_no_extreme_lines() {
+ let text = "Color holds it all together. A restrained palette of two or three swatches, applied consistently to headlines, rules and backgrounds, gives a publication its voice. Spot inks, tints and gradients are all just named swatches, so a single change ripples through every page.";
+ let para = ParaAttrs { align: Some(Align::LeftJustified), first_line_indent: Some(12.0), hyph_min_word: Some(6), ..Default::default() };
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let (_, sid) =
+ d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 170.67, 2000.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap();
+ d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.75));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let mut worst = 0.0f64;
+ for l in all_lines(&cs) {
+ let s: String = d.story(sid).unwrap().text[l.range.clone()].to_string();
+ eprintln!("{:5.2} {}", l.spacing, s);
+ if !l.last_in_para {
+ worst = worst.max(l.spacing);
+ }
+ }
+ // Regression bound; tighter once dictionary hyphenation lands (plan: Moby PD word list).
+ assert!(worst < 8.0, "loose line ratio {worst}");
+}
diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs
index 4749aa07..4dab9e45 100644
--- a/crates/doc/src/attrs.rs
+++ b/crates/doc/src/attrs.rs
@@ -320,7 +320,21 @@ fn roman(mut n: u32) -> String {
if n == 0 {
return "0".into();
}
- let t = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")];
+ let t = [
+ (1000, "M"),
+ (900, "CM"),
+ (500, "D"),
+ (400, "CD"),
+ (100, "C"),
+ (90, "XC"),
+ (50, "L"),
+ (40, "XL"),
+ (10, "X"),
+ (9, "IX"),
+ (5, "V"),
+ (4, "IV"),
+ (1, "I"),
+ ];
let mut s = String::new();
for (v, r) in t {
while n >= v {
diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs
index 846ea25e..91988eb3 100644
--- a/crates/doc/src/build.rs
+++ b/crates/doc/src/build.rs
@@ -182,7 +182,13 @@ impl Document {
pages[1].side = PageSide::Right;
}
let id = SpreadId(self.alloc());
- let mut sp = Spread { id, pages, items: vec![], parent: Some(ParentInfo { prefix: prefix.into(), name: name.into(), based_on: None }), allow_shuffle: true };
+ let mut sp = Spread {
+ id,
+ pages,
+ items: vec![],
+ parent: Some(ParentInfo { prefix: prefix.into(), name: name.into(), based_on: None }),
+ allow_shuffle: true,
+ };
sp.relayout();
self.parents.push(Arc::new(sp));
id
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index def4d380..1bc22820 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -24,7 +24,7 @@ use std::sync::Arc;
pub use attrs::*;
pub use designcraft_color as color;
pub use designcraft_geom as geom;
-pub use edit::{ItemLoc, ItemPath, SpreadRef};
+pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit};
pub use ids::*;
pub use item::*;
pub use page::*;
diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs
index 3855104c..4dcf9ec9 100644
--- a/crates/doc/src/page.rs
+++ b/crates/doc/src/page.rs
@@ -253,7 +253,13 @@ mod tests {
#[test]
fn spread_spine_and_hit() {
- let s = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0)], items: vec![], parent: None, allow_shuffle: true };
+ let s = Spread {
+ id: SpreadId(1),
+ pages: vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0)],
+ items: vec![],
+ parent: None,
+ allow_shuffle: true,
+ };
assert_eq!(s.spine_x(), 612.0);
assert_eq!(s.page_at_x(700.0), Some(1));
assert_eq!(s.page_at_x(-50.0), Some(0));
diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs
index de79ad82..976bf5ad 100644
--- a/crates/doc/src/story.rs
+++ b/crates/doc/src/story.rs
@@ -85,7 +85,14 @@ pub struct Story {
impl Story {
pub fn new(id: StoryId) -> Self {
- Story { id, text: String::new(), paras: vec![ParaFormat::default()], chars: vec![CharRun { len: 0, format: CharFormat::default() }], frames: vec![], rev: 0 }
+ Story {
+ id,
+ text: String::new(),
+ paras: vec![ParaFormat::default()],
+ chars: vec![CharRun { len: 0, format: CharFormat::default() }],
+ frames: vec![],
+ rev: 0,
+ }
}
pub fn with_text(id: StoryId, text: &str, para: ParaFormat) -> Self {
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index d07804d9..c4c7a466 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -67,7 +67,14 @@ fn hit_testing_respects_layers() {
fn page_names_follow_sections() {
let mut d = Document::new(&NewDocument { pages: 6, ..Default::default() });
assert_eq!(d.page_name(0), "1");
- d.sections.push(Section { start: 2, start_number: Some(1), style: NumberStyle::LowerRoman, prefix: "A-".into(), marker: String::new(), include_prefix: true });
+ d.sections.push(Section {
+ start: 2,
+ start_number: Some(1),
+ style: NumberStyle::LowerRoman,
+ prefix: "A-".into(),
+ marker: String::new(),
+ include_prefix: true,
+ });
assert_eq!(d.page_name(1), "2");
assert_eq!(d.page_name(2), "A-i");
assert_eq!(d.page_name(4), "A-iii");
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
new file mode 100644
index 00000000..5d9e5ec5
--- /dev/null
+++ b/crates/engine/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "designcraft-engine"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "DesignCraft engine: session, documents, history, selection, command registry, tool hosting"
+
+[lints]
+workspace = true
+
+[dependencies]
+designcraft-geom = { workspace = true }
+designcraft-color = { workspace = true }
+designcraft-doc = { workspace = true }
+designcraft-fonts = { workspace = true }
+designcraft-compose = { workspace = true }
+designcraft-render = { workspace = true }
+designcraft-tools = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+log = { workspace = true }
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
new file mode 100644
index 00000000..b8d456d2
--- /dev/null
+++ b/crates/engine/src/cmd/edit.rs
@@ -0,0 +1,163 @@
+//! Edit menu: undo/redo, selection, clear, clipboard.
+
+use std::sync::Arc;
+
+use designcraft_doc::{ItemId, Selection, SpreadRef};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
+use crate::{HistoryEntry, Result, Session};
+
+fn can_undo(s: &Session) -> std::result::Result<(), String> {
+ s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
+}
+fn can_redo(s: &Session) -> std::result::Result<(), String> {
+ s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
+}
+fn has_clip(s: &Session) -> std::result::Result<(), String> {
+ has_doc(s)?;
+ if s.clipboard.is_some() { Ok(()) } else { Err("clipboard is empty".into()) }
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| {
+ let st = s.doc_mut()?;
+ let e = st.history.undo.pop().expect("checked");
+ st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
+ st.doc = e.doc;
+ st.selection = e.selection;
+ st.revision += 1;
+ Ok(json!({"undone": e.label}))
+ }),
+ cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
+ let st = s.doc_mut()?;
+ let e = st.history.redo.pop().expect("checked");
+ st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
+ st.doc = e.doc;
+ st.selection = e.selection;
+ st.revision += 1;
+ Ok(json!({"redone": e.label}))
+ }),
+ cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
+ let ids = ids_param(p, "ids").unwrap_or_default();
+ let add = bool_or(p, "add", false);
+ let content = bool_or(p, "content", false);
+ let st = s.doc_mut()?;
+ let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
+ if add {
+ for i in ids {
+ if !st.selection.items.contains(&i) {
+ st.selection.items.push(i);
+ }
+ }
+ } else {
+ st.selection = Selection { items: ids, content, ..Default::default() };
+ }
+ st.selection.text = None;
+ st.revision += 1;
+ ok()
+ }),
+ cmd!(noundo "selection.toggle", "Toggle Selection", [], None, "{id}", has_doc, |s, p| {
+ let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.toggle", "missing id"))?;
+ let st = s.doc_mut()?;
+ st.selection.text = None;
+ if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
+ st.selection.items.remove(i);
+ } else {
+ st.selection.items.push(id);
+ }
+ st.revision += 1;
+ ok()
+ }),
+ cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
+ let st = s.doc_mut()?;
+ if let Some(t) = st.selection.text {
+ let len = st.doc.story(t.story).map(|x| x.len()).unwrap_or(0);
+ st.selection.text = Some(designcraft_doc::TextSel { anchor: 0, focus: len, ..t });
+ } else {
+ let mut ids = Vec::new();
+ let parents = st.editing_parents;
+ let refs: Vec = st.doc.spread_refs().filter(|r| matches!(r, SpreadRef::Parent(_)) == parents).collect();
+ for r in refs {
+ if let Some(sp) = st.doc.spread(r) {
+ for it in &sp.items {
+ let locked = it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
+ if !locked && !it.hidden {
+ ids.push(it.id);
+ }
+ }
+ }
+ }
+ st.selection = Selection::items(ids);
+ }
+ st.revision += 1;
+ ok()
+ }),
+ cmd!(noundo "edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", has_doc, |s, _| {
+ let st = s.doc_mut()?;
+ st.selection = Selection::default();
+ st.revision += 1;
+ ok()
+ }),
+ cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
+ if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
+ return super::text::delete_selection(s);
+ }
+ let ids = super::targets(s, p)?;
+ s.edit(|d, sel| {
+ for id in &ids {
+ let _ = d.remove_item(*id);
+ }
+ *sel = Selection::default();
+ Ok(json!({"deleted": ids.len()}))
+ })
+ }),
+ cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| {
+ s.clipboard = Some(Arc::new(clip_doc(s)?));
+ ok()
+ }),
+ cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, p| {
+ s.clipboard = Some(Arc::new(clip_doc(s)?));
+ s.execute("edit.clear", p)
+ }),
+ cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{inPlace?: bool}", has_clip, |s, p| {
+ let clip = s.clipboard.clone().expect("checked");
+ let off = if bool_or(p, "inPlace", false) { 0.0 } else { 12.0 };
+ let ids: Vec = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
+ s.edit(|d, sel| {
+ let new = super::object::duplicate_from(d, &clip, &ids, SpreadRef::Doc(0), designcraft_geom::Vec2::new(off, off))?;
+ *sel = Selection::items(new.clone());
+ Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()}))
+ })
+ }),
+ cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clip, |s, _| s
+ .execute("edit.paste", &json!({"inPlace": true}))),
+ cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Alt+Shift+D"), "{}", has_selection, |s, _| s
+ .execute("transform.move", &json!({"dx": 12.0, "dy": 12.0, "copy": true}))),
+ ]
+}
+
+/// A document holding copies of the selected items (and their stories) on spread 0.
+fn clip_doc(s: &Session) -> Result {
+ let st = s.doc()?;
+ let mut d = (*st.doc).clone();
+ let keep: Vec = st.selection.items.clone();
+ // Gather the selected items (keep their spread coordinates) into spread 0.
+ let mut items = Vec::new();
+ for id in &keep {
+ if let Some(it) = st.doc.item(*id) {
+ items.push(Arc::new(it.clone()));
+ }
+ }
+ for sp in d.spreads.iter_mut() {
+ Arc::make_mut(sp).items.clear();
+ }
+ if let Some(sp) = d.spreads.first_mut() {
+ Arc::make_mut(sp).items = items;
+ }
+ Ok(d)
+}
+
+#[allow(dead_code)]
+fn _unused(_: Value) {}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
new file mode 100644
index 00000000..4c0f87e3
--- /dev/null
+++ b/crates/engine/src/cmd/file.rs
@@ -0,0 +1,258 @@
+//! File menu: new, open, save, place, export, close.
+
+use std::sync::Arc;
+
+use designcraft_doc::build::{NewDocument, PRESETS};
+use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Selection, Shape};
+use designcraft_geom::{Affine, Rect, shapes};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, bad, cmd, f64_or, has_doc, ok, str_param};
+use crate::{DocState, EngineError, Result, Session};
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"),
+ "{preset?: \"Letter\"|\"A4\"|…, width?, height?, pages?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, title?}",
+ always, file_new),
+ cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
+ cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
+ cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft (JSON)", always, file_open),
+ cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64}", always, file_open_bytes),
+ cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
+ cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
+ cmd!(query "file.serialize", "Serialize", [], None, "{} → {json}", has_doc, |s, _| Ok(json!({"json": String::from_utf8_lossy(&to_bytes(&s.doc()?.doc)).to_string()}))),
+ cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| {
+ let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0);
+ s.close_document(i);
+ ok()
+ }),
+ cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| {
+ s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize);
+ ok()
+ }),
+ cmd!(
+ "file.place",
+ "Place…",
+ ["File"],
+ Some("Cmd+D"),
+ "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image; into the selected empty frame if any",
+ has_doc,
+ file_place
+ ),
+ ]
+}
+
+pub fn to_bytes(d: &Document) -> Vec {
+ // Assets are embedded as base64 alongside the JSON document.
+ let mut v = serde_json::to_value(d).unwrap_or_default();
+ let assets: serde_json::Map = d.assets.iter().map(|(k, a)| (k.0.to_string(), Value::from(base64_encode(&a.data)))).collect();
+ v["assetData"] = Value::Object(assets);
+ v["format"] = json!({"name": "designcraft", "version": 1});
+ serde_json::to_vec(&v).unwrap_or_default()
+}
+
+pub fn from_bytes(b: &[u8]) -> Result {
+ let v: Value = serde_json::from_slice(b).map_err(|e| EngineError::Other(format!("not a DesignCraft document: {e}")))?;
+ let mut d: Document = serde_json::from_value(v.clone()).map_err(|e| EngineError::Other(format!("bad document: {e}")))?;
+ if let Some(data) = v.get("assetData").and_then(Value::as_object) {
+ for (k, s) in data {
+ if let (Ok(id), Some(s)) = (k.parse::(), s.as_str())
+ && let Some(a) = d.assets.get_mut(&AssetId(id))
+ {
+ Arc::make_mut(a).data = Arc::new(base64_decode(s));
+ }
+ }
+ }
+ d.check()?;
+ Ok(d)
+}
+
+fn file_new(s: &mut Session, p: &Value) -> Result {
+ let mut nd = match str_param(p, "preset") {
+ Some(name) => NewDocument::from_preset(name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
+ None => NewDocument::default(),
+ };
+ nd.width = f64_or(p, "width", nd.width);
+ nd.height = f64_or(p, "height", nd.height);
+ nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999);
+ nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
+ nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(nd.columns);
+ nd.gutter = f64_or(p, "gutter", nd.gutter);
+ nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false);
+ match p.get("margins") {
+ Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)),
+ Some(v @ Value::Object(_)) => {
+ if let Ok(m) = serde_json::from_value(v.clone()) {
+ nd.margins = m;
+ }
+ }
+ _ => {}
+ }
+ if let Some(b) = p.get("bleed").and_then(Value::as_f64) {
+ nd.bleed = [b; 4];
+ }
+ s.untitled += 1;
+ nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
+ if nd.width <= 0.0 || nd.height <= 0.0 || nd.width > 15552.0 || nd.height > 15552.0 {
+ return Err(bad("file.new", "page size out of range (0 < size ≤ 216 in)"));
+ }
+ let d = Document::new(&nd);
+ let i = s.add_document(DocState::new(d, None));
+ Ok(json!({"index": i}))
+}
+
+fn file_sample(s: &mut Session, _p: &Value) -> Result {
+ let d = crate::sample::magazine();
+ let i = s.add_document(DocState::new(d, None));
+ Ok(json!({"index": i}))
+}
+
+fn file_open(s: &mut Session, p: &Value) -> Result {
+ let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let d = from_bytes(&bytes)?;
+ let i = s.add_document(DocState::new(d, Some(path.to_string())));
+ Ok(json!({"index": i}))
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = (s, path);
+ Err(EngineError::Other("use file.openBytes on the web".into()))
+ }
+}
+
+fn file_open_bytes(s: &mut Session, p: &Value) -> Result {
+ let b = base64_decode(str_param(p, "base64").unwrap_or(""));
+ let mut d = from_bytes(&b)?;
+ if let Some(n) = str_param(p, "name") {
+ d.title = n.to_string();
+ }
+ let i = s.add_document(DocState::new(d, None));
+ Ok(json!({"index": i}))
+}
+
+fn file_save(s: &mut Session, p: &Value) -> Result {
+ let st = s.doc_mut()?;
+ let path = str_param(p, "path")
+ .map(str::to_string)
+ .or_else(|| st.path.clone())
+ .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
+ let bytes = to_bytes(&st.doc);
+ #[cfg(not(target_arch = "wasm32"))]
+ std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ st.path = Some(path.clone());
+ st.saved_revision = st.revision;
+ st.saved_doc = st.doc.clone();
+ Ok(json!({"path": path, "bytes": bytes.len()}))
+}
+
+fn file_place(s: &mut Session, p: &Value) -> Result {
+ let (bytes, name, link) = if let Some(b) = str_param(p, "base64") {
+ (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)
+ } else if let Some(path) = str_param(p, "path") {
+ #[cfg(not(target_arch = "wasm32"))]
+ let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ #[cfg(target_arch = "wasm32")]
+ let b: Vec = vec![];
+ let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
+ (b, name, Some(path.to_string()))
+ } else {
+ s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) });
+ return ok();
+ };
+ let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?;
+ // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
+ let (nw, nh) = (pw as f64, ph as f64);
+ let target_frame = super::id_param(p, "frame").or_else(|| {
+ let st = s.active()?;
+ st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
+ });
+ let spread = super::spread_param(p, "spread");
+ let lid = s.doc()?.active_layer;
+ let px = p.get("x").and_then(Value::as_f64);
+ let py = p.get("y").and_then(Value::as_f64);
+ let want_w = p.get("width").and_then(Value::as_f64);
+ s.edit(|d, sel| {
+ let aid = AssetId(d.alloc());
+ let mime = designcraft_render::image_mime(&bytes).to_string();
+ d.assets.insert(aid, Arc::new(Asset { id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
+ let (w, h) = match want_w {
+ Some(w) => (w, w * nh / nw),
+ None => {
+ let page_w = d.settings.page_width * 0.6;
+ if nw > page_w { (page_w, page_w * nh / nw) } else { (nw, nh) }
+ }
+ };
+ let id = if let Some(fid) = target_frame {
+ let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
+ let r = it.inner_bounds();
+ // Fill frame proportionally.
+ let k = (r.width() / nw).max(r.height() / nh);
+ let (gw, gh) = (nw * k, nh * k);
+ it.content = Content::Graphic(Graphic {
+ asset: aid,
+ size: (nw, nh),
+ xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
+ auto_fit: designcraft_doc::Fitting::FillProportionally,
+ });
+ fid
+ } else {
+ let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?;
+ let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0));
+ let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0));
+ let id = ItemId(d.alloc());
+ let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + w, y + h)));
+ it.object_style = d.styles.default_graphic_frame.clone();
+ it.content = Content::Graphic(Graphic {
+ asset: aid,
+ size: (nw, nh),
+ xf: Affine::translate((x, y)) * Affine::scale(w / nw),
+ auto_fit: Default::default(),
+ });
+ d.insert_item(spread, it, None)?;
+ id
+ };
+ *sel = Selection::items(vec![id]);
+ Ok(json!({"id": id.0, "asset": aid.0}))
+ })
+}
+
+const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+pub fn base64_encode(data: &[u8]) -> String {
+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
+ for c in data.chunks(3) {
+ let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
+ out.push(B64[(n >> 18) as usize & 63] as char);
+ out.push(B64[(n >> 12) as usize & 63] as char);
+ out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
+ out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
+ }
+ out
+}
+
+pub fn base64_decode(s: &str) -> Vec {
+ let mut out = Vec::with_capacity(s.len() * 3 / 4);
+ let mut buf = 0u32;
+ let mut bits = 0;
+ for b in s.bytes() {
+ let v = match b {
+ b'A'..=b'Z' => b - b'A',
+ b'a'..=b'z' => b - b'a' + 26,
+ b'0'..=b'9' => b - b'0' + 52,
+ b'+' | b'-' => 62,
+ b'/' | b'_' => 63,
+ _ => continue,
+ } as u32;
+ buf = buf << 6 | v;
+ bits += 6;
+ if bits >= 8 {
+ bits -= 8;
+ out.push((buf >> bits) as u8);
+ }
+ }
+ out
+}
diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs
new file mode 100644
index 00000000..f208ce2c
--- /dev/null
+++ b/crates/engine/src/cmd/inspect.rs
@@ -0,0 +1,95 @@
+//! Queries for agents and UIs: document structure, items, pages.
+
+use designcraft_doc::{Content, Item};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, always, cmd, has_doc};
+use crate::{Result, Session};
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(query "document.inspect", "Inspect Document", [], None, "{} → pages, spreads, items, stories (with overset), styles, swatches, selection", has_doc, inspect),
+ cmd!(query "document.list", "List Documents", [], None, "{}", always, |s, _| {
+ Ok(json!(s.documents().iter().enumerate().map(|(i, d)| json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()})).collect::>()))
+ }),
+ cmd!(noundo "tool.select", "Select Tool", [], None, "{tool: selection|directSelection|type|rectangleFrame|…}", always, |s, p| {
+ let t = p.get("tool").and_then(Value::as_str).unwrap_or("selection");
+ if designcraft_tools::tool_info(t).is_none() {
+ return Err(super::bad("tool.select", format!("unknown tool `{t}`")));
+ }
+ s.set_tool(t);
+ Ok(json!({"tool": s.tool_id()}))
+ }),
+ cmd!(query "tool.list", "List Tools", [], None, "{}", always, |_, _| Ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default())),
+ cmd!(query "document.history", "History", [], None, "{}", has_doc, |s, _| {
+ let st = s.doc()?;
+ Ok(json!({"undo": st.history.undo.iter().map(|e| &e.label).collect::>(), "redo": st.history.redo.iter().map(|e| &e.label).collect::>()}))
+ }),
+ ]
+}
+
+fn item_json(it: &Item) -> Value {
+ let b = it.bounds();
+ let mut v = json!({
+ "id": it.id.0, "name": it.name, "kind": it.default_label(), "layer": it.layer.0,
+ "bounds": [b.x0, b.y0, b.x1, b.y1], "fill": it.fill.swatch, "stroke": {"swatch": it.stroke.swatch, "weight": it.stroke.weight},
+ "locked": it.locked, "hidden": it.hidden,
+ });
+ match &it.content {
+ Content::Text(t) => {
+ v["story"] = json!(t.story.0);
+ v["columns"] = json!(t.options.columns);
+ }
+ Content::Graphic(g) => v["asset"] = json!(g.asset.0),
+ Content::Group { items } => v["children"] = Value::Array(items.iter().map(|c| item_json(c)).collect()),
+ Content::Unassigned => {}
+ }
+ v
+}
+
+fn inspect(s: &mut Session, _p: &Value) -> Result {
+ let st = s.doc()?;
+ let d = &st.doc;
+ let spreads: Vec = d
+ .spreads
+ .iter()
+ .enumerate()
+ .map(|(si, sp)| {
+ json!({
+ "index": si,
+ "pages": sp.pages.iter().enumerate().map(|(pi, p)| {
+ let abs = d.first_page_of_spread(si) + pi;
+ json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side,
+ "parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()),
+ "margins": p.margins, "columns": p.columns.count})
+ }).collect::>(),
+ "items": sp.items.iter().map(|i| item_json(i)).collect::>(),
+ })
+ })
+ .collect();
+ let stories: Vec = d
+ .stories
+ .values()
+ .map(|story| {
+ let cs = s.cache.get(d, story.id, None);
+ let preview: String = story.text.chars().take(80).collect();
+ json!({"id": story.id.0, "frames": story.frames.iter().map(|f| f.0).collect::>(), "length": story.len(), "paragraphs": story.paras.len(),
+ "overset": cs.overset_at.is_some(), "lines": cs.line_count(), "preview": preview})
+ })
+ .collect();
+ Ok(json!({
+ "title": st.title(), "path": st.path, "dirty": st.is_dirty(), "pageCount": d.page_count(),
+ "settings": {"pageWidth": d.settings.page_width, "pageHeight": d.settings.page_height, "facingPages": d.settings.facing_pages, "intent": d.settings.intent},
+ "spreads": spreads,
+ "parents": d.parents.iter().map(|p| json!({"id": p.id.0, "label": p.parent.as_ref().map(|i| i.label()), "items": p.items.len()})).collect::>(),
+ "layers": d.layers,
+ "stories": stories,
+ "paragraphStyles": d.styles.paragraph.iter().map(|p| &p.name).collect::>(),
+ "characterStyles": d.styles.character.iter().map(|p| &p.name).collect::>(),
+ "swatches": d.swatches.iter().map(|w| &w.name).collect::>(),
+ "selection": st.selection,
+ "activeLayer": st.active_layer.0,
+ "tool": s.tool_id(),
+ "canUndo": !st.history.undo.is_empty(),
+ }))
+}
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
new file mode 100644
index 00000000..1c689fca
--- /dev/null
+++ b/crates/engine/src/cmd/layout.rs
@@ -0,0 +1,260 @@
+//! Layout menu and Pages/Layers panels: pages, spreads, parents, margins & columns, layers.
+
+use designcraft_doc::{LAYER_COLORS, Layer, LayerId, Margins, SpreadId};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bad, cmd, has_doc, ok, str_param};
+use crate::Result;
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "layout.pages.insert",
+ "Insert Pages…",
+ ["Layout", "Pages"],
+ Some("Cmd+Shift+P"),
+ "{count?: 1, after?: page index (default: last), parent?: \"A\"|null}",
+ has_doc,
+ |s, p| {
+ let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
+ let st = s.doc()?;
+ let n = st.doc.page_count();
+ let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1);
+ let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
+ s.edit(|d, _| {
+ let ids = d.insert_pages(Some(after), count, parent)?;
+ Ok(json!({"pages": ids.len(), "total": d.page_count()}))
+ })
+ }
+ ),
+ cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| {
+ let pages: Vec = p
+ .get("pages")
+ .and_then(Value::as_array)
+ .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect())
+ .unwrap_or_default();
+ s.edit(|d, sel| {
+ d.delete_pages(&pages)?;
+ sel.items.retain(|i| d.item(*i).is_some());
+ sel.text = sel.text.filter(|t| d.story(t.story).is_some());
+ ok()
+ })
+ }),
+ cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| {
+ let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize;
+ let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize;
+ s.edit(|d, _| {
+ d.move_page(from, to)?;
+ ok()
+ })
+ }),
+ cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| {
+ let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
+ s.edit(|d, _| {
+ let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
+ let after = d.first_page_of_spread(si) + sp.pages.len() - 1;
+ let parent = sp.pages.first().and_then(|p| p.parent);
+ d.insert_pages(Some(after), sp.pages.len(), parent)?;
+ // Copy items onto the new spread(s) at the same positions.
+ let src = d.clone();
+ let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
+ let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
+ super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
+ ok()
+ })
+ }),
+ cmd!(
+ "layout.pages.applyParent",
+ "Apply Parent to Pages…",
+ ["Layout", "Pages"],
+ None,
+ "{pages: [index], parent: \"A\"|null}",
+ has_doc,
+ |s, p| {
+ let pages: Vec = p
+ .get("pages")
+ .and_then(Value::as_array)
+ .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect())
+ .unwrap_or_default();
+ let parent = parent_ref(&s.doc()?.doc, p.get("parent")).unwrap_or(None);
+ s.edit(|d, _| {
+ d.apply_parent(&pages, parent)?;
+ ok()
+ })
+ }
+ ),
+ cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| {
+ let name = str_param(p, "name").unwrap_or("Parent").to_string();
+ s.edit(|d, _| {
+ let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
+ let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
+ let id = d.add_parent(&prefix, &name, m.0, m.1, m.2);
+ Ok(json!({"id": id.0, "prefix": prefix}))
+ })
+ }),
+ cmd!(noundo "layout.parents.edit", "Edit Parents", [], None, "{on: bool} — show parent spreads on the canvas", has_doc, |s, p| {
+ let st = s.doc_mut()?;
+ st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents);
+ st.selection = Default::default();
+ st.revision += 1;
+ ok()
+ }),
+ cmd!(
+ "layout.marginsAndColumns",
+ "Margins and Columns…",
+ ["Layout"],
+ None,
+ "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?}",
+ has_doc,
+ |s, p| {
+ let pages: Option> =
+ p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
+ let p = p.clone();
+ s.edit(|d, _| {
+ let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
+ for abs in all {
+ let Some((si, pi)) = d.page_loc(abs) else { continue };
+ let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi];
+ match p.get("margins") {
+ Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
+ Some(v @ Value::Object(_)) => {
+ if let Ok(m) = serde_json::from_value(v.clone()) {
+ pg.margins = m;
+ }
+ }
+ _ => {}
+ }
+ if let Some(c) = p.get("columns").and_then(Value::as_u64) {
+ pg.columns.count = (c as u32).clamp(1, 216);
+ }
+ if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
+ pg.columns.gutter = g.max(0.0);
+ }
+ }
+ ok()
+ })
+ }
+ ),
+ cmd!(
+ "layout.documentSetup",
+ "Document Setup…",
+ ["File"],
+ Some("Cmd+Alt+P"),
+ "{width?, height?, facingPages?, bleed?: number}",
+ has_doc,
+ |s, p| {
+ let p = p.clone();
+ s.edit(|d, _| {
+ if let Some(f) = p.get("facingPages").and_then(Value::as_bool) {
+ d.settings.facing_pages = f;
+ }
+ if let Some(b) = p.get("bleed").and_then(Value::as_f64) {
+ d.settings.bleed = [b; 4];
+ }
+ let w = p.get("width").and_then(Value::as_f64);
+ let h = p.get("height").and_then(Value::as_f64);
+ if w.is_some() || h.is_some() {
+ d.settings.page_width = w.unwrap_or(d.settings.page_width);
+ d.settings.page_height = h.unwrap_or(d.settings.page_height);
+ for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
+ let sp = std::sync::Arc::make_mut(sp);
+ for pg in &mut sp.pages {
+ pg.width = d.settings.page_width;
+ pg.height = d.settings.page_height;
+ }
+ sp.relayout();
+ }
+ }
+ d.repaginate();
+ ok()
+ })
+ }
+ ),
+ cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| {
+ let name = str_param(p, "name").map(str::to_string);
+ let r = s.edit(|d, _| {
+ let id = LayerId(d.alloc());
+ let n = d.layers.len();
+ let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1));
+ d.layers.insert(
+ 0,
+ Layer {
+ id,
+ name,
+ color: LAYER_COLORS[n % LAYER_COLORS.len()].1,
+ visible: true,
+ locked: false,
+ printable: true,
+ show_guides: true,
+ suppress_wrap_when_hidden: false,
+ },
+ );
+ Ok(id)
+ })?;
+ s.doc_mut()?.active_layer = r;
+ Ok(json!({"id": r.0}))
+ }),
+ cmd!("layer.set", "Layer Options…", [], None, "{id, name?, visible?, locked?, printable?, color?: [r,g,b]}", has_doc, |s, p| {
+ let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
+ let p = p.clone();
+ s.edit(|d, _| {
+ let l = d.layer_mut(id).ok_or_else(|| bad("layer.set", "no such layer"))?;
+ if let Some(v) = p.get("name").and_then(Value::as_str) {
+ l.name = v.into();
+ }
+ if let Some(v) = p.get("visible").and_then(Value::as_bool) {
+ l.visible = v;
+ }
+ if let Some(v) = p.get("locked").and_then(Value::as_bool) {
+ l.locked = v;
+ }
+ if let Some(v) = p.get("printable").and_then(Value::as_bool) {
+ l.printable = v;
+ }
+ if let Some(c) = p.get("color").and_then(|c| serde_json::from_value(c.clone()).ok()) {
+ l.color = c;
+ }
+ ok()
+ })
+ }),
+ cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| {
+ let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
+ s.edit(|d, sel| {
+ if d.layers.len() < 2 {
+ return Err(bad("layer.delete", "a document needs at least one layer"));
+ }
+ let doomed: Vec<_> =
+ d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect();
+ for i in doomed {
+ let _ = d.remove_item(i);
+ }
+ d.layers.retain(|l| l.id != id);
+ *sel = Default::default();
+ ok()
+ })
+ }),
+ cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| {
+ let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
+ let st = s.doc_mut()?;
+ if st.doc.layer(id).is_none() {
+ return Err(bad("layer.activate", "no such layer"));
+ }
+ st.active_layer = id;
+ st.revision += 1;
+ ok()
+ }),
+ ]
+}
+
+/// `"A"` → that parent's id; `null` → Some(None) (= [None]); missing → None (use default).
+fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option> {
+ match v? {
+ Value::Null => Some(None),
+ Value::String(s) if s == "[None]" => Some(None),
+ Value::String(s) => Some(d.parents.iter().find(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)).map(|p| p.id)),
+ _ => None,
+ }
+}
+
+#[allow(dead_code)]
+fn _r(_: Result<()>) {}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
new file mode 100644
index 00000000..a95f2882
--- /dev/null
+++ b/crates/engine/src/cmd/mod.rs
@@ -0,0 +1,176 @@
+//! The command registry. Ids follow InDesign's menu structure.
+
+mod edit;
+mod file;
+mod inspect;
+mod layout;
+mod object;
+mod style;
+pub mod text;
+
+use designcraft_doc::{ItemId, SpreadRef};
+use designcraft_geom::{Point, Rect};
+use serde::Serialize;
+use serde_json::Value;
+
+use crate::{EngineError, Result, Session};
+
+pub type Run = fn(&mut Session, &Value) -> Result;
+pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
+
+pub struct CommandSpec {
+ pub id: &'static str,
+ pub label: &'static str,
+ /// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus.
+ pub menu: &'static [&'static str],
+ pub shortcut: Option<&'static str>,
+ pub params: &'static str,
+ pub enabled: Enabled,
+ pub run: Run,
+ pub journal: bool,
+ /// Record an undo step when the document changes.
+ pub undoable: bool,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct CommandInfo {
+ pub id: &'static str,
+ pub label: &'static str,
+ pub menu: Vec<&'static str>,
+ pub shortcut: Option<&'static str>,
+ pub params: &'static str,
+ pub enabled: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub disabled_reason: Option,
+}
+
+impl CommandSpec {
+ pub fn info(&self, s: &Session) -> CommandInfo {
+ let e = (self.enabled)(s);
+ CommandInfo {
+ id: self.id,
+ label: self.label,
+ menu: self.menu.to_vec(),
+ shortcut: self.shortcut,
+ params: self.params,
+ enabled: e.is_ok(),
+ disabled_reason: e.err(),
+ }
+ }
+}
+
+pub fn always(_: &Session) -> std::result::Result<(), String> {
+ Ok(())
+}
+pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
+ s.active().map(|_| ()).ok_or_else(|| "no document open".into())
+}
+pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
+ has_doc(s)?;
+ if s.active().is_some_and(|d| !d.selection.items.is_empty()) { Ok(()) } else { Err("nothing selected".into()) }
+}
+pub fn has_text(s: &Session) -> std::result::Result<(), String> {
+ has_doc(s)?;
+ if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
+}
+pub fn has_text_or_frames(s: &Session) -> std::result::Result<(), String> {
+ has_doc(s)?;
+ if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.items.iter().any(|i| d.doc.item(*i).is_some_and(|x| x.is_text_frame()))) {
+ Ok(())
+ } else {
+ Err("select text or a text frame".into())
+ }
+}
+
+macro_rules! cmd {
+ ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
+ $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
+ };
+ (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
+ $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
+ };
+ (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
+ $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
+ };
+}
+pub(crate) use cmd;
+
+pub fn command_specs() -> &'static [CommandSpec] {
+ static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new();
+ SPECS.get_or_init(|| {
+ let mut v = Vec::new();
+ v.extend(file::specs());
+ v.extend(edit::specs());
+ v.extend(object::specs());
+ v.extend(text::specs());
+ v.extend(style::specs());
+ v.extend(layout::specs());
+ v.extend(inspect::specs());
+ v
+ })
+}
+
+pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
+ command_specs().iter().find(|c| c.id == id)
+}
+
+// ---------- param helpers ----------
+
+pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError {
+ EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
+}
+pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
+ p.get(key).and_then(Value::as_f64).unwrap_or(default)
+}
+pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
+ p.get(key).and_then(Value::as_bool).unwrap_or(default)
+}
+pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
+ p.get(key).and_then(Value::as_str)
+}
+pub(crate) fn id_param(p: &Value, key: &str) -> Option {
+ p.get(key).and_then(Value::as_u64).map(ItemId)
+}
+pub(crate) fn ids_param(p: &Value, key: &str) -> Option> {
+ p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect())
+}
+pub(crate) fn point_param(p: &Value, key: &str) -> Option {
+ let a = p.get(key)?.as_array()?;
+ Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+}
+pub(crate) fn rect_param(p: &Value, key: &str) -> Option {
+ let a = p.get(key)?.as_array()?;
+ let f = |i: usize| a.get(i).and_then(Value::as_f64);
+ Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?))
+}
+/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0).
+pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef {
+ match p.get(key) {
+ Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize),
+ Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)),
+ None => SpreadRef::Doc(0),
+ }
+}
+
+/// Targets: `ids` / `id` params or the selection.
+pub(crate) fn targets(s: &Session, p: &Value) -> Result> {
+ if let Some(ids) = ids_param(p, "ids") {
+ return Ok(ids);
+ }
+ if let Some(id) = id_param(p, "id") {
+ return Ok(vec![id]);
+ }
+ Ok(s.doc()?.selection.items.clone())
+}
+
+pub(crate) fn ok() -> Result {
+ Ok(Value::Null)
+}
+
+pub fn file_bytes(d: &designcraft_doc::Document) -> Vec {
+ file::to_bytes(d)
+}
+pub fn file_from(b: &[u8]) -> Result {
+ file::from_bytes(b)
+}
+pub use file::{base64_decode, base64_encode};
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
new file mode 100644
index 00000000..e1253f61
--- /dev/null
+++ b/crates/engine/src/cmd/object.rs
@@ -0,0 +1,725 @@
+//! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide,
+//! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use designcraft_doc::{
+ Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
+};
+use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
+use crate::{Result, Session};
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "frame.create",
+ "Create Frame",
+ [],
+ None,
+ "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool}",
+ has_doc,
+ frame_create
+ ),
+ cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
+ cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
+ cmd!("transform.resize", "Resize", [], None, "{from: rect, to: rect, content?: bool (scale content), ids?}", has_selection, transform_resize),
+ cmd!("transform.rotate", "Rotate", ["Object", "Transform"], None, "{angle (degrees, CCW), ids?}", has_selection, transform_rotate),
+ cmd!("transform.flip", "Flip", ["Object", "Transform"], None, "{axis: horizontal|vertical, ids?}", has_selection, |s, p| {
+ let h = str_param(p, "axis") != Some("vertical");
+ apply_about_center(s, p, if h { Affine::scale_non_uniform(-1.0, 1.0) } else { Affine::scale_non_uniform(1.0, -1.0) })
+ }),
+ cmd!(
+ "transform.set",
+ "Transform Panel",
+ [],
+ None,
+ "{x?, y?, width?, height?, ref?: 0..8} — reference-point based geometry",
+ has_selection,
+ transform_set
+ ),
+ cmd!("object.arrange", "Arrange", ["Object", "Arrange"], None, "{to: front|forward|backward|back}", has_selection, arrange),
+ cmd!("object.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| s
+ .execute("object.arrange", &json!({"to": "front"}))),
+ cmd!("object.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| s
+ .execute("object.arrange", &json!({"to": "forward"}))),
+ cmd!("object.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| s
+ .execute("object.arrange", &json!({"to": "backward"}))),
+ cmd!("object.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| s
+ .execute("object.arrange", &json!({"to": "back"}))),
+ cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{ids?}", has_selection, group),
+ cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{ids?}", has_selection, ungroup),
+ cmd!("object.lock", "Lock", ["Object"], Some("Cmd+L"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.locked = true, true)),
+ cmd!("object.unlockAll", "Unlock All on Spread", ["Object"], Some("Cmd+Alt+L"), "{}", has_doc, |s, _| all_flag(s, |i| i.locked = false)),
+ cmd!("object.hide", "Hide", ["Object"], Some("Cmd+3"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.hidden = true, true)),
+ cmd!("object.showAll", "Show All on Spread", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, |s, _| all_flag(s, |i| i.hidden = false)),
+ cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| {
+ let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
+ let tint = f64_or(p, "tint", 1.0) as f32;
+ set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
+ }),
+ cmd!(
+ "object.stroke",
+ "Stroke",
+ [],
+ None,
+ "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?, join?, ids?}",
+ has_selection,
+ |s, p| {
+ let p2 = p.clone();
+ set_flag(
+ s,
+ p,
+ move |i| {
+ let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() };
+ if let Some(v) = p2.get("swatch").and_then(Value::as_str) {
+ st.swatch = v.into();
+ }
+ if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
+ st.tint = v as f32;
+ }
+ if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
+ st.weight = v.max(0.0);
+ }
+ for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
+ if let Some(v) = p2.get(k).cloned() {
+ let _ = match apply {
+ 0 => serde_json::from_value(v).map(|x| st.align = x),
+ 1 => serde_json::from_value(v).map(|x| st.kind = x),
+ 2 => serde_json::from_value(v).map(|x| st.cap = x),
+ 3 => serde_json::from_value(v).map(|x| st.join = x),
+ 4 => serde_json::from_value(v).map(|x| st.start = x),
+ _ => serde_json::from_value(v).map(|x| st.end = x),
+ };
+ }
+ }
+ i.stroke = st;
+ },
+ false,
+ )
+ }
+ ),
+ cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| {
+ let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32;
+ let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok());
+ set_flag(
+ s,
+ p,
+ move |i| {
+ i.opacity = o;
+ if let Some(b) = blend {
+ i.blend = b;
+ }
+ },
+ false,
+ )
+ }),
+ cmd!(
+ "object.dropShadow",
+ "Drop Shadow",
+ ["Object", "Effects"],
+ Some("Cmd+Alt+M"),
+ "{on?: bool, distance?, angle?, opacity?, size?, ids?}",
+ has_selection,
+ |s, p| {
+ let p2 = p.clone();
+ set_flag(
+ s,
+ p,
+ move |i| {
+ let ds = &mut i.effects.drop_shadow;
+ ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on);
+ ds.distance = f64_or(&p2, "distance", ds.distance);
+ ds.angle = f64_or(&p2, "angle", ds.angle);
+ ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32;
+ ds.size = f64_or(&p2, "size", ds.size);
+ },
+ false,
+ )
+ }
+ ),
+ cmd!("object.content", "Content", ["Object", "Content"], None, "{type: graphic|text|unassigned, ids?}", has_selection, content_type),
+ cmd!(
+ "object.textFrameOptions",
+ "Text Frame Options…",
+ ["Object"],
+ Some("Cmd+B"),
+ "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, ids?}",
+ has_selection,
+ text_frame_options
+ ),
+ cmd!(
+ "object.cornerOptions",
+ "Corner Options…",
+ ["Object"],
+ None,
+ "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, ids?}",
+ has_selection,
+ |s, p| {
+ let shape =
+ p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_geom::corners::CornerShape::Rounded);
+ let size = f64_or(p, "size", 12.0);
+ set_flag(s, p, move |i| i.corners = designcraft_geom::corners::CornerOptions::uniform(shape, size), false)
+ }
+ ),
+ cmd!(
+ "object.textWrap",
+ "Text Wrap",
+ ["Window"],
+ Some("Cmd+Alt+W"),
+ "{mode: none|boundingBox|contour|jumpObject|jumpToNextColumn, offset?: number|[t,l,b,r], ids?}",
+ has_selection,
+ |s, p| {
+ let mode = p.get("mode").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_doc::WrapMode::BoundingBox);
+ let off = match p.get("offset") {
+ Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
+ Some(v) => serde_json::from_value(v.clone()).ok(),
+ None => None,
+ };
+ set_flag(
+ s,
+ p,
+ move |i| {
+ i.wrap.mode = mode;
+ if let Some(o) = off {
+ i.wrap.offsets = o;
+ }
+ },
+ false,
+ )
+ }
+ ),
+ cmd!(
+ "object.fit",
+ "Fitting",
+ ["Object", "Fitting"],
+ None,
+ "{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}",
+ has_selection,
+ fit
+ ),
+ cmd!("object.rename", "Rename", [], None, "{id, name}", has_doc, |s, p| {
+ let name = str_param(p, "name").unwrap_or("").to_string();
+ set_flag(s, p, move |i| i.name = name.clone(), false)
+ }),
+ cmd!("object.setLayer", "Move to Layer", [], None, "{layer, ids?}", has_selection, |s, p| {
+ let l = designcraft_doc::LayerId(p.get("layer").and_then(Value::as_u64).unwrap_or(0));
+ set_flag(s, p, move |i| i.layer = l, false)
+ }),
+ ]
+}
+
+fn frame_create(s: &mut Session, p: &Value) -> Result {
+ let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?;
+ let shape = str_param(p, "shape").unwrap_or("rectangle");
+ let content = str_param(p, "content").unwrap_or("graphic");
+ let sr = spread_param(p, "spread");
+ let lid = s.doc()?.active_layer;
+ let text = str_param(p, "text").unwrap_or("").to_string();
+ let caret = bool_or(p, "caret", content == "text");
+ let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 100) as u32;
+ let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
+ s.edit(|d, sel| {
+ if d.spread(sr).is_none() {
+ return Err(bad("frame.create", "no such spread"));
+ }
+ let (path, sh) = match shape {
+ "ellipse" | "oval" => (shapes::ellipse(rect), Shape::Oval),
+ "polygon" => (polygon_in(rect, sides), Shape::Polygon),
+ _ => (shapes::rectangle(rect), Shape::Rectangle),
+ };
+ if content == "text" {
+ let (id, sid) = d.add_text_frame(sr, rect, lid, &text, ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() })?;
+ if let Some(it) = d.item_mut(id) {
+ it.path = path;
+ it.shape = sh;
+ }
+ *sel = if caret {
+ Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id) })
+ } else {
+ Selection::items(vec![id])
+ };
+ return Ok(json!({"id": id.0, "story": sid.0}));
+ }
+ let id = ItemId(d.alloc());
+ let mut it = Item::new(id, lid, sh, path);
+ if content == "graphic" {
+ it.object_style = d.styles.default_graphic_frame.clone();
+ } else {
+ // Shapes drawn with Rectangle/Ellipse/Polygon get the default 1 pt black stroke.
+ it.stroke = Stroke::default();
+ }
+ d.insert_item(sr, it, None)?;
+ *sel = Selection::items(vec![id]);
+ Ok(json!({"id": id.0}))
+ })
+}
+
+fn polygon_in(r: Rect, sides: u32) -> designcraft_geom::PathData {
+ let c = r.center();
+ let mut p = shapes::polygon(Point::ZERO, 1.0, sides, 0.0);
+ // Fit the unit polygon's bounds to the rect.
+ if let Some(b) = p.bounds() {
+ let a = Affine::translate(c.to_vec2())
+ * Affine::scale_non_uniform(r.width() / b.width().max(1e-9), r.height() / b.height().max(1e-9))
+ * Affine::translate(-b.center().to_vec2());
+ p.transform(a);
+ }
+ p
+}
+
+fn line_create(s: &mut Session, p: &Value) -> Result {
+ let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?;
+ let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?;
+ let sr = spread_param(p, "spread");
+ let lid = s.doc()?.active_layer;
+ s.edit(|d, sel| {
+ let id = ItemId(d.alloc());
+ let mut it = Item::new(id, lid, Shape::GraphicLine, shapes::line(a, b));
+ it.stroke = Stroke::default();
+ d.insert_item(sr, it, None)?;
+ *sel = Selection::items(vec![id]);
+ Ok(json!({"id": id.0}))
+ })
+}
+
+/// Deep-copy items `ids` from `src` into `dst` spread `to` (fresh ids; text frames get story copies).
+pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: SpreadRef, off: Vec2) -> Result> {
+ let mut out = Vec::new();
+ let mut story_map: HashMap = HashMap::new();
+ for id in ids {
+ let Some(it) = src.item(*id) else { continue };
+ let mut copy = it.clone();
+ copy.xf = Affine::translate(off) * copy.xf;
+ renumber(dst, src, &mut copy, &mut story_map);
+ if dst.layer(copy.layer).is_none() {
+ copy.layer = dst.default_layer();
+ }
+ let nid = copy.id;
+ dst.insert_item(to, copy, None)?;
+ out.push(nid);
+ }
+ Ok(out)
+}
+
+fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) {
+ it.id = ItemId(dst.alloc());
+ let my_id = it.id;
+ match &mut it.content {
+ Content::Text(tf) => {
+ let new_sid = match stories.get(&tf.story) {
+ Some(n) => *n,
+ None => {
+ let n = StoryId(dst.alloc());
+ let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
+ st.id = n;
+ st.frames.clear();
+ dst.stories.insert(n, Arc::new(st));
+ stories.insert(tf.story, n);
+ n
+ }
+ };
+ tf.story = new_sid;
+ if let Some(st) = dst.story_mut(new_sid) {
+ st.frames.push(my_id);
+ }
+ }
+ Content::Graphic(g) => {
+ if let Some(a) = src.assets.get(&g.asset)
+ && !dst.assets.contains_key(&g.asset)
+ {
+ dst.assets.insert(g.asset, a.clone());
+ }
+ }
+ Content::Group { items } => {
+ for c in items.iter_mut() {
+ renumber(dst, src, Arc::make_mut(c), stories);
+ }
+ }
+ Content::Unassigned => {}
+ }
+}
+
+fn transform_move(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
+ let copy = bool_or(p, "copy", false);
+ let to: Option = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok());
+ s.edit(|d, sel| {
+ let ids = if copy {
+ let src = d.clone();
+ let mut new = Vec::new();
+ for id in &ids {
+ let sr = d.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
+ new.extend(duplicate_from(d, &src, &[*id], to.unwrap_or(sr), Vec2::ZERO)?);
+ }
+ *sel = Selection::items(new.clone());
+ new
+ } else {
+ ids
+ };
+ for id in &ids {
+ if let Some(target) = to
+ && !copy
+ && let Some(loc) = d.find(*id)
+ && loc.spread != target
+ && loc.path.len() == 1
+ {
+ let it = d.remove_item_keep_story(*id)?;
+ d.insert_item(target, it, None)?;
+ }
+ let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
+ if it.locked {
+ continue;
+ }
+ it.xf = Affine::translate((dx, dy)) * it.xf;
+ }
+ Ok(json!({"moved": ids.len()}))
+ })
+}
+
+fn transform_resize(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?;
+ let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
+ let scale_content = bool_or(p, "content", false);
+ if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 {
+ return Err(bad("transform.resize", "degenerate source rect"));
+ }
+ let sx = to.width() / from.width();
+ let sy = to.height() / from.height();
+ let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
+ let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
+ let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
+ s.edit(|d, _| {
+ for id in &ids {
+ let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
+ if it.locked {
+ continue;
+ }
+ if scale_content || matches!(it.content, Content::Group { .. }) {
+ it.xf = m * it.xf;
+ } else {
+ // Resize the frame's path (in inner space); content keeps its size (InDesign's default).
+ let inner = it.xf.inverse() * m * it.xf;
+ it.path.transform(inner);
+ }
+ }
+ Ok(json!({"resized": ids.len()}))
+ })
+}
+
+fn apply_about_center(s: &mut Session, p: &Value, a: Affine) -> Result {
+ let ids = targets(s, p)?;
+ s.edit(|d, _| {
+ let mut b: Option = None;
+ for id in &ids {
+ if let Some(it) = d.item(*id) {
+ b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds())));
+ }
+ }
+ let c = b.unwrap_or(Rect::ZERO).center();
+ let m = Affine::translate(c.to_vec2()) * a * Affine::translate(-c.to_vec2());
+ for id in &ids {
+ if let Some(it) = d.item_mut(*id) {
+ it.xf = m * it.xf;
+ }
+ }
+ ok()
+ })
+}
+
+fn transform_rotate(s: &mut Session, p: &Value) -> Result {
+ let a = f64_or(p, "angle", 0.0);
+ apply_about_center(s, p, Affine::rotate(-a.to_radians()))
+}
+
+fn transform_set(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(0) as usize;
+ s.edit(|d, _| {
+ let mut b: Option = None;
+ for id in &ids {
+ if let Some(it) = d.item(*id) {
+ b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds())));
+ }
+ }
+ let Some(from) = b else { return ok() };
+ let w = f64_or(p, "width", from.width()).max(0.01);
+ let h = f64_or(p, "height", from.height()).max(0.01);
+ let anchor = designcraft_geom::reference_point(from, rf);
+ // New rect keeping the reference point fixed, then moved so the ref point lands on x/y.
+ let fx = [0.0, 0.5, 1.0][rf % 3];
+ let fy = [0.0, 0.5, 1.0][(rf / 3).min(2)];
+ let mut to = Rect::new(anchor.x - fx * w, anchor.y - fy * h, anchor.x - fx * w + w, anchor.y - fy * h + h);
+ // x/y are the reference point position relative to the page origin of the first page in the spread.
+ let page_x = ids
+ .first()
+ .and_then(|i| d.find(*i))
+ .and_then(|l| {
+ d.spread(l.spread).map(|sp| {
+ let pi = sp.page_at_x(from.center().x).unwrap_or(0);
+ sp.pages[pi].x
+ })
+ })
+ .unwrap_or(0.0);
+ if let Some(x) = p.get("x").and_then(Value::as_f64) {
+ to = to + Vec2::new(x + page_x - anchor.x, 0.0);
+ }
+ if let Some(y) = p.get("y").and_then(Value::as_f64) {
+ to = to + Vec2::new(0.0, y - anchor.y);
+ }
+ let m = Affine::translate((to.x0, to.y0))
+ * Affine::scale_non_uniform(to.width() / from.width().max(1e-9), to.height() / from.height().max(1e-9))
+ * Affine::translate((-from.x0, -from.y0));
+ for id in &ids {
+ if let Some(it) = d.item_mut(*id) {
+ if matches!(it.content, Content::Group { .. }) {
+ it.xf = m * it.xf;
+ } else {
+ let inner = it.xf.inverse() * m * it.xf;
+ it.path.transform(inner);
+ }
+ }
+ }
+ ok()
+ })
+}
+
+fn arrange(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let to = str_param(p, "to").unwrap_or("front").to_string();
+ s.edit(|d, _| {
+ for id in &ids {
+ let Some(loc) = d.find(*id) else { continue };
+ if loc.path.len() != 1 {
+ continue;
+ }
+ let sp = d.spread_mut(loc.spread).expect("found");
+ let i = loc.path[0];
+ let n = sp.items.len();
+ let it = sp.items.remove(i);
+ let j = match to.as_str() {
+ "front" => n - 1,
+ "back" => 0,
+ "forward" => (i + 1).min(n - 1),
+ _ => i.saturating_sub(1),
+ };
+ sp.items.insert(j, it);
+ }
+ ok()
+ })
+}
+
+fn group(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ if ids.len() < 2 {
+ return Err(bad("object.group", "select at least two objects"));
+ }
+ s.edit(|d, sel| {
+ let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
+ // Keep stacking order: collect in z-order.
+ let order: Vec = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default();
+ let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0);
+ let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default();
+ let mut kids = Vec::new();
+ for id in &order {
+ kids.push(d.remove_item_keep_story(*id)?);
+ }
+ let gid = ItemId(d.alloc());
+ let mut g = Item::new(gid, layer, Shape::Group, designcraft_geom::PathData::default());
+ g.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() };
+ let at = front + 1 - order.len();
+ d.insert_item(sr, g, Some(at))?;
+ *sel = Selection::items(vec![gid]);
+ Ok(json!({"id": gid.0}))
+ })
+}
+
+fn ungroup(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ s.edit(|d, sel| {
+ let mut out = Vec::new();
+ for id in &ids {
+ let Some(loc) = d.find(*id) else { continue };
+ let Some(g) = d.item(*id).cloned() else { continue };
+ let Content::Group { items } = &g.content else { continue };
+ if loc.path.len() != 1 {
+ continue;
+ }
+ let sp = d.spread_mut(loc.spread).expect("found");
+ sp.items.remove(loc.path[0]);
+ for (k, c) in items.iter().enumerate() {
+ let mut c = (**c).clone();
+ c.xf = g.xf * c.xf;
+ out.push(c.id);
+ sp.items.insert(loc.path[0] + k, Arc::new(c));
+ }
+ }
+ *sel = Selection::items(out);
+ ok()
+ })
+}
+
+fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result {
+ let ids = targets(s, p)?;
+ s.edit(|d, sel| {
+ for id in &ids {
+ if let Some(it) = d.item_mut(*id) {
+ f(it);
+ }
+ }
+ if deselect {
+ *sel = Selection::default();
+ }
+ Ok(json!({"changed": ids.len()}))
+ })
+}
+
+fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result {
+ s.edit(|d, _| {
+ for i in 0..d.spreads.len() {
+ let sp = Arc::make_mut(&mut d.spreads[i]);
+ for it in &mut sp.items {
+ f(Arc::make_mut(it));
+ }
+ }
+ ok()
+ })
+}
+
+fn content_type(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let t = str_param(p, "type").unwrap_or("unassigned").to_string();
+ s.edit(|d, _| {
+ for id in &ids {
+ let cur = d.item(*id).map(|i| i.content.clone());
+ match (t.as_str(), cur) {
+ ("text", Some(Content::Unassigned | Content::Graphic(_))) => {
+ let sid = StoryId(d.alloc());
+ let mut st = Story::new(sid);
+ st.frames.push(*id);
+ d.stories.insert(sid, Arc::new(st));
+ if let Some(it) = d.item_mut(*id) {
+ it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
+ }
+ }
+ ("graphic" | "unassigned", Some(Content::Text(tf))) => {
+ let empty = d.story(tf.story).map(|s| s.frames.len() == 1).unwrap_or(true);
+ if let Some(st) = d.story_mut(tf.story) {
+ st.frames.retain(|f| f != id);
+ }
+ if empty {
+ d.stories.remove(&tf.story);
+ }
+ if let Some(it) = d.item_mut(*id) {
+ it.content = Content::Unassigned;
+ }
+ }
+ ("graphic" | "unassigned", Some(Content::Graphic(_))) if t == "unassigned" => {
+ if let Some(it) = d.item_mut(*id) {
+ it.content = Content::Unassigned;
+ }
+ }
+ _ => {}
+ }
+ }
+ ok()
+ })
+}
+
+fn text_frame_options(s: &mut Session, p: &Value) -> Result {
+ let st = s.doc()?;
+ let mut ids = targets(s, p)?;
+ if ids.is_empty()
+ && let Some(t) = st.selection.text
+ {
+ ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default();
+ }
+ let p = p.clone();
+ s.edit(|d, _| {
+ for id in &ids {
+ let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
+ let o = &mut tf.options;
+ if let Some(v) = p.get("columns").and_then(Value::as_u64) {
+ o.columns = (v as u32).clamp(1, 40);
+ }
+ if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
+ o.gutter = v.max(0.0);
+ }
+ match p.get("inset") {
+ Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4],
+ Some(v @ Value::Array(_)) => {
+ if let Ok(a) = serde_json::from_value(v.clone()) {
+ o.inset = a;
+ }
+ }
+ _ => {}
+ }
+ for (k, which) in [("verticalJustification", 0), ("firstBaseline", 1), ("autoSize", 2), ("columnsKind", 3)] {
+ if let Some(v) = p.get(k).cloned() {
+ let _ = match which {
+ 0 => serde_json::from_value(v).map(|x| o.vertical_justification = x),
+ 1 => serde_json::from_value(v).map(|x| o.first_baseline = x),
+ 2 => serde_json::from_value(v).map(|x| o.auto_size = x),
+ _ => serde_json::from_value(v).map(|x| o.columns_kind = x),
+ };
+ }
+ }
+ if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) {
+ o.ignore_wrap = v;
+ }
+ if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) {
+ o.balance_columns = v;
+ }
+ if let Some(v) = p.get("columnRule").and_then(Value::as_bool) {
+ o.column_rule = v;
+ }
+ }
+ Ok(json!({"changed": ids.len()}))
+ })
+}
+
+fn fit(s: &mut Session, p: &Value) -> Result {
+ let ids = targets(s, p)?;
+ let mode = str_param(p, "mode").unwrap_or("fitProportionally").to_string();
+ s.edit(|d, _| {
+ for id in &ids {
+ let Some(it) = d.item_mut(*id) else { continue };
+ let r = it.inner_bounds();
+ let Content::Graphic(g) = &mut it.content else { continue };
+ let (nw, nh) = g.size;
+ let cur = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh));
+ match mode.as_str() {
+ "fillProportionally" | "fitProportionally" => {
+ let k = if mode == "fillProportionally" { (r.width() / nw).max(r.height() / nh) } else { (r.width() / nw).min(r.height() / nh) };
+ g.xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k);
+ }
+ "fitContentToFrame" => g.xf = Affine::translate((r.x0, r.y0)) * Affine::scale_non_uniform(r.width() / nw, r.height() / nh),
+ "centerContent" => g.xf = Affine::translate(r.center() - cur.center()) * g.xf,
+ "fitFrameToContent" => it.path = shapes::rectangle(cur),
+ _ => return Err(bad("object.fit", format!("unknown mode `{mode}`"))),
+ }
+ }
+ ok()
+ })
+}
+
+trait RemoveKeep {
+ fn remove_item_keep_story(&mut self, id: ItemId) -> Result- ;
+}
+
+/// Detach an item without touching story threads (moving between spreads, grouping).
+impl RemoveKeep for Document {
+ fn remove_item_keep_story(&mut self, id: ItemId) -> Result
- {
+ let loc = self.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
+ if loc.path.len() != 1 {
+ return Err(bad("object", "nested items can't be detached"));
+ }
+ let sp = self.spread_mut(loc.spread).expect("found");
+ Ok(Arc::unwrap_or_clone(sp.items.remove(loc.path[0])))
+ }
+}
diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs
new file mode 100644
index 00000000..21b12d2a
--- /dev/null
+++ b/crates/engine/src/cmd/style.rs
@@ -0,0 +1,278 @@
+//! Paragraph / character styles and swatches.
+
+use designcraft_color::{Swatch, SwatchValue};
+use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param};
+use crate::{Result, Session};
+
+pub fn specs() -> Vec
{
+ vec![
+ cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para),
+ cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char),
+ cmd!(
+ "style.paragraph.create",
+ "New Paragraph Style…",
+ [],
+ None,
+ "{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}",
+ has_doc,
+ create_para
+ ),
+ cmd!(
+ "style.paragraph.edit",
+ "Paragraph Style Options…",
+ [],
+ None,
+ "{name, rename?, basedOn?, nextStyle?, para?: {…}, chars?: {…}}",
+ has_doc,
+ edit_para
+ ),
+ cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
+ cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
+ cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
+ cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
+ let st = s.doc()?;
+ Ok(json!({
+ "paragraph": st.doc.styles.paragraph.iter().map(|p| &p.name).collect::>(),
+ "character": st.doc.styles.character.iter().map(|p| &p.name).collect::>(),
+ "object": st.doc.styles.object.iter().map(|p| &p.name).collect::>(),
+ }))
+ }),
+ cmd!(
+ "swatch.create",
+ "New Color Swatch…",
+ [],
+ None,
+ "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}",
+ has_doc,
+ create_swatch
+ ),
+ cmd!("swatch.delete", "Delete Swatch", [], None, "{name}", has_doc, |s, p| {
+ let name = str_param(p, "name").unwrap_or("").to_string();
+ s.edit(|d, _| {
+ if d.swatch(&name).is_some_and(|w| w.locked) {
+ return Err(bad("swatch.delete", "special swatches can't be deleted"));
+ }
+ d.swatches.retain(|w| w.name != name);
+ ok()
+ })
+ }),
+ ]
+}
+
+fn attrs(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result {
+ let mut a = T::default();
+ if let Some(o) = v.and_then(Value::as_object) {
+ for (k, v) in o {
+ set(&mut a, k, v).map_err(|e| bad("style", e))?;
+ }
+ }
+ Ok(a)
+}
+
+fn apply_para(s: &mut Session, p: &Value) -> Result {
+ let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
+ let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
+ if s.doc()?.doc.styles.para(&name).is_none() {
+ return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`")));
+ }
+ let targets = super::text::format_targets_pub(s);
+ s.edit(|d, _| {
+ for (sid, r) in &targets {
+ if let Some(st) = d.story_mut(*sid) {
+ st.format_paras(r.clone(), |f| {
+ f.style = name.clone();
+ if clear {
+ f.para = ParaAttrs::default();
+ f.chars = CharAttrs::default();
+ }
+ });
+ if clear {
+ let (a, b) = (st.para_ranges()[st.para_at(r.start)].start, st.para_ranges()[st.para_at(r.end)].end);
+ st.format_chars(a..b, |f| f.over = CharAttrs::default());
+ }
+ }
+ }
+ ok()
+ })
+}
+
+fn apply_char(s: &mut Session, p: &Value) -> Result {
+ let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string();
+ if s.doc()?.doc.styles.char_style(&name).is_none() {
+ return Err(bad("style.character.apply", format!("no character style `{name}`")));
+ }
+ let targets = super::text::format_targets_pub(s);
+ s.edit(|d, _| {
+ for (sid, r) in &targets {
+ if let Some(st) = d.story_mut(*sid) {
+ st.format_chars(r.clone(), |f| f.style = name.clone());
+ }
+ }
+ ok()
+ })
+}
+
+fn create_para(s: &mut Session, p: &Value) -> Result {
+ let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
+ let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
+ let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
+ if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
+ && let Ok(cur) = s.execute("type.selectionAttrs", &json!({}))
+ && !cur.is_null()
+ {
+ if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) {
+ let mut full = ParaProps_to_attrs(&pp);
+ full.merge(¶);
+ para = full;
+ }
+ if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) {
+ let mut full = CharProps_to_attrs(&cp);
+ full.merge(&chars);
+ chars = full;
+ }
+ }
+ let based_on = str_param(p, "basedOn").map(str::to_string);
+ let next = str_param(p, "nextStyle").map(str::to_string);
+ s.edit(|d, _| {
+ let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base);
+ if let Some(b) = &based_on
+ && d.styles.para(b).is_none()
+ {
+ return Err(bad("style.paragraph.create", format!("no style `{b}`")));
+ }
+ d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() });
+ Ok(json!({"name": name}))
+ })
+}
+
+#[allow(non_snake_case)]
+fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs {
+ designcraft_doc::ParaProps::common([p])
+}
+#[allow(non_snake_case)]
+fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
+ designcraft_doc::CharProps::common([p])
+}
+
+fn edit_para(s: &mut Session, p: &Value) -> Result {
+ let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
+ let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
+ let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
+ let rename = str_param(p, "rename").map(str::to_string);
+ let based = p.get("basedOn").cloned();
+ s.edit(|d, _| {
+ if let Some(Value::String(b)) = &based
+ && d.styles.para_based_on_cycles(&name, b)
+ {
+ return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
+ }
+ let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
+ st.para.merge(¶);
+ st.chars.merge(&chars);
+ match based {
+ Some(Value::String(b)) => st.based_on = Some(b),
+ Some(Value::Null) => st.based_on = None,
+ _ => {}
+ }
+ if let Some(n) = rename.clone() {
+ st.name = n.clone();
+ for sid in d.stories.keys().copied().collect::>() {
+ if let Some(story) = d.story_mut(sid) {
+ for f in &mut story.paras {
+ if f.style == name {
+ f.style = n.clone();
+ }
+ }
+ }
+ }
+ }
+ ok()
+ })
+}
+
+fn delete_para(s: &mut Session, p: &Value) -> Result {
+ let name = str_param(p, "name").unwrap_or("").to_string();
+ let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string();
+ if name.starts_with('[') {
+ return Err(bad("style.paragraph.delete", "built-in styles can't be deleted"));
+ }
+ s.edit(|d, _| {
+ d.styles_mut().paragraph.retain(|x| x.name != name);
+ for sid in d.stories.keys().copied().collect::>() {
+ if let Some(story) = d.story_mut(sid) {
+ for f in &mut story.paras {
+ if f.style == name {
+ f.style = repl.clone();
+ }
+ }
+ }
+ }
+ ok()
+ })
+}
+
+fn create_char(s: &mut Session, p: &Value) -> Result {
+ let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
+ let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
+ let based_on = str_param(p, "basedOn").map(str::to_string);
+ s.edit(|d, _| {
+ let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
+ d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
+ Ok(json!({"name": name}))
+ })
+}
+
+fn edit_char(s: &mut Session, p: &Value) -> Result {
+ let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
+ let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
+ s.edit(|d, _| {
+ let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
+ st.chars.merge(&chars);
+ ok()
+ })
+}
+
+fn create_swatch(s: &mut Session, p: &Value) -> Result {
+ let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
+ let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
+ let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false);
+ let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| match color {
+ designcraft_color::Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k),
+ other => {
+ let [r, g, b] = other.to_rgb();
+ format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
+ }
+ });
+ s.edit(|d, _| {
+ let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name);
+ d.swatches.push(Swatch {
+ name: name.clone(),
+ value: SwatchValue::Color {
+ color,
+ color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process },
+ },
+ locked: false,
+ named: true,
+ });
+ Ok(json!({"name": name}))
+ })
+}
+
+pub fn parse_color(v: &Value) -> Option {
+ use designcraft_color::Color;
+ match v {
+ Value::String(s) => Color::from_hex(s),
+ Value::Array(a) if a.len() >= 3 => {
+ let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
+ Some(Color::rgb(f(0)?, f(1)?, f(2)?))
+ }
+ Value::Object(o) => {
+ let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
+ Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
+ }
+ _ => None,
+ }
+}
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
new file mode 100644
index 00000000..3f685cec
--- /dev/null
+++ b/crates/engine/src/cmd/text.rs
@@ -0,0 +1,469 @@
+//! Text editing (Type tool) and character/paragraph formatting.
+
+use designcraft_compose as compose;
+use designcraft_doc::{CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
+use designcraft_geom::Point;
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param};
+use crate::{Result, Session};
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(noundo "text.placeCaret", "Place Caret", [], None, "{frame, point: [x,y] (spread coords)} — converts empty frames to text frames", has_doc, |s, p| place(s, p, false)),
+ cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
+ cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
+ place(s, p, false)?;
+ let st = s.doc_mut()?;
+ let Some(t) = st.selection.text else { return ok() };
+ let text = &st.doc.story(t.story).map(|x| x.text.clone()).unwrap_or_default();
+ let (a, b) = word_bounds(text, t.focus);
+ st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
+ ok()
+ }),
+ cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus}", has_doc, |s, p| {
+ let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
+ let st = s.doc_mut()?;
+ let len = st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.len();
+ let a = (p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize).min(len);
+ let f = (p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)).min(len);
+ st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None });
+ st.revision += 1;
+ ok()
+ }),
+ cmd!("text.insert", "Type", [], None, "{text} — replaces the selected text", has_text, insert),
+ cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
+ cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
+ cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
+ let st = s.doc_mut()?;
+ if let Some(t) = st.selection.text.take() {
+ let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
+ st.selection.items = f.into_iter().collect();
+ }
+ st.revision += 1;
+ ok()
+ }),
+ cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
+ let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
+ let text = str_param(p, "text").unwrap_or("").to_string();
+ s.edit(|d, sel| {
+ d.set_story_text(sid, &text)?;
+ if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
+ t.anchor = t.anchor.min(text.len());
+ t.focus = t.focus.min(text.len());
+ }
+ ok()
+ })
+ }),
+ cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| {
+ let st = s.doc()?;
+ let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
+ let cs = s.cache.get(&st.doc, sid, None);
+ let mut v = st.doc.story_summary(sid).unwrap_or_default();
+ v["overset"] = json!(cs.overset_at);
+ v["lines"] = json!(cs.line_count());
+ Ok(v)
+ }),
+ cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?}", has_text_or_frames, fill_placeholder),
+ cmd!(
+ "type.char",
+ "Character Formatting",
+ [],
+ None,
+ "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
+ has_text_or_frames,
+ |s, p| format_chars(s, p.get("attrs").unwrap_or(p))
+ ),
+ cmd!(
+ "type.para",
+ "Paragraph Formatting",
+ [],
+ None,
+ "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}",
+ has_text_or_frames,
+ |s, p| format_paras(s, p.get("attrs").unwrap_or(p))
+ ),
+ cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…}", has_text_or_frames, |s, p| {
+ format_paras(s, &json!({"align": p.get("align").cloned().unwrap_or(json!("left"))}))
+ }),
+ cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))),
+ cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras(
+ s,
+ &json!({"align": "center"})
+ )),
+ cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))),
+ cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras(
+ s,
+ &json!({"align": "leftJustified"})
+ )),
+ cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
+ cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
+ cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
+ cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
+ s,
+ &json!({"capitalization": "allCaps"})
+ )),
+ cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
+ cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
+ cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
+ ]
+}
+
+fn story_of(s: &Session, p: &Value) -> Option {
+ let st = s.active()?;
+ if let Some(v) = p.get("story").and_then(Value::as_u64) {
+ return Some(StoryId(v));
+ }
+ let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied());
+ if let Some(f) = frame {
+ return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story);
+ }
+ st.selection.text.map(|t| t.story)
+}
+
+/// Story byte at spread point `pt` in text frame `frame`.
+fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
+ let st = s.active()?;
+ let loc = st.doc.find(frame)?;
+ let it = st.doc.item_at(&loc)?;
+ let sid = it.text_frame()?.story;
+ let xf = st.doc.parent_xf(&loc) * it.xf;
+ let inner = xf.inverse() * pt;
+ let cs = s.cache.get(&st.doc, sid, None);
+ let fi = cs.frames.iter().position(|f| f.frame == frame)?;
+ let b = compose::hit(&cs, fi, inner).unwrap_or(0);
+ Some((sid, b))
+}
+
+fn place(s: &mut Session, p: &Value, extend: bool) -> Result {
+ let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
+ let pt = point_param(p, "point").unwrap_or(Point::ZERO);
+ // Empty frames become text frames.
+ let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned));
+ if needs_convert {
+ s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
+ }
+ let (sid, b) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
+ let st = s.doc_mut()?;
+ let t = match (extend, st.selection.text) {
+ (true, Some(t)) if t.story == sid => TextSel { focus: b, frame: Some(frame), ..t },
+ _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame) },
+ };
+ st.selection = Selection::text(t);
+ st.revision += 1;
+ Ok(json!({"story": sid.0, "pos": b}))
+}
+
+fn insert(s: &mut Session, p: &Value) -> Result {
+ let text = str_param(p, "text").unwrap_or("").to_string();
+ let text = if s.prefs.typographers_quotes { smart_quotes(s, &text) } else { text };
+ s.edit(|d, sel| {
+ let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
+ let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
+ let r = t.range();
+ let r = r.start.min(st.len())..r.end.min(st.len());
+ st.replace(r.clone(), &text);
+ let pos = r.start + text.len();
+ sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
+ Ok(json!({"pos": pos}))
+ })
+}
+
+fn smart_quotes(s: &Session, text: &str) -> String {
+ if !text.contains(['"', '\'']) {
+ return text.to_string();
+ }
+ let prev = s
+ .active()
+ .and_then(|st| {
+ let t = st.selection.text?;
+ let story = st.doc.story(t.story)?;
+ story.text[..t.range().start.min(story.len())].chars().last()
+ })
+ .unwrap_or(' ');
+ let mut out = String::with_capacity(text.len());
+ let mut last = prev;
+ for c in text.chars() {
+ let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}');
+ let r = match c {
+ '"' => {
+ if open {
+ '\u{201C}'
+ } else {
+ '\u{201D}'
+ }
+ }
+ '\'' => {
+ if open {
+ '\u{2018}'
+ } else {
+ '\u{2019}'
+ }
+ }
+ c => c,
+ };
+ out.push(r);
+ last = c;
+ }
+ out
+}
+
+pub(crate) fn delete_selection(s: &mut Session) -> Result {
+ s.edit(|d, sel| {
+ let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
+ let r = t.range();
+ if let Some(st) = d.story_mut(t.story) {
+ st.delete(r.clone());
+ }
+ sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
+ ok()
+ })
+}
+
+fn delete(s: &mut Session, p: &Value) -> Result {
+ let forward = bool_or(p, "forward", false);
+ let word = bool_or(p, "word", false);
+ s.edit(|d, sel| {
+ let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
+ let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
+ let r = t.range();
+ let r = if !r.is_empty() {
+ r
+ } else if forward {
+ let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) };
+ r.start..end
+ } else {
+ let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
+ start..r.start
+ };
+ st.delete(r.clone());
+ sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
+ ok()
+ })
+}
+
+pub fn next_char(s: &str, i: usize) -> usize {
+ s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
+}
+pub fn prev_char(s: &str, i: usize) -> usize {
+ s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0)
+}
+fn is_word(c: char) -> bool {
+ c.is_alphanumeric() || c == '\'' || c == '’'
+}
+pub fn next_word(s: &str, i: usize) -> usize {
+ let mut j = i;
+ let mut seen = false;
+ for (k, c) in s[i..].char_indices() {
+ if is_word(c) {
+ seen = true;
+ } else if seen {
+ return i + k;
+ }
+ j = i + k + c.len_utf8();
+ }
+ j
+}
+pub fn prev_word(s: &str, i: usize) -> usize {
+ let mut seen = false;
+ for (k, c) in s[..i].char_indices().rev() {
+ if is_word(c) {
+ seen = true;
+ } else if seen {
+ return k + c.len_utf8();
+ }
+ }
+ 0
+}
+fn word_bounds(s: &str, i: usize) -> (usize, usize) {
+ let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
+ let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
+ (a, b)
+}
+
+fn move_caret(s: &mut Session, p: &Value) -> Result {
+ let dir = str_param(p, "dir").unwrap_or("right").to_string();
+ let extend = bool_or(p, "extend", false);
+ let word = bool_or(p, "word", false);
+ let st = s.doc()?;
+ let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
+ let story = st.doc.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
+ let text = story.text.clone();
+ let cs = s.cache.get(&st.doc, t.story, None);
+ let pos = t.focus.min(text.len());
+ let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
+ let new = match dir.as_str() {
+ "left" if !extend && !t.is_caret() => collapse_to(true),
+ "right" if !extend && !t.is_caret() => collapse_to(false),
+ "left" => {
+ if word {
+ prev_word(&text, pos)
+ } else {
+ prev_char(&text, pos)
+ }
+ }
+ "right" => {
+ if word {
+ next_word(&text, pos)
+ } else {
+ next_char(&text, pos)
+ }
+ }
+ "up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos),
+ "lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0),
+ "lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()),
+ "storyStart" => 0,
+ "storyEnd" => text.len(),
+ other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
+ };
+ let stm = s.doc_mut()?;
+ stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
+ stm.revision += 1;
+ Ok(json!({"pos": new}))
+}
+
+fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
+ cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end))
+}
+
+fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option {
+ let (fi, x, baseline, _, _) = compose::caret(cs, pos)?;
+ let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect();
+ let cur = lines.iter().position(|(i, l)| *i == fi && (l.baseline - baseline).abs() < 0.01 && pos >= l.range.start && pos <= l.range.end)?;
+ let target = if up { cur.checked_sub(1)? } else { cur + 1 };
+ let (tf, tl) = lines.get(target)?;
+ compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
+}
+
+/// Ranges and stories a formatting command applies to: the text selection, or whole stories of
+/// the selected text frames.
+fn format_targets(s: &Session) -> Vec<(StoryId, std::ops::Range)> {
+ let Some(st) = s.active() else { return vec![] };
+ if let Some(t) = st.selection.text {
+ return vec![(t.story, t.range())];
+ }
+ let mut v = Vec::new();
+ for id in &st.selection.items {
+ if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) {
+ let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0);
+ if !v.iter().any(|(s, _)| *s == tf.story) {
+ v.push((tf.story, 0..len));
+ }
+ }
+ }
+ v
+}
+
+pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result {
+ let mut a = CharAttrs::default();
+ if let Some(o) = attrs.as_object() {
+ for (k, v) in o {
+ a.set_json(k, v).map_err(|e| bad("type.char", e))?;
+ }
+ }
+ let targets = format_targets(s);
+ s.edit(|d, _| {
+ for (sid, r) in &targets {
+ let Some(st) = d.story_mut(*sid) else { continue };
+ if r.is_empty() {
+ // Caret: change the typing format (the empty run at the caret).
+ let pos = r.start;
+ if st.is_empty() {
+ st.chars[0].format.over.merge(&a);
+ st.rev += 1;
+ } else {
+ let _ = pos;
+ }
+ continue;
+ }
+ st.format_chars(r.clone(), |f| f.over.merge(&a));
+ }
+ ok()
+ })
+}
+
+pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result {
+ let mut a = ParaAttrs::default();
+ if let Some(o) = attrs.as_object() {
+ for (k, v) in o {
+ a.set_json(k, v).map_err(|e| bad("type.para", e))?;
+ }
+ }
+ let targets = format_targets(s);
+ s.edit(|d, _| {
+ for (sid, r) in &targets {
+ if let Some(st) = d.story_mut(*sid) {
+ st.format_paras(r.clone(), |p| p.para.merge(&a));
+ }
+ }
+ ok()
+ })
+}
+
+fn step_size(s: &mut Session, delta: f64) -> Result {
+ let cur = selection_attrs(s, &json!({}))?;
+ let size = cur["chars"]["size"].as_f64().unwrap_or(12.0);
+ format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)}))
+}
+
+fn selection_attrs(s: &mut Session, _p: &Value) -> Result {
+ let st = s.doc()?;
+ let targets = format_targets(s);
+ let Some((sid, r)) = targets.first().cloned() else { return Ok(Value::Null) };
+ let story = st.doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
+ let pi = story.para_at(r.start);
+ let pf = &story.paras[pi];
+ let (pp, base) = st.doc.styles.resolve_para(pf);
+ let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
+ let cp = st.doc.styles.resolve_char(&base, cf);
+ Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
+ "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
+}
+
+/// Our own filler text (not Adobe's).
+pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
+et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
+Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \
+dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \
+Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit.";
+
+fn fill_placeholder(s: &mut Session, p: &Value) -> Result {
+ let st = s.doc()?;
+ let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame));
+ let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) {
+ Some(t) => t.story,
+ None => {
+ if let Some(f) = frame {
+ s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?;
+ }
+ story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))?
+ }
+ };
+ // Fill until overset: repeat the filler sentences.
+ let mut text = String::new();
+ let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect();
+ let doc = s.doc()?.doc.clone();
+ let mut d2 = (*doc).clone();
+ for i in 0..400 {
+ text.push_str(sentences[i % sentences.len()]);
+ if i % 3 == 2 {
+ d2.set_story_text(sid, &text)?;
+ let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default());
+ if cs.is_overset() {
+ // Trim to the last sentence that fits.
+ let cut = cs.overset_at.unwrap_or(text.len());
+ let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut);
+ text.truncate(keep);
+ break;
+ }
+ }
+ }
+ s.edit(|d, _| {
+ d.set_story_text(sid, text.trim_end())?;
+ ok()
+ })
+}
+
+pub(crate) fn format_targets_pub(s: &Session) -> Vec<(StoryId, std::ops::Range)> {
+ format_targets(s)
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
new file mode 100644
index 00000000..88c923a6
--- /dev/null
+++ b/crates/engine/src/lib.rs
@@ -0,0 +1,252 @@
+//! The DesignCraft engine façade.
+//!
+//! Every user-visible action is a command with a stable id (`frame.create`, `text.insert`,
+//! `layout.pages.insert`…) and JSON parameters. The egui UI, the CLI, the control channel and MCP
+//! all go through [`Session::execute`]. Tools (pointer gestures) are hosted here and reduce to
+//! commands, so every gesture is journaled and replayable.
+#![forbid(unsafe_code)]
+
+pub mod cmd;
+pub mod sample;
+mod tooling;
+
+use std::sync::Arc;
+
+use designcraft_compose::Cache;
+use designcraft_doc::{Document, LayerId, Selection};
+use serde_json::Value;
+
+pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
+pub use designcraft_compose as compose;
+pub use designcraft_doc as doc;
+pub use designcraft_tools as tools;
+pub use tooling::{UiRequest, ViewInfo};
+
+#[derive(Debug, thiserror::Error)]
+pub enum EngineError {
+ #[error("unknown command `{0}`")]
+ UnknownCommand(String),
+ #[error("command `{0}` is not available right now: {1}")]
+ Disabled(String, String),
+ #[error("invalid parameters for `{cmd}`: {msg}")]
+ BadParams { cmd: String, msg: String },
+ #[error("no active document")]
+ NoDocument,
+ #[error("{0}")]
+ Other(String),
+}
+
+impl From for EngineError {
+ fn from(e: designcraft_doc::DocError) -> Self {
+ EngineError::Other(e.to_string())
+ }
+}
+
+pub type Result = std::result::Result;
+
+#[derive(Clone, Debug)]
+pub struct HistoryEntry {
+ pub label: String,
+ pub doc: Arc,
+ pub selection: Selection,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct History {
+ pub undo: Vec,
+ pub redo: Vec,
+ pub limit: usize,
+}
+
+#[derive(Clone, Debug)]
+pub struct Interaction {
+ pub label: String,
+ pub doc: Arc,
+ pub selection: Selection,
+ pub preview: Option<(String, Value)>,
+}
+
+#[derive(Clone, Debug)]
+pub struct DocState {
+ pub doc: Arc,
+ pub selection: Selection,
+ pub history: History,
+ pub path: Option,
+ pub revision: u64,
+ pub saved_revision: u64,
+ /// The document as last saved (dirty = the current document is a different allocation).
+ pub saved_doc: Arc,
+ pub active_layer: LayerId,
+ pub interaction: Option,
+ /// Editing parent spreads (Pages panel double-click on a parent).
+ pub editing_parents: bool,
+ pub uid: u64,
+}
+
+static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
+
+impl DocState {
+ pub fn new(doc: Document, path: Option) -> Self {
+ let active_layer = doc.default_layer();
+ let doc = Arc::new(doc);
+ DocState {
+ saved_doc: doc.clone(),
+ doc,
+ selection: Selection::default(),
+ history: History { limit: 1000, ..Default::default() },
+ path,
+ revision: 1,
+ saved_revision: 1,
+ active_layer,
+ interaction: None,
+ editing_parents: false,
+ uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
+ }
+ }
+ pub fn is_dirty(&self) -> bool {
+ !Arc::ptr_eq(&self.doc, &self.saved_doc)
+ }
+ pub fn title(&self) -> String {
+ self.path
+ .as_deref()
+ .and_then(|p| std::path::Path::new(p).file_name())
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| self.doc.title.clone())
+ }
+}
+
+/// Preferences that the engine needs (UI keeps its own).
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct Prefs {
+ pub keyboard_increment: f64,
+ pub show_hidden_characters: bool,
+ pub typographers_quotes: bool,
+}
+
+impl Default for Prefs {
+ fn default() -> Self {
+ Prefs { keyboard_increment: 1.0, show_hidden_characters: false, typographers_quotes: true }
+ }
+}
+
+pub struct Session {
+ docs: Vec,
+ active: Option,
+ pub prefs: Prefs,
+ pub cache: Arc,
+ pub(crate) tool: Box,
+ pub journal: Vec<(String, Value)>,
+ pub clipboard: Option>,
+ /// Requests for the UI (dialogs, view changes) produced by tools/commands.
+ pub ui_requests: Vec,
+ pub(crate) untitled: u32,
+}
+
+impl Default for Session {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Session {
+ pub fn new() -> Self {
+ Session {
+ docs: vec![],
+ active: None,
+ prefs: Prefs::default(),
+ cache: Arc::new(Cache::new()),
+ tool: designcraft_tools::create("selection"),
+ journal: vec![],
+ clipboard: None,
+ ui_requests: vec![],
+ untitled: 0,
+ }
+ }
+
+ pub fn documents(&self) -> &[DocState] {
+ &self.docs
+ }
+ pub fn active_index(&self) -> Option {
+ self.active
+ }
+ pub fn active(&self) -> Option<&DocState> {
+ self.active.and_then(|i| self.docs.get(i))
+ }
+ pub fn active_mut(&mut self) -> Option<&mut DocState> {
+ self.active.and_then(|i| self.docs.get_mut(i))
+ }
+ pub fn doc(&self) -> Result<&DocState> {
+ self.active().ok_or(EngineError::NoDocument)
+ }
+ pub fn doc_mut(&mut self) -> Result<&mut DocState> {
+ self.active_mut().ok_or(EngineError::NoDocument)
+ }
+ pub fn set_active(&mut self, i: usize) {
+ if i < self.docs.len() {
+ self.active = Some(i);
+ }
+ }
+ pub fn add_document(&mut self, d: DocState) -> usize {
+ self.docs.push(d);
+ self.active = Some(self.docs.len() - 1);
+ self.docs.len() - 1
+ }
+ pub fn close_document(&mut self, i: usize) {
+ if i < self.docs.len() {
+ self.docs.remove(i);
+ self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
+ }
+ }
+
+ /// Run a command by id. Edits push one undo step (unless inside an interaction).
+ pub fn execute(&mut self, id: &str, params: &Value) -> Result {
+ let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
+ (spec.enabled)(self).map_err(|e| EngineError::Disabled(id.into(), e))?;
+ let before = self.active().map(|d| (d.uid, d.doc.clone()));
+ let r = (spec.run)(self, params)?;
+ // Record undo if the document changed (and we're not previewing an interaction).
+ if let (Some((uid, old)), Some(st)) = (before, self.active_mut())
+ && st.uid == uid
+ && !Arc::ptr_eq(&old, &st.doc)
+ && st.interaction.is_none()
+ && spec.undoable
+ {
+ let entry = HistoryEntry { label: spec.label.to_string(), doc: old, selection: st.selection.clone() };
+ push_undo(st, entry);
+ }
+ if spec.journal {
+ self.journal.push((id.to_string(), params.clone()));
+ }
+ Ok(r)
+ }
+
+ pub fn commands(&self) -> Vec {
+ command_specs().iter().map(|c| c.info(self)).collect()
+ }
+
+ /// Mutate the active document (copy-on-write) and bump the revision.
+ pub fn edit(&mut self, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result {
+ let st = self.doc_mut()?;
+ let mut doc = (*st.doc).clone();
+ let mut sel = st.selection.clone();
+ let r = f(&mut doc, &mut sel)?;
+ st.doc = Arc::new(doc);
+ st.selection = sel;
+ st.revision += 1;
+ Ok(r)
+ }
+}
+
+fn push_undo(st: &mut DocState, e: HistoryEntry) {
+ st.history.undo.push(e);
+ st.history.redo.clear();
+ if st.history.undo.len() > st.history.limit.max(1) {
+ st.history.undo.remove(0);
+ }
+}
+
+pub(crate) use push_undo as record_undo;
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs
new file mode 100644
index 00000000..c9637a2d
--- /dev/null
+++ b/crates/engine/src/sample.rs
@@ -0,0 +1,355 @@
+//! A sample magazine document (original text; imagery generated in code) used for the welcome
+//! screen, screenshots and tests.
+
+use std::sync::Arc;
+
+use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
+use designcraft_doc::build::NewDocument;
+use designcraft_doc::{
+ Align, Asset, AssetId, CharAttrs, CharFormat, CharacterStyle, Content, Document, Fill, FirstBaseline, Graphic, Item, ItemId, Leading, Margins,
+ ParaAttrs, ParaFormat, ParagraphStyle, Rule, Shape, SpreadRef, Stroke, VerticalJustification, WrapMode, story,
+};
+use designcraft_geom::{Affine, Rect, shapes};
+
+const BODY: &str = "Every page begins as an empty field of possibility. The grid arrives first: margins that frame the reading area, columns that set the rhythm, and a baseline grid that keeps every line in step from one column to the next. Good layout is quiet. It lets the reader move through a story without noticing the machinery that carries them.\n\
+Typography does the heavy lifting. A paragraph composer weighs every possible line break at once, trading a slightly loose line here for a much better one three lines later. The result is an even texture of type, free of rivers and ragged gaps, where hyphens appear only when they truly help.\n\
+Images give a spread its pulse. A strong photograph bleeds off the edge of the page, while smaller pictures sit inside the grid and let the text wrap around them. Captions, pull quotes and running heads add layers that a careful reader can explore at their own pace.\n\
+Color holds it all together. A restrained palette of two or three swatches, applied consistently to headlines, rules and backgrounds, gives a publication its voice. Spot inks, tints and gradients are all just named swatches, so a single change ripples through every page.\n\
+Behind the scenes, styles are the secret to working fast. Paragraph styles define the voice of each element, character styles pick out emphasis, and object styles keep frames consistent. Change a style once and the whole document follows, from the cover to the very last page.\n\
+And when the layout is done, the document must travel: to a printer as a press-ready PDF with bleed and crop marks, to a colleague as an open interchange file, or to a screen as a crisp image. A layout tool earns its keep by making every one of those journeys effortless.";
+
+/// Procedural "photograph": a dusk sky with a sun and layered hills.
+fn art_png(w: u32, h: u32, hue: f32) -> Vec {
+ let mut px = Vec::with_capacity((w * h * 4) as usize);
+ let sun = (w as f32 * 0.68, h as f32 * 0.42, h as f32 * 0.16);
+ for y in 0..h {
+ for x in 0..w {
+ let fy = y as f32 / h as f32;
+ let fx = x as f32 / w as f32;
+ // Sky gradient.
+ let top = [0.10 + hue * 0.2, 0.12, 0.32 + hue * 0.1];
+ let mid = [0.95, 0.45 + hue * 0.2, 0.30];
+ let t = (fy / 0.62).min(1.0);
+ let mut c = [top[0] + (mid[0] - top[0]) * t, top[1] + (mid[1] - top[1]) * t, top[2] + (mid[2] - top[2]) * t];
+ // Sun glow.
+ let d = ((x as f32 - sun.0).powi(2) + (y as f32 - sun.1).powi(2)).sqrt();
+ if d < sun.2 {
+ c = [1.0, 0.86, 0.55];
+ } else {
+ let g = (1.0 - (d - sun.2) / (h as f32 * 0.5)).max(0.0).powi(2) * 0.5;
+ c = [c[0] + g, c[1] + g * 0.7, c[2] + g * 0.3];
+ }
+ // Hills.
+ for (k, (amp, base, col)) in
+ [(0.05, 0.62, [0.32, 0.16, 0.30]), (0.07, 0.72, [0.20, 0.10, 0.22]), (0.04, 0.84, [0.10, 0.06, 0.14])].iter().enumerate()
+ {
+ let hill = base + amp * ((fx * (5.0 + k as f32 * 3.0) + k as f32).sin() + 0.5 * (fx * 13.0 + hue * 4.0).sin());
+ if fy > hill {
+ c = *col;
+ }
+ }
+ for v in c {
+ px.push((v.clamp(0.0, 1.0) * 255.0) as u8);
+ }
+ px.push(255);
+ }
+ }
+ let r = designcraft_render::Rendered { width: w, height: h, pixels: px };
+ r.to_png()
+}
+
+fn para_style(name: &str, based: Option<&str>, para: ParaAttrs, chars: CharAttrs) -> ParagraphStyle {
+ ParagraphStyle { name: name.into(), based_on: based.map(Into::into), next_style: None, para, chars, shortcut: String::new() }
+}
+
+fn image(d: &mut Document, name: &str, w: u32, h: u32, hue: f32) -> AssetId {
+ let id = AssetId(d.alloc());
+ let data = art_png(w, h, hue);
+ d.assets.insert(id, Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(data), pixels: Some((w, h)) }));
+ id
+}
+
+fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: (u32, u32)) -> ItemId {
+ let id = ItemId(d.alloc());
+ let lid = d.default_layer();
+ let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
+ let (nw, nh) = (px.0 as f64, px.1 as f64);
+ let k = (r.width() / nw).max(r.height() / nh);
+ it.content = Content::Graphic(Graphic {
+ asset,
+ size: (nw, nh),
+ xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
+ auto_fit: designcraft_doc::Fitting::FillProportionally,
+ });
+ it.object_style = designcraft_doc::BASIC_GRAPHICS_FRAME.into();
+ d.insert_item(sr, it, None).expect("spread");
+ id
+}
+
+fn text(d: &mut Document, sr: SpreadRef, r: Rect, t: &str, style: &str) -> (ItemId, designcraft_doc::StoryId) {
+ let lid = d.default_layer();
+ d.add_text_frame(sr, r, lid, t, ParaFormat { style: style.into(), ..Default::default() }).expect("spread")
+}
+
+pub fn magazine() -> Document {
+ let mut d = Document::new(&NewDocument {
+ title: "Quarterly — Spring Issue".into(),
+ pages: 4,
+ margins: Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 },
+ columns: 3,
+ gutter: 14.0,
+ bleed: [9.0; 4],
+ ..Default::default()
+ });
+ d.title = "Quarterly — Spring Issue".into();
+ // Swatches.
+ d.swatches.push(Swatch::color("Ink Plum", Color::cmyk(0.62, 0.95, 0.30, 0.25)));
+ d.swatches.push(Swatch::color("Sunset", Color::cmyk(0.0, 0.62, 0.78, 0.0)));
+ d.swatches.push(Swatch::color("Paper Warm", Color::cmyk(0.0, 0.03, 0.08, 0.0)));
+ d.swatches.push(Swatch {
+ name: "Dusk Gradient".into(),
+ value: SwatchValue::Gradient {
+ gradient: Gradient {
+ kind: GradientKind::Linear,
+ stops: vec![
+ GradientStop { offset: 0.0, color: Color::cmyk(0.62, 0.95, 0.30, 0.25), opacity: 1.0, midpoint: 0.5 },
+ GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.62, 0.78, 0.0), opacity: 1.0, midpoint: 0.5 },
+ ],
+ },
+ },
+ locked: false,
+ named: true,
+ });
+ // Styles.
+ let serif = |style: &str, size: f64| CharAttrs {
+ font_family: Some("Source Serif 4".into()),
+ font_style: Some(style.into()),
+ size: Some(size),
+ ..Default::default()
+ };
+ let sans = |style: &str, size: f64| CharAttrs {
+ font_family: Some("Source Sans 3".into()),
+ font_style: Some(style.into()),
+ size: Some(size),
+ ..Default::default()
+ };
+ {
+ let st = d.styles_mut();
+ st.paragraph.push(para_style(
+ "Body",
+ Some(story::BASIC_PARAGRAPH),
+ ParaAttrs { align: Some(Align::LeftJustified), first_line_indent: Some(12.0), hyph_min_word: Some(6), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(13.5)), ..serif("Regular", 9.75) },
+ ));
+ st.paragraph.push(para_style(
+ "Body First",
+ Some("Body"),
+ ParaAttrs { first_line_indent: Some(0.0), ..Default::default() },
+ CharAttrs::default(),
+ ));
+ st.paragraph.push(para_style(
+ "Headline",
+ None,
+ ParaAttrs { align: Some(Align::Left), space_after: Some(10.0), hyphenate: Some(false), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(46.0)), tracking: Some(-20.0), fill: Some("Ink Plum".into()), ..serif("Bold", 46.0) },
+ ));
+ st.paragraph.push(para_style(
+ "Deck",
+ None,
+ ParaAttrs { space_after: Some(14.0), hyphenate: Some(false), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(19.0)), fill: Some("[Black]".into()), fill_tint: Some(0.75), ..serif("Italic", 14.0) },
+ ));
+ st.paragraph.push(para_style(
+ "Kicker",
+ None,
+ ParaAttrs {
+ space_after: Some(6.0),
+ rule_below: Some(Rule { on: true, weight: 1.5, color: "Sunset".into(), offset: 5.0, column_width: false, ..Default::default() }),
+ ..Default::default()
+ },
+ CharAttrs {
+ tracking: Some(160.0),
+ capitalization: Some(designcraft_doc::Capitalization::AllCaps),
+ fill: Some("Sunset".into()),
+ ..sans("Semibold", 9.0)
+ },
+ ));
+ st.paragraph.push(para_style(
+ "Pull Quote",
+ None,
+ ParaAttrs { align: Some(Align::Left), hyphenate: Some(false), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(24.0)), fill: Some("Ink Plum".into()), ..serif("Italic", 19.0) },
+ ));
+ st.paragraph.push(para_style(
+ "Caption",
+ None,
+ ParaAttrs::default(),
+ CharAttrs { leading: Some(Leading::Points(10.5)), fill_tint: Some(0.7), ..sans("Regular", 7.5) },
+ ));
+ st.paragraph.push(para_style("Folio", None, ParaAttrs::default(), CharAttrs { tracking: Some(60.0), ..sans("Semibold", 7.5) }));
+ st.paragraph.push(para_style(
+ "Cover Title",
+ None,
+ ParaAttrs { align: Some(Align::Left), hyphenate: Some(false), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(88.0)), tracking: Some(-30.0), fill: Some("[Paper]".into()), ..serif("Bold", 96.0) },
+ ));
+ st.paragraph.push(para_style(
+ "Cover Deck",
+ None,
+ ParaAttrs { hyphenate: Some(false), ..Default::default() },
+ CharAttrs { leading: Some(Leading::Points(22.0)), fill: Some("[Paper]".into()), ..serif("Italic", 17.0) },
+ ));
+ st.character.push(CharacterStyle {
+ name: "Emphasis".into(),
+ based_on: None,
+ chars: CharAttrs { font_style: Some("Italic".into()), ..Default::default() },
+ shortcut: String::new(),
+ });
+ st.character.push(CharacterStyle {
+ name: "Accent".into(),
+ based_on: None,
+ chars: CharAttrs { fill: Some("Sunset".into()), font_style: Some("Semibold".into()), ..Default::default() },
+ shortcut: String::new(),
+ });
+ }
+ let (w, h) = (612.0, 792.0);
+ // Parent A: folio and running head.
+ let pa = SpreadRef::Parent(0);
+ {
+ let (_, s) = text(&mut d, pa, Rect::new(42.0, 750.0, 300.0, 762.0), &format!("{} · QUARTERLY", story::PAGE_NUMBER), "Folio");
+ let _ = s;
+ let (_, _) = text(&mut d, pa, Rect::new(w + 312.0, 750.0, w + w - 42.0, 762.0), &format!("SPRING ISSUE · {}", story::PAGE_NUMBER), "Folio");
+ if let Some(st) = d.story_mut(designcraft_doc::StoryId(d.next_id - 1)) {
+ st.format_paras(0..0, |p| p.para.align = Some(Align::Right));
+ }
+ let lid = d.default_layer();
+ for x in [42.0, w + 42.0] {
+ let id = ItemId(d.alloc());
+ let mut l = Item::new(id, lid, Shape::GraphicLine, shapes::line((x, 742.0).into(), (x + w - 96.0, 742.0).into()));
+ l.stroke = Stroke { weight: 0.5, swatch: "[Black]".into(), tint: 0.4, ..Stroke::default() };
+ d.insert_item(pa, l, None).expect("parent");
+ }
+ }
+ // Page 1: cover (spread 0, single right page, no parent).
+ let _ = d.apply_parent(&[0], None);
+ let cover_art = image(&mut d, "dusk-cover.png", 1224, 1584, 0.0);
+ graphic_frame(&mut d, SpreadRef::Doc(0), Rect::new(-9.0, -9.0, w + 9.0, h + 9.0), cover_art, (1224, 1584));
+ let (_, _) = text(&mut d, SpreadRef::Doc(0), Rect::new(42.0, 60.0, 570.0, 120.0), "THE SPRING ISSUE · NO. 01", "Kicker");
+ if let Some(st) = d.stories.values().last().map(|s| s.id)
+ && let Some(st) = d.story_mut(st)
+ {
+ st.format_chars(0..st.len(), |f| f.over.fill = Some("[Paper]".into()));
+ }
+ let (_, cs) = text(&mut d, SpreadRef::Doc(0), Rect::new(42.0, 450.0, 570.0, 690.0), "The Quiet\nArt of Layout", "Cover Title");
+ let _ = cs;
+ text(
+ &mut d,
+ SpreadRef::Doc(0),
+ Rect::new(42.0, 700.0, 420.0, 760.0),
+ "Grids, type and color: how great pages are made — and the tools that make them.",
+ "Cover Deck",
+ );
+
+ // Spread 1 (pages 2–3).
+ let s1 = SpreadRef::Doc(1);
+ let lx = 42.0; // left page outside margin
+ text(&mut d, s1, Rect::new(lx, 54.0, w - 54.0, 70.0), "Feature · Design", "Kicker");
+ let (_, hs) = text(&mut d, s1, Rect::new(lx, 80.0, w - 54.0, 190.0), "Notes on the Grid", "Headline");
+ let _ = hs;
+ text(
+ &mut d,
+ s1,
+ Rect::new(lx, 182.0, w - 120.0, 240.0),
+ "A good layout is invisible. Here is what is going on underneath the page — from the baseline grid to the paragraph composer.",
+ "Deck",
+ );
+ let art2 = image(&mut d, "hills.png", 1200, 760, 0.6);
+ let pic = graphic_frame(&mut d, s1, Rect::new(lx, 252.0, w - 54.0, 252.0 + 300.0), art2, (1200, 760));
+ let _ = pic;
+ text(
+ &mut d,
+ s1,
+ Rect::new(lx, 558.0, w - 54.0, 572.0),
+ "Evening light over layered hills — generated entirely in code for this sample.",
+ "Caption",
+ );
+ // Body: threaded through 2 columns on the left page and 3 columns on the right page.
+ let (f1, body) = text(&mut d, s1, Rect::new(lx, 584.0, w - 54.0, 738.0), BODY, "Body");
+ if let Some(it) = d.item_mut(f1).and_then(Item::text_frame_mut) {
+ it.options.columns = 2;
+ it.options.gutter = 14.0;
+ }
+ let (f2, _) = text(&mut d, s1, Rect::new(w + 54.0, 54.0, 2.0 * w - 42.0, 708.0), "", "Body");
+ if let Some(it) = d.item_mut(f2).and_then(Item::text_frame_mut) {
+ it.options.columns = 3;
+ it.options.gutter = 14.0;
+ }
+ d.thread(f1, f2).expect("thread");
+ // First paragraph: Body First + accent lead-in.
+ if let Some(st) = d.story_mut(body) {
+ st.paras[0].style = "Body First".into();
+ st.format_chars(0..20, |f| *f = CharFormat { style: "Accent".into(), over: Default::default() });
+ // Repeat the body text so it flows across the spread.
+ let more = format!("\n{BODY}\n{BODY}");
+ let end = st.len();
+ st.insert(end, &more);
+ }
+ // Pull quote with shading, wrapped.
+ let (pq, pqs) = text(
+ &mut d,
+ s1,
+ Rect::new(w + 54.0 + 178.0, 300.0, 2.0 * w - 42.0, 420.0),
+ "“The best layouts disappear. What remains is the story.”",
+ "Pull Quote",
+ );
+ if let Some(it) = d.item_mut(pq) {
+ it.fill = Fill::swatch("Paper Warm");
+ it.wrap.mode = WrapMode::BoundingBox;
+ it.wrap.offsets = [10.0, 10.0, 10.0, 10.0];
+ if let Some(tf) = it.text_frame_mut() {
+ tf.options.inset = [14.0, 14.0, 14.0, 14.0];
+ tf.options.vertical_justification = VerticalJustification::Center;
+ tf.options.first_baseline = FirstBaseline::Ascent;
+ }
+ }
+ let _ = pqs;
+ // A sunset-gradient accent bar.
+ let lid = d.default_layer();
+ let id = ItemId(d.alloc());
+ let mut bar = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(w + 54.0, 600.0 + 120.0, 2.0 * w - 42.0, 600.0 + 126.0)));
+ bar.fill = Fill { swatch: "Dusk Gradient".into(), ..Fill::none() };
+ d.insert_item(s1, bar, None).expect("spread");
+
+ // Page 4: color page.
+ let s2 = SpreadRef::Doc(2);
+ let id = ItemId(d.alloc());
+ let mut bg = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(-9.0, -9.0, w + 9.0, 396.0)));
+ bg.fill = Fill::swatch("Ink Plum");
+ d.insert_item(s2, bg, None).expect("spread");
+ let (_, k) = text(&mut d, s2, Rect::new(42.0, 80.0, 400.0, 100.0), "Coming next", "Kicker");
+ let _ = k;
+ let (t4, _) = text(&mut d, s2, Rect::new(42.0, 110.0, 560.0, 330.0), "Color, Ink & Paper", "Cover Title");
+ if let Some(sid) = d.item(t4).and_then(|i| i.text_frame()).map(|t| t.story)
+ && let Some(st) = d.story_mut(sid)
+ {
+ st.format_chars(0..st.len(), |f| f.over.size = Some(64.0));
+ st.format_chars(0..st.len(), |f| f.over.leading = Some(Leading::Points(64.0)));
+ }
+ for (i, sw) in ["Ink Plum", "Sunset", "C=100 M=0 Y=0 K=0", "Paper Warm"].iter().enumerate() {
+ let id = ItemId(d.alloc());
+ let x = 42.0 + i as f64 * 132.0;
+ let mut c = Item::new(id, lid, Shape::Oval, shapes::ellipse(Rect::new(x, 440.0, x + 116.0, 556.0)));
+ c.fill = Fill::swatch(sw);
+ if *sw == "Paper Warm" {
+ c.stroke = Stroke { weight: 0.5, ..Stroke::default() };
+ }
+ d.insert_item(s2, c, None).expect("spread");
+ text(&mut d, s2, Rect::new(x, 566.0, x + 116.0, 580.0), sw, "Caption");
+ }
+ let (f4, _) = text(&mut d, s2, Rect::new(42.0, 610.0, 570.0, 730.0), &BODY[..BODY.find('\n').unwrap_or(BODY.len())], "Body First");
+ if let Some(it) = d.item_mut(f4).and_then(Item::text_frame_mut) {
+ it.options.columns = 2;
+ }
+ debug_assert!(d.check().is_ok(), "{:?}", d.check());
+ d
+}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
new file mode 100644
index 00000000..736b73ab
--- /dev/null
+++ b/crates/engine/src/tests.rs
@@ -0,0 +1,106 @@
+use serde_json::json;
+
+use super::*;
+
+fn session() -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2})).unwrap();
+ s
+}
+
+#[test]
+fn create_frame_type_and_undo() {
+ let mut s = session();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
+ let sid = r["story"].as_u64().unwrap();
+ s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
+ let st = s.execute("story.get", &json!({"story": sid})).unwrap();
+ assert_eq!(st["text"], "Hello, world");
+ s.execute("text.delete", &json!({})).unwrap();
+ assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "Hello, worl");
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "Hello, world");
+ s.execute("edit.undo", &json!({})).unwrap();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(s.doc().unwrap().doc.stories.is_empty());
+}
+
+#[test]
+fn smart_quotes_and_formatting() {
+ let mut s = session();
+ s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
+ s.execute("text.insert", &json!({"text": "\"hi\" it's"})).unwrap();
+ let t = s.execute("document.inspect", &json!({})).unwrap();
+ let sid = t["stories"][0]["id"].as_u64().unwrap();
+ assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "\u{201C}hi\u{201D} it\u{2019}s");
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
+ s.execute("type.char", &json!({"attrs": {"size": 24, "fontStyle": "Bold"}})).unwrap();
+ let a = s.execute("type.selectionAttrs", &json!({})).unwrap();
+ assert_eq!(a["chars"]["size"], 24.0);
+ s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
+ assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "center");
+}
+
+#[test]
+fn tool_gesture_creates_one_undo_step() {
+ let mut s = session();
+ s.set_tool("rectangle");
+ let v = ViewInfo { zoom: 1.0 };
+ use designcraft_tools::{PointerEvent, PointerKind};
+ s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
+ for i in 1..10 {
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 10.0 + i as f64 * 10.0, 10.0 + i as f64 * 5.0), v).unwrap();
+ }
+ s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 55.0), v).unwrap();
+ let st = s.doc().unwrap();
+ assert_eq!(st.history.undo.len(), 1);
+ assert_eq!(st.doc.spreads[0].items.len(), 1);
+ assert_eq!(st.doc.spreads[0].items[0].bounds(), designcraft_geom::Rect::new(10.0, 10.0, 100.0, 55.0));
+ // Move with the selection tool.
+ s.set_tool("selection");
+ s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 30.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 70.0, 30.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 40.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 40.0), v).unwrap();
+ let st = s.doc().unwrap();
+ assert_eq!(st.history.undo.len(), 2);
+ assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 40.0);
+}
+
+#[test]
+fn pages_and_layers() {
+ let mut s = session();
+ s.execute("layout.pages.insert", &json!({"count": 3})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.page_count(), 5);
+ s.execute("layout.pages.delete", &json!({"pages": [0]})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.page_count(), 4);
+ s.execute("layer.new", &json!({"name": "Text"})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.layers[0].name, "Text");
+}
+
+#[test]
+fn sample_document_is_valid_and_composes() {
+ let mut s = Session::new();
+ s.execute("file.newSample", &json!({})).unwrap();
+ let st = s.doc().unwrap();
+ st.doc.check().unwrap();
+ assert_eq!(st.doc.page_count(), 4);
+ let i = s.execute("document.inspect", &json!({})).unwrap();
+ assert!(i["stories"].as_array().unwrap().len() >= 8);
+ // Save/load round trip.
+ let bytes = cmd::file_bytes(&s.doc().unwrap().doc);
+ let back = cmd::file_from(&bytes).unwrap();
+ assert_eq!(back.page_count(), 4);
+ assert_eq!(back.assets.len(), s.doc().unwrap().doc.assets.len());
+ assert!(back.assets.values().all(|a| !a.data.is_empty()));
+}
+
+#[test]
+fn every_command_has_metadata() {
+ let mut ids = std::collections::HashSet::new();
+ for c in command_specs() {
+ assert!(ids.insert(c.id), "duplicate command {}", c.id);
+ assert!(!c.label.is_empty() && !c.params.is_empty(), "{}", c.id);
+ }
+ assert!(command_specs().len() > 60);
+}
diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs
new file mode 100644
index 00000000..68efc6db
--- /dev/null
+++ b/crates/engine/src/tooling.rs
@@ -0,0 +1,166 @@
+//! Tool hosting: the active tool receives pointer/key events and its actions are executed here.
+
+use std::sync::Arc;
+
+use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
+use serde::Serialize;
+use serde_json::Value;
+
+use crate::{EngineError, HistoryEntry, Interaction, Result, Session, record_undo};
+
+/// Something the UI should do in response to a tool or command.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum UiRequest {
+ Dialog {
+ id: String,
+ params: Value,
+ },
+ View {
+ params: Value,
+ },
+ /// Open a file picker for the given purpose (`place`, `open`, `saveAs`, `export`).
+ Pick {
+ purpose: String,
+ params: Value,
+ },
+}
+
+/// View information the UI passes with pointer events.
+#[derive(Clone, Copy, Debug, Default, Serialize)]
+pub struct ViewInfo {
+ /// Screen pixels per point.
+ pub zoom: f64,
+}
+
+impl Session {
+ pub fn tool_id(&self) -> &'static str {
+ self.tool.id()
+ }
+
+ pub fn set_tool(&mut self, id: &str) {
+ if self.tool.id() == id {
+ return;
+ }
+ self.tool = designcraft_tools::create(id);
+ // Leaving the Type tool keeps the text frame selected.
+ if id != "type"
+ && let Some(st) = self.active_mut()
+ && let Some(t) = st.selection.text.take()
+ && let Some(f) = t.frame.or_else(|| st.doc.story(t.story).and_then(|s| s.frames.first().copied()))
+ {
+ st.selection.items = vec![f];
+ }
+ }
+
+ pub fn layout(&self) -> CanvasLayout {
+ match self.active() {
+ Some(st) => CanvasLayout::new(&st.doc, st.editing_parents),
+ None => CanvasLayout::default(),
+ }
+ }
+
+ fn with_ctx(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn designcraft_tools::Tool, &ToolContext) -> T) -> Option {
+ let st = self.active()?;
+ let doc = st.doc.clone();
+ let sel = st.selection.clone();
+ let layer = st.active_layer;
+ let layout = CanvasLayout::new(&doc, st.editing_parents);
+ let cache = self.cache.clone();
+ let cx = ToolContext { doc: &doc, selection: &sel, cache: &cache, layout: &layout, zoom: view.zoom.max(1e-6), layer, snap: true };
+ Some(f(self.tool.as_mut(), &cx))
+ }
+
+ pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
+ let actions = self.with_ctx(view, |t, cx| t.pointer(cx, ev)).unwrap_or_default();
+ self.run_actions(actions)
+ }
+
+ pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result {
+ let actions = self.with_ctx(view, |t, cx| t.key(cx, key, mods)).unwrap_or_default();
+ let handled = !actions.is_empty();
+ self.run_actions(actions)?;
+ Ok(handled)
+ }
+
+ pub fn overlays(&mut self, view: ViewInfo) -> Vec {
+ self.with_ctx(view, |t, cx| t.overlays(cx)).unwrap_or_default()
+ }
+
+ pub fn cursor(&mut self, p: designcraft_geom::Point, mods: Mods, view: ViewInfo) -> Cursor {
+ self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default()
+ }
+
+ pub fn tool_busy(&self) -> bool {
+ self.tool.busy()
+ }
+
+ pub fn wants_text(&mut self) -> bool {
+ self.with_ctx(ViewInfo { zoom: 1.0 }, |t, cx| t.wants_text(cx)).unwrap_or(false)
+ }
+
+ pub fn run_actions(&mut self, actions: Vec) -> Result<()> {
+ for a in actions {
+ match a {
+ Action::Begin(label) => self.begin_interaction(&label)?,
+ Action::Preview(cmd, p) => self.preview(&cmd, p)?,
+ Action::Commit => self.commit_interaction()?,
+ Action::Cancel => self.cancel_interaction(),
+ Action::Exec(cmd, p) => {
+ self.execute(&cmd, &p)?;
+ }
+ Action::Dialog(id, params) => self.ui_requests.push(UiRequest::Dialog { id, params }),
+ Action::SwitchTool(id) => self.set_tool(&id),
+ Action::View(params) => self.ui_requests.push(UiRequest::View { params }),
+ }
+ }
+ Ok(())
+ }
+
+ pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
+ let st = self.doc_mut()?;
+ st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
+ Ok(())
+ }
+
+ /// Apply `cmd` on top of the interaction's snapshot (replacing the previous preview).
+ pub fn preview(&mut self, cmd: &str, params: Value) -> Result<()> {
+ let st = self.doc_mut()?;
+ let Some(it) = st.interaction.clone() else { return Err(EngineError::Other("no interaction".into())) };
+ st.doc = it.doc.clone();
+ st.selection = it.selection.clone();
+ let r = self.execute(cmd, ¶ms);
+ if let Some(st) = self.active_mut()
+ && let Some(i) = st.interaction.as_mut()
+ {
+ i.preview = Some((cmd.to_string(), params));
+ }
+ // Journal only the committed result.
+ if r.is_ok() {
+ self.journal.pop();
+ }
+ r.map(|_| ())
+ }
+
+ pub fn commit_interaction(&mut self) -> Result<()> {
+ let st = self.doc_mut()?;
+ let Some(it) = st.interaction.take() else { return Ok(()) };
+ if !Arc::ptr_eq(&it.doc, &st.doc) {
+ record_undo(st, HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
+ }
+ if let Some((c, p)) = it.preview {
+ self.journal.push((c, p));
+ }
+ Ok(())
+ }
+
+ pub fn cancel_interaction(&mut self) {
+ if let Some(st) = self.active_mut()
+ && let Some(it) = st.interaction.take()
+ {
+ st.doc = it.doc;
+ st.selection = it.selection;
+ st.revision += 1;
+ }
+ }
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index d4b6f364..7dacf57b 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -159,7 +159,16 @@ impl Renderer {
}
/// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels).
- pub fn render(&mut self, doc: &Document, cache: &Cache, spreads: &[Placed], width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
+ pub fn render(
+ &mut self,
+ doc: &Document,
+ cache: &Cache,
+ spreads: &[Placed],
+ width: u32,
+ height: u32,
+ view: Affine,
+ opts: &RenderOptions,
+ ) -> Rendered {
let start = now();
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
@@ -232,8 +241,13 @@ impl Renderer {
}
ctx.set_transform(f.view);
ctx.set_paint(color_of(&paper, 1.0));
- for p in &sp.pages {
- ctx.fill_rect(&p.bounds());
+ // One rect for equal-height spreads avoids an anti-aliasing seam at the spine.
+ if sp.pages.windows(2).all(|w| w[0].height == w[1].height) {
+ ctx.fill_rect(&sp.bounds());
+ } else {
+ for p in &sp.pages {
+ ctx.fill_rect(&p.bounds());
+ }
}
}
// Layers back to front; within each layer parent items first (document spreads only).
@@ -381,7 +395,8 @@ impl Renderer {
ctx.pop_layer();
}
StrokeAlign::Outside if closed => {
- let outline = kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px);
+ let outline =
+ kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px);
let mut p = outline;
// Remove the interior: even-odd with the path itself.
for el in bp.elements() {
@@ -430,7 +445,10 @@ impl Renderer {
ctx.set_transform(f.view * xf * g.xf);
let sx = g.size.0 / pm.width().max(1) as f64;
let sy = g.size.1 / pm.height().max(1) as f64;
- ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium) });
+ ctx.set_paint(vello_cpu::Image {
+ image: vello_cpu::ImageSource::Pixmap(pm),
+ sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
+ });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
@@ -468,10 +486,12 @@ impl Renderer {
if style.underline || style.strikethrough {
let w = style.size / 14.0;
if style.underline {
- lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w)));
+ lines_deco
+ .push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w)));
}
if style.strikethrough {
- lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w)));
+ lines_deco
+ .push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w)));
}
}
let outline = db.outline(&g.face, g.gid);
@@ -642,6 +662,23 @@ fn now() -> u64 {
0
}
+/// Pixel size of an encoded image without decoding it fully.
+pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
+ image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
+}
+
+/// MIME type guess for encoded image bytes.
+pub fn image_mime(bytes: &[u8]) -> &'static str {
+ match image::guess_format(bytes) {
+ Ok(image::ImageFormat::Png) => "image/png",
+ Ok(image::ImageFormat::Jpeg) => "image/jpeg",
+ Ok(image::ImageFormat::Gif) => "image/gif",
+ Ok(image::ImageFormat::WebP) => "image/webp",
+ Ok(image::ImageFormat::Tiff) => "image/tiff",
+ _ => "application/octet-stream",
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -652,9 +689,11 @@ mod tests {
fn renders_page_with_text_and_fill() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
- let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "Hello DesignCraft", ParaFormat::default()).unwrap();
+ let (fid, _) =
+ d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "Hello DesignCraft", ParaFormat::default()).unwrap();
let id = designcraft_doc::ItemId(d.alloc());
- let mut box_ = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 136.0, 500.0)));
+ let mut box_ =
+ Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 136.0, 500.0)));
box_.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
d.insert_item(SpreadRef::Doc(0), box_, None).unwrap();
let cache = Cache::new();
diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs
index 7d145244..6b153f06 100644
--- a/crates/tools/src/catalog.rs
+++ b/crates/tools/src/catalog.rs
@@ -21,7 +21,10 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")],
&[t("page", "Page Tool", Some("Shift+P"), "tool-page")],
&[t("gap", "Gap Tool", Some("U"), "tool-gap")],
- &[t("contentCollector", "Content Collector Tool", Some("B"), "tool-collector"), t("contentPlacer", "Content Placer Tool", Some("B"), "tool-placer")],
+ &[
+ t("contentCollector", "Content Collector Tool", Some("B"), "tool-collector"),
+ t("contentPlacer", "Content Placer Tool", Some("B"), "tool-placer"),
+ ],
&[],
&[
t("type", "Type Tool", Some("T"), "tool-type"),
@@ -36,13 +39,21 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
t("deleteAnchor", "Delete Anchor Point Tool", Some("-"), "tool-pen-delete"),
t("convertDirection", "Convert Direction Point Tool", Some("Shift+C"), "tool-anchor"),
],
- &[t("pencil", "Pencil Tool", Some("N"), "tool-pencil"), t("smooth", "Smooth Tool", None, "tool-smooth"), t("erase", "Erase Tool", None, "tool-erase")],
+ &[
+ t("pencil", "Pencil Tool", Some("N"), "tool-pencil"),
+ t("smooth", "Smooth Tool", None, "tool-smooth"),
+ t("erase", "Erase Tool", None, "tool-erase"),
+ ],
&[
t("rectangleFrame", "Rectangle Frame Tool", Some("F"), "tool-rect-frame"),
t("ellipseFrame", "Ellipse Frame Tool", None, "tool-ellipse-frame"),
t("polygonFrame", "Polygon Frame Tool", None, "tool-polygon-frame"),
],
- &[t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"), t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"), t("polygon", "Polygon Tool", None, "tool-polygon")],
+ &[
+ t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"),
+ t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"),
+ t("polygon", "Polygon Tool", None, "tool-polygon"),
+ ],
&[t("scissors", "Scissors Tool", Some("C"), "tool-scissors")],
&[
t("freeTransform", "Free Transform Tool", Some("E"), "tool-free-transform"),
@@ -50,7 +61,10 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
t("scale", "Scale Tool", Some("S"), "tool-scale"),
t("shear", "Shear Tool", Some("O"), "tool-shear"),
],
- &[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")],
+ &[
+ t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"),
+ t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather"),
+ ],
&[t("note", "Note Tool", None, "tool-note")],
&[
t("colorTheme", "Color Theme Tool", Some("Shift+I"), "tool-color-theme"),
diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs
new file mode 100644
index 00000000..3066fa36
--- /dev/null
+++ b/crates/tools/src/frame.rs
@@ -0,0 +1,133 @@
+//! Frame and shape tools (Rectangle/Ellipse/Polygon Frame, Rectangle/Ellipse/Polygon, Line).
+//! Drag to draw (Shift = square/45°, Alt = from centre); click opens the size dialog.
+
+use designcraft_geom::{Point, Rect};
+use serde_json::json;
+
+use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, rect_json, spread_json};
+
+pub struct FrameTool {
+ id: &'static str,
+ start: Option,
+ cur: Point,
+ active: bool,
+}
+
+impl FrameTool {
+ pub fn new(id: &str) -> Self {
+ let id = crate::tool_info(id).map(|t| t.id).unwrap_or("rectangleFrame");
+ Self { id, start: None, cur: Point::ZERO, active: false }
+ }
+ fn kind(&self) -> (&'static str, &'static str) {
+ match self.id {
+ "rectangleFrame" => ("rectangle", "graphic"),
+ "ellipseFrame" => ("ellipse", "graphic"),
+ "polygonFrame" => ("polygon", "graphic"),
+ "rectangle" => ("rectangle", "unassigned"),
+ "ellipse" => ("ellipse", "unassigned"),
+ "polygon" => ("polygon", "unassigned"),
+ _ => ("line", "unassigned"),
+ }
+ }
+}
+
+pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect {
+ let (mut dx, mut dy) = (b.x - a.x, b.y - a.y);
+ if m.shift {
+ let s = dx.abs().max(dy.abs());
+ dx = s * dx.signum();
+ dy = s * dy.signum();
+ }
+ if m.alt {
+ Rect::new(a.x - dx.abs(), a.y - dy.abs(), a.x + dx.abs(), a.y + dy.abs())
+ } else {
+ Rect::from_points(a, Point::new(a.x + dx, a.y + dy))
+ }
+}
+
+impl Tool for FrameTool {
+ fn id(&self) -> &'static str {
+ self.id
+ }
+
+ fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ let (shape, content) = self.kind();
+ match ev.kind {
+ PointerKind::Down => {
+ self.start = Some(ev.pos);
+ self.cur = ev.pos;
+ self.active = false;
+ vec![]
+ }
+ PointerKind::Drag => {
+ let Some(a) = self.start else { return vec![] };
+ self.cur = ev.pos;
+ if !self.active && (ev.pos - a).hypot() < cx.tol(3.0) {
+ return vec![];
+ }
+ let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
+ let off = cx.layout.offset(sr);
+ let mut out = vec![];
+ if !self.active {
+ self.active = true;
+ out.push(Action::Begin(format!(
+ "Create {}",
+ crate::tool_info(self.id).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("Frame")
+ )));
+ }
+ if shape == "line" {
+ let mut b = ev.pos - off;
+ if ev.mods.shift {
+ let v = b - sa;
+ let v = designcraft_geom::constrain_angle(v, 45.0);
+ b = sa + v;
+ }
+ out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
+ } else {
+ let r = drag_rect(sa, ev.pos - off, ev.mods);
+ out.push(Action::Preview(
+ "frame.create".into(),
+ json!({"spread": spread_json(sr), "shape": shape, "content": content, "rect": rect_json(r)}),
+ ));
+ }
+ out
+ }
+ PointerKind::Up => {
+ let was = self.active;
+ self.active = false;
+ let start = self.start.take();
+ if was {
+ return vec![Action::Commit];
+ }
+ // Click: size dialog.
+ if let Some(a) = start
+ && let Some((sr, sa)) = cx.layout.spread_at(a)
+ {
+ return vec![Action::Dialog(
+ "frameSize".into(),
+ json!({"spread": spread_json(sr), "shape": shape, "content": content, "x": sa.x, "y": sa.y, "width": 72.0, "height": 72.0}),
+ )];
+ }
+ vec![]
+ }
+ _ => vec![],
+ }
+ }
+
+ fn overlays(&self, cx: &ToolContext) -> Vec {
+ if let (true, Some(a)) = (self.active, self.start) {
+ let r = Rect::from_points(a, self.cur);
+ let _ = cx;
+ return vec![Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }];
+ }
+ vec![]
+ }
+
+ fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
+ Cursor::Crosshair
+ }
+
+ fn busy(&self) -> bool {
+ self.active
+ }
+}
diff --git a/crates/tools/src/layout.rs b/crates/tools/src/layout.rs
index 3d433c8f..c75d34ea 100644
--- a/crates/tools/src/layout.rs
+++ b/crates/tools/src/layout.rs
@@ -27,7 +27,8 @@ pub const SPREAD_GAP: f64 = 72.0;
impl CanvasLayout {
/// Layout of the document spreads (or the parent spreads when `parents` is true).
pub fn new(doc: &Document, parents: bool) -> Self {
- let list: Vec = if parents { (0..doc.parents.len()).map(SpreadRef::Parent).collect() } else { (0..doc.spreads.len()).map(SpreadRef::Doc).collect() };
+ let list: Vec =
+ if parents { (0..doc.parents.len()).map(SpreadRef::Parent).collect() } else { (0..doc.spreads.len()).map(SpreadRef::Doc).collect() };
let mut slots = Vec::with_capacity(list.len());
let mut y = 0.0;
let mut pb: Option = None;
diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs
index af8c0b1d..65386d67 100644
--- a/crates/tools/src/lib.rs
+++ b/crates/tools/src/lib.rs
@@ -12,7 +12,7 @@ pub mod catalog;
mod frame;
pub mod layout;
mod nav;
-mod select;
+pub mod select;
mod text;
use designcraft_compose::Cache;
@@ -160,12 +160,27 @@ impl ToolContext<'_> {
pub enum Overlay {
Marquee(Rect),
/// Preview outline of a frame being drawn.
- Path { path: BezPath, color: [u8; 3], dashed: bool },
- Line { a: Point, b: Point, color: [u8; 3], dashed: bool },
+ Path {
+ path: BezPath,
+ color: [u8; 3],
+ dashed: bool,
+ },
+ Line {
+ a: Point,
+ b: Point,
+ color: [u8; 3],
+ dashed: bool,
+ },
/// Measurement pill near the cursor.
- Measure { p: Point, text: String },
+ Measure {
+ p: Point,
+ text: String,
+ },
/// Smart guide line in the smart-guide colour.
- Guide { a: Point, b: Point },
+ Guide {
+ a: Point,
+ b: Point,
+ },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -220,7 +235,7 @@ pub fn create(id: &str) -> Box {
"rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)),
"type" => Box::new(text::TypeTool::default()),
"hand" => Box::new(nav::HandTool::default()),
- "zoom" => Box::new(nav::ZoomTool::default()),
+ "zoom" => Box::new(nav::ZoomTool),
other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))),
}
}
diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs
new file mode 100644
index 00000000..b0cc9590
--- /dev/null
+++ b/crates/tools/src/nav.rs
@@ -0,0 +1,61 @@
+//! Hand (H) and Zoom (Z) tools.
+
+use designcraft_geom::Point;
+use serde_json::json;
+
+use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
+
+#[derive(Default)]
+pub struct HandTool {
+ last: Option,
+}
+
+impl Tool for HandTool {
+ fn id(&self) -> &'static str {
+ "hand"
+ }
+ fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ match ev.kind {
+ PointerKind::Down => {
+ self.last = Some(ev.pos);
+ vec![]
+ }
+ PointerKind::Drag => {
+ // Canvas positions shift as we pan, so convert the delta to screen pixels once.
+ let Some(l) = self.last else { return vec![] };
+ let d = (ev.pos - l) * cx.zoom;
+ vec![Action::View(json!({"pan": [d.x, d.y]}))]
+ }
+ PointerKind::Up => {
+ self.last = None;
+ vec![]
+ }
+ _ => vec![],
+ }
+ }
+ fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
+ if self.last.is_some() { Cursor::HandGrab } else { Cursor::Hand }
+ }
+ fn busy(&self) -> bool {
+ self.last.is_some()
+ }
+}
+
+#[derive(Default)]
+pub struct ZoomTool;
+
+impl Tool for ZoomTool {
+ fn id(&self) -> &'static str {
+ "zoom"
+ }
+ fn pointer(&mut self, _cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ if ev.kind == PointerKind::Up {
+ let f = if ev.mods.alt { 0.5 } else { 2.0 };
+ return vec![Action::View(json!({"zoomAt": [ev.pos.x, ev.pos.y], "factor": f}))];
+ }
+ vec![]
+ }
+ fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
+ if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }
+ }
+}
diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs
new file mode 100644
index 00000000..4fa2642e
--- /dev/null
+++ b/crates/tools/src/select.rs
@@ -0,0 +1,304 @@
+//! Selection tool (V) and Direct Selection tool (A).
+//!
+//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
+//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
+//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
+
+use designcraft_doc::SpreadRef;
+use designcraft_geom::{Point, Rect, Vec2};
+use serde_json::{Value, json};
+
+use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, rect_json, spread_json};
+
+#[derive(Clone, Debug)]
+enum Drag {
+ None,
+ Pending { start: Point, hit: bool },
+ Move { start: Point, origin_spread: SpreadRef },
+ Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef },
+ Marquee { start: Point, cur: Point },
+}
+
+pub struct SelectionTool {
+ direct: bool,
+ drag: Drag,
+ hover_handle: Option,
+}
+
+impl SelectionTool {
+ pub fn new(direct: bool) -> Self {
+ Self { direct, drag: Drag::None, hover_handle: None }
+ }
+}
+
+/// Handle positions (canvas) of a rect: 0 TL, 1 T, 2 TR, 3 R, 4 BR, 5 B, 6 BL, 7 L.
+pub fn handles(r: Rect) -> [Point; 8] {
+ let c = r.center();
+ [
+ Point::new(r.x0, r.y0),
+ Point::new(c.x, r.y0),
+ Point::new(r.x1, r.y0),
+ Point::new(r.x1, c.y),
+ Point::new(r.x1, r.y1),
+ Point::new(c.x, r.y1),
+ Point::new(r.x0, r.y1),
+ Point::new(r.x0, c.y),
+ ]
+}
+
+fn handle_at(cx: &ToolContext, p: Point) -> Option {
+ let b = cx.selection_bounds()?;
+ let tol = cx.tol(5.0);
+ handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol)
+}
+
+/// New rect when dragging `handle` of `from` to `p`.
+pub fn resize_rect(from: Rect, handle: usize, p: Point, m: Mods) -> Rect {
+ let (mut x0, mut y0, mut x1, mut y1) = (from.x0, from.y0, from.x1, from.y1);
+ match handle {
+ 0 => {
+ x0 = p.x;
+ y0 = p.y;
+ }
+ 1 => y0 = p.y,
+ 2 => {
+ x1 = p.x;
+ y0 = p.y;
+ }
+ 3 => x1 = p.x,
+ 4 => {
+ x1 = p.x;
+ y1 = p.y;
+ }
+ 5 => y1 = p.y,
+ 6 => {
+ x0 = p.x;
+ y1 = p.y;
+ }
+ _ => x0 = p.x,
+ }
+ if m.shift && from.width() > 1e-9 && from.height() > 1e-9 {
+ let sx = (x1 - x0) / from.width();
+ let sy = (y1 - y0) / from.height();
+ let s = if matches!(handle, 1 | 5) {
+ sy
+ } else if matches!(handle, 3 | 7) || sx.abs() > sy.abs() {
+ sx
+ } else {
+ sy
+ };
+ let (w, h) = (from.width() * s, from.height() * s);
+ match handle {
+ 0 => {
+ x0 = x1 - w;
+ y0 = y1 - h;
+ }
+ 2 => {
+ x1 = x0 + w;
+ y0 = y1 - h;
+ }
+ 3..=5 => {
+ x1 = x0 + w;
+ y1 = y0 + h;
+ }
+ 6 | 7 => {
+ x0 = x1 - w;
+ y1 = y0 + h;
+ }
+ _ => {
+ x0 = x1 - w;
+ y0 = y1 - h;
+ }
+ }
+ }
+ if m.alt {
+ let c = from.center();
+ let (hw, hh) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
+ let (hw, hh) = match handle {
+ 1 | 5 => (from.width() / 2.0, (if handle == 1 { c.y - y0 } else { y1 - c.y })),
+ 3 | 7 => ((if handle == 7 { c.x - x0 } else { x1 - c.x }), from.height() / 2.0),
+ _ => (hw.abs().max((c.x - x0).abs()).max((x1 - c.x).abs()), hh.abs().max((c.y - y0).abs()).max((y1 - c.y).abs())),
+ };
+ return Rect::new(c.x - hw, c.y - hh, c.x + hw, c.y + hh);
+ }
+ Rect::new(x0, y0, x1, y1)
+}
+
+impl Tool for SelectionTool {
+ fn id(&self) -> &'static str {
+ if self.direct { "directSelection" } else { "selection" }
+ }
+
+ fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ let p = ev.pos;
+ match ev.kind {
+ PointerKind::Move => {
+ self.hover_handle = handle_at(cx, p);
+ vec![]
+ }
+ PointerKind::Down => {
+ if let Some(h) = handle_at(cx, p).filter(|_| !self.direct)
+ && let (Some(b), Some(first)) = (cx.selection_bounds(), cx.selection.items.first())
+ && let Some(loc) = cx.doc.find(*first)
+ {
+ let off = cx.layout.offset(loc.spread);
+ self.drag = Drag::Resize { handle: h, start: p, from: b - off, spread: loc.spread };
+ return vec![Action::Begin("Resize".into())];
+ }
+ match cx.hit(p) {
+ Some((sr, id)) => {
+ let id = if self.direct { id } else { cx.doc.top_level_of(id).unwrap_or(id) };
+ let mut out = vec![];
+ if ev.mods.shift {
+ out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
+ } else if !cx.selection.contains(id) {
+ out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
+ }
+ self.drag = Drag::Pending { start: p, hit: true };
+ let _ = sr;
+ out
+ }
+ None => {
+ self.drag = Drag::Pending { start: p, hit: false };
+ if ev.mods.shift { vec![] } else { vec![Action::Exec("selection.set".into(), json!({"ids": []}))] }
+ }
+ }
+ }
+ PointerKind::Drag => match self.drag.clone() {
+ Drag::Pending { start, hit } => {
+ if (p - start).hypot() < cx.tol(3.0) {
+ return vec![];
+ }
+ if hit && !cx.selection.items.is_empty() {
+ let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
+ self.drag = Drag::Move { start, origin_spread: sr };
+ let label = if ev.mods.alt { "Duplicate" } else { "Move" };
+ let mut v = vec![Action::Begin(label.into())];
+ v.extend(self.pointer(cx, ev));
+ v
+ } else {
+ self.drag = Drag::Marquee { start, cur: p };
+ vec![]
+ }
+ }
+ Drag::Move { start, origin_spread } => {
+ let mut d: Vec2 = p - start;
+ if ev.mods.shift {
+ if d.x.abs() > d.y.abs() {
+ d.y = 0.0;
+ } else {
+ d.x = 0.0;
+ }
+ }
+ // Dragging to another spread moves the items there.
+ let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
+ let mut params = json!({"dx": d.x, "dy": d.y, "copy": ev.mods.alt});
+ if target != origin_spread {
+ let off = cx.layout.offset(target) - cx.layout.offset(origin_spread);
+ params = json!({"dx": d.x - off.x, "dy": d.y - off.y, "copy": ev.mods.alt, "toSpread": spread_json(target)});
+ }
+ vec![Action::Preview("transform.move".into(), params)]
+ }
+ Drag::Resize { handle, start, from, spread } => {
+ let off = cx.layout.offset(spread);
+ let to = resize_rect(from, handle, p - off, ev.mods);
+ let _ = start;
+ vec![Action::Preview("transform.resize".into(), json!({"from": rect_json(from), "to": rect_json(to), "content": ev.mods.cmd}))]
+ }
+ Drag::Marquee { start, .. } => {
+ self.drag = Drag::Marquee { start, cur: p };
+ vec![]
+ }
+ Drag::None => vec![],
+ },
+ PointerKind::Up => {
+ let d = std::mem::replace(&mut self.drag, Drag::None);
+ match d {
+ Drag::Move { .. } | Drag::Resize { .. } => vec![Action::Commit],
+ Drag::Marquee { start, cur } => {
+ let r = Rect::from_points(start, cur);
+ // Items whose bounds intersect the marquee.
+ let mut ids: Vec = Vec::new();
+ for slot in &cx.layout.slots {
+ let Some(sp) = cx.doc.spread(slot.spread) else { continue };
+ for it in &sp.items {
+ let b = it.bounds() + slot.offset;
+ let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
+ if !locked && !it.hidden && b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0 {
+ ids.push(Value::from(it.id.0));
+ }
+ }
+ }
+ vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
+ }
+ _ => vec![],
+ }
+ }
+ PointerKind::DoubleClick => {
+ if let Some((_, id)) = cx.hit(p)
+ && cx.doc.item(id).is_some_and(|i| i.is_text_frame())
+ {
+ let (sr, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p));
+ let _ = sr;
+ return vec![
+ Action::SwitchTool("type".into()),
+ Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]})),
+ ];
+ }
+ vec![]
+ }
+ }
+ }
+
+ fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec {
+ let inc = cx.doc.settings.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
+ let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
+ if cx.selection.items.is_empty() {
+ return vec![];
+ }
+ match key {
+ ToolKey::Left => mv(-inc, 0.0),
+ ToolKey::Right => mv(inc, 0.0),
+ ToolKey::Up => mv(0.0, -inc),
+ ToolKey::Down => mv(0.0, inc),
+ ToolKey::Delete | ToolKey::Backspace => vec![Action::Exec("edit.clear".into(), json!({}))],
+ ToolKey::Escape => vec![Action::Exec("selection.set".into(), json!({"ids": []}))],
+ _ => vec![],
+ }
+ }
+
+ fn overlays(&self, _cx: &ToolContext) -> Vec {
+ match &self.drag {
+ Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
+ _ => vec![],
+ }
+ }
+
+ fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
+ if self.direct {
+ return Cursor::ArrowHollow;
+ }
+ match self.drag {
+ Drag::Move { .. } => return Cursor::Move,
+ Drag::Resize { handle, .. } => return handle_cursor(handle),
+ _ => {}
+ }
+ match handle_at(cx, p) {
+ Some(h) => handle_cursor(h),
+ None => Cursor::Arrow,
+ }
+ }
+
+ fn busy(&self) -> bool {
+ !matches!(self.drag, Drag::None)
+ }
+}
+
+fn handle_cursor(h: usize) -> Cursor {
+ match h {
+ 0 | 4 => Cursor::ResizeNwSe,
+ 2 | 6 => Cursor::ResizeNeSw,
+ 1 | 5 => Cursor::ResizeV,
+ _ => Cursor::ResizeH,
+ }
+}
diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs
new file mode 100644
index 00000000..1526c4cc
--- /dev/null
+++ b/crates/tools/src/tests.rs
@@ -0,0 +1,58 @@
+use designcraft_compose::Cache;
+use designcraft_doc::build::NewDocument;
+use designcraft_doc::{Document, ParaFormat, Selection, SpreadRef};
+use designcraft_geom::Rect;
+
+use super::*;
+
+fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> {
+ ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: false }
+}
+
+#[test]
+fn frame_tool_drag_emits_begin_preview_commit() {
+ let d = Document::new(&NewDocument::default());
+ let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
+ let cx = ctx(&d, &s, &c, &l);
+ let mut t = create("rectangleFrame");
+ assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)).is_empty());
+ let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 60.0));
+ assert!(matches!(&a[0], Action::Begin(_)));
+ match &a[1] {
+ Action::Preview(cmd, p) => {
+ assert_eq!(cmd, "frame.create");
+ assert_eq!(p["rect"], serde_json::json!([10.0, 10.0, 110.0, 60.0]));
+ assert_eq!(p["content"], "graphic");
+ }
+ other => panic!("{other:?}"),
+ }
+ assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]);
+}
+
+#[test]
+fn selection_click_and_marquee() {
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "x", ParaFormat::default()).unwrap();
+ let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
+ let cx = ctx(&d, &s, &c, &l);
+ let mut t = create("selection");
+ let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
+ assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [fid.0], "content": false}))]);
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
+ // Marquee from empty space.
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0));
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 300.0));
+ let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0));
+ assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [fid.0], "add": false}))]);
+}
+
+#[test]
+fn resize_rect_modifiers() {
+ let r = Rect::new(0.0, 0.0, 100.0, 50.0);
+ assert_eq!(select::resize_rect(r, 4, designcraft_geom::Point::new(200.0, 80.0), Mods::default()), Rect::new(0.0, 0.0, 200.0, 80.0));
+ let p = select::resize_rect(r, 4, designcraft_geom::Point::new(200.0, 60.0), Mods { shift: true, ..Default::default() });
+ assert_eq!(p, Rect::new(0.0, 0.0, 200.0, 100.0));
+ let c = select::resize_rect(r, 3, designcraft_geom::Point::new(150.0, 25.0), Mods { alt: true, ..Default::default() });
+ assert_eq!(c, Rect::new(-50.0, 0.0, 150.0, 50.0));
+}
diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs
new file mode 100644
index 00000000..4de65bc0
--- /dev/null
+++ b/crates/tools/src/text.rs
@@ -0,0 +1,117 @@
+//! Type tool (T): click in a frame to place the caret, drag in a frame to select text, drag on
+//! empty canvas to draw a new text frame. Keys move the caret / delete; typing goes through
+//! `text.insert` (sent by the UI as text input).
+
+use designcraft_geom::Point;
+use serde_json::json;
+
+use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json};
+
+#[derive(Default)]
+pub struct TypeTool {
+ start: Option,
+ selecting: Option,
+ drawing: bool,
+}
+
+impl Tool for TypeTool {
+ fn id(&self) -> &'static str {
+ "type"
+ }
+
+ fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ match ev.kind {
+ PointerKind::Down | PointerKind::DoubleClick => {
+ self.start = Some(ev.pos);
+ self.drawing = false;
+ self.selecting = None;
+ if let Some((_, id)) = cx.hit(ev.pos)
+ && let Some(it) = cx.doc.item(id)
+ && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. })
+ {
+ let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
+ self.selecting = Some(id.0);
+ let cmd = if ev.kind == PointerKind::DoubleClick {
+ "text.selectWord"
+ } else if ev.mods.shift {
+ "text.extendTo"
+ } else {
+ "text.placeCaret"
+ };
+ return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))];
+ }
+ vec![]
+ }
+ PointerKind::Drag => {
+ let Some(a) = self.start else { return vec![] };
+ if let Some(fid) = self.selecting {
+ let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
+ return vec![Action::Exec("text.extendTo".into(), json!({"frame": fid, "point": [sp.x, sp.y]}))];
+ }
+ if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing {
+ return vec![];
+ }
+ let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
+ let off = cx.layout.offset(sr);
+ let r = drag_rect(sa, ev.pos - off, ev.mods);
+ let mut out = vec![];
+ if !self.drawing {
+ self.drawing = true;
+ out.push(Action::Begin("Create Text Frame".into()));
+ }
+ out.push(Action::Preview(
+ "frame.create".into(),
+ json!({"spread": spread_json(sr), "shape": "rectangle", "content": "text", "rect": rect_json(r), "caret": true}),
+ ));
+ out
+ }
+ PointerKind::Up => {
+ self.start = None;
+ self.selecting = None;
+ if std::mem::take(&mut self.drawing) {
+ return vec![Action::Commit];
+ }
+ vec![]
+ }
+ PointerKind::Move => vec![],
+ }
+ }
+
+ fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec {
+ if cx.selection.text.is_none() {
+ return vec![];
+ }
+ let mv = |dir: &str| {
+ vec![Action::Exec("text.move".into(), json!({"dir": dir, "extend": mods.shift, "word": mods.alt || mods.ctrl, "far": mods.cmd}))]
+ };
+ match key {
+ ToolKey::Left => mv("left"),
+ ToolKey::Right => mv("right"),
+ ToolKey::Up => mv("up"),
+ ToolKey::Down => mv("down"),
+ ToolKey::Home => mv("lineStart"),
+ ToolKey::End => mv("lineEnd"),
+ ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))],
+ ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))],
+ ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))],
+ ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))],
+ ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))],
+ _ => vec![],
+ }
+ }
+
+ fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
+ match cx.hit(p) {
+ Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text,
+ _ => Cursor::Text,
+ }
+ }
+
+ fn busy(&self) -> bool {
+ self.drawing
+ }
+
+ fn wants_text(&self, cx: &ToolContext) -> bool {
+ cx.selection.text.is_some()
+ }
+}
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
new file mode 100644
index 00000000..3cb2c374
--- /dev/null
+++ b/crates/ui-egui/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "designcraft-ui-egui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it"
+
+[lints]
+workspace = true
+
+[dependencies]
+designcraft-geom = { workspace = true }
+designcraft-color = { workspace = true }
+designcraft-doc = { workspace = true }
+designcraft-fonts = { workspace = true }
+designcraft-compose = { workspace = true }
+designcraft-render = { workspace = true }
+designcraft-tools = { workspace = true }
+designcraft-engine = { workspace = true }
+egui = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
new file mode 100644
index 00000000..b9693f4a
--- /dev/null
+++ b/crates/ui-egui/src/canvas.rs
@@ -0,0 +1,831 @@
+//! The document canvas: pasteboard, spreads (rendered by `designcraft-render`), guides, frame edges,
+//! selection, text ports and threads, the text caret, rulers, and pointer/keyboard input.
+
+use designcraft_compose as compose;
+use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef};
+use designcraft_engine::doc::Selection;
+use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2};
+use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey};
+use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
+use serde_json::{Value, json};
+
+use crate::{DesignApp, ScreenMode, View, theme::Tokens};
+
+pub const RULER: f32 = 17.0;
+
+/// Canvas ↔ screen transform.
+#[derive(Clone, Copy, Debug)]
+pub struct Xf {
+ pub min: Pos2,
+ pub origin: Point,
+ pub zoom: f64,
+}
+
+impl Xf {
+ pub fn new(rect: Rect, v: &View) -> Self {
+ Xf { min: rect.min, origin: v.origin, zoom: v.zoom }
+ }
+ pub fn to_screen(&self, p: Point) -> Pos2 {
+ pos2(self.min.x + ((p.x - self.origin.x) * self.zoom) as f32, self.min.y + ((p.y - self.origin.y) * self.zoom) as f32)
+ }
+ pub fn to_canvas(&self, p: Pos2) -> Point {
+ Point::new(self.origin.x + (p.x - self.min.x) as f64 / self.zoom, self.origin.y + (p.y - self.min.y) as f64 / self.zoom)
+ }
+ pub fn rect(&self, r: DRect) -> Rect {
+ Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
+ }
+ /// Canvas → screen affine (for vello rendering: pixels = points × ppp).
+ pub fn affine(&self, ppp: f64, area_min: Pos2) -> Affine {
+ let dx = (self.min.x - area_min.x) as f64;
+ let dy = (self.min.y - area_min.y) as f64;
+ Affine::scale(ppp) * Affine::translate((dx, dy)) * Affine::scale(self.zoom) * Affine::translate((-self.origin.x, -self.origin.y))
+ }
+}
+
+fn c32(c: [u8; 3]) -> Color32 {
+ Color32::from_rgb(c[0], c[1], c[2])
+}
+
+/// Fit the current spread (or page) in the canvas.
+pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) {
+ let Some(st) = app.session.active() else { return };
+ let layout = CanvasLayout::new(&st.doc, st.editing_parents);
+ let cur = current_slot(app, &layout).unwrap_or(0);
+ let Some(slot) = layout.slots.get(cur) else { return };
+ let mut b = slot.bounds;
+ if what == "page"
+ && let Some(sp) = st.doc.spread(slot.spread)
+ {
+ let center = app.view().map(|v| v.origin.x + rect.width() as f64 / 2.0 / v.zoom).unwrap_or(0.0);
+ let pi = sp.page_at_x(center - slot.offset.x).unwrap_or(0);
+ b = sp.pages[pi].bounds() + slot.offset;
+ }
+ if what == "all" {
+ b = layout.slots.iter().map(|s| s.bounds).reduce(|a, b| a.union(b)).unwrap_or(b);
+ }
+ let pad = 40.0;
+ let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0);
+ let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom);
+ if let Some(v) = app.view_mut() {
+ *v = View { zoom, origin, fitted: true };
+ }
+}
+
+/// Index of the slot nearest the view centre.
+pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option {
+ let v = app.view()?;
+ let r = app.canvas_rect?;
+ let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom);
+ layout
+ .slots
+ .iter()
+ .enumerate()
+ .min_by(|a, b| {
+ let da = (a.1.bounds.center().y - c.y).abs();
+ let db = (b.1.bounds.center().y - c.y).abs();
+ da.total_cmp(&db)
+ })
+ .map(|(i, _)| i)
+}
+
+/// Absolute page index under the view centre.
+pub fn current_page(app: &DesignApp) -> Option {
+ let st = app.session.active()?;
+ let layout = CanvasLayout::new(&st.doc, st.editing_parents);
+ let i = current_slot(app, &layout)?;
+ let slot = layout.slots.get(i)?;
+ let SpreadRef::Doc(si) = slot.spread else { return Some(0) };
+ let v = app.view()?;
+ let r = app.canvas_rect?;
+ let cx = v.origin.x + r.width() as f64 / 2.0 / v.zoom - slot.offset.x;
+ let pi = st.doc.spreads[si].page_at_x(cx).unwrap_or(0);
+ Some(st.doc.first_page_of_spread(si) + pi)
+}
+
+/// Scroll so absolute page `abs` is centred (keeps zoom).
+pub fn go_to_page(app: &mut DesignApp, abs: usize) {
+ let Some(st) = app.session.active() else { return };
+ let layout = CanvasLayout::new(&st.doc, false);
+ let Some(pr) = layout.page_rect(&st.doc, abs) else { return };
+ let Some(rect) = app.canvas_rect else { return };
+ if let Some(v) = app.view_mut() {
+ v.origin = Point::new(pr.center().x - rect.width() as f64 / 2.0 / v.zoom, pr.center().y - rect.height() as f64 / 2.0 / v.zoom);
+ }
+}
+
+pub fn zoom_at(app: &mut DesignApp, screen: Pos2, factor: f64) {
+ let Some(rect) = app.canvas_rect else { return };
+ let Some(v) = app.view_mut() else { return };
+ let xf = Xf::new(rect, v);
+ let c = xf.to_canvas(screen);
+ let nz = (v.zoom * factor).clamp(0.05, 40.0);
+ v.zoom = nz;
+ v.origin = Point::new(c.x - (screen.x - rect.min.x) as f64 / nz, c.y - (screen.y - rect.min.y) as f64 / nz);
+}
+
+pub fn set_zoom(app: &mut DesignApp, z: f64) {
+ let Some(rect) = app.canvas_rect else { return };
+ let cur = app.view().map(|v| v.zoom).unwrap_or(1.0);
+ zoom_at(app, rect.center(), z / cur);
+}
+
+pub fn apply_view_request(app: &mut DesignApp, p: &Value) {
+ if let Some(d) = p.get("pan").and_then(Value::as_array) {
+ let (dx, dy) = (d.first().and_then(Value::as_f64).unwrap_or(0.0), d.get(1).and_then(Value::as_f64).unwrap_or(0.0));
+ if let Some(v) = app.view_mut() {
+ v.origin = Point::new(v.origin.x - dx / v.zoom, v.origin.y - dy / v.zoom);
+ }
+ }
+ if let (Some(a), Some(rect)) = (p.get("zoomAt").and_then(Value::as_array), app.canvas_rect)
+ && let Some(v) = app.view()
+ {
+ let c = Point::new(a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0));
+ let xf = Xf::new(rect, v);
+ let s = xf.to_screen(c);
+ zoom_at(app, s, p.get("factor").and_then(Value::as_f64).unwrap_or(2.0));
+ }
+}
+
+fn mods(i: &egui::InputState, space: bool) -> Mods {
+ Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command, ctrl: i.modifiers.ctrl, space }
+}
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let full = ui.available_rect_before_wrap();
+ let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation;
+ let rect = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
+ app.canvas_rect = Some(rect);
+ let resp = ui.allocate_rect(full, Sense::click_and_drag());
+ if !app.view().is_some_and(|v| v.fitted) {
+ fit(app, rect, "spread");
+ }
+ handle_input(app, ui, &resp, rect);
+ let Some(st) = app.session.active() else { return };
+ let v = *app.view().expect("view");
+ let xf = Xf::new(rect, &v);
+ let doc = st.doc.clone();
+ let layout = CanvasLayout::new(&doc, st.editing_parents);
+ let painter = ui.painter_at(rect);
+ let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation);
+ let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard };
+ painter.rect_filled(rect, 0.0, bg);
+ // Page shadows.
+ for slot in &layout.slots {
+ let r = xf.rect(slot.bounds);
+ painter.rect_filled(r.translate(vec2(3.0, 3.0)), 0.0, Color32::from_black_alpha(110));
+ }
+ // Rendered content.
+ render_texture(app, ui.ctx(), rect, &xf, &layout, preview);
+ if let Some(tex) = &app.canvas.texture {
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+ if preview {
+ // Preview: only page areas (trim) show content.
+ for slot in &layout.slots {
+ let r = xf.rect(slot.bounds);
+ let ruv = Rect::from_min_max(
+ pos2((r.min.x - rect.min.x) / rect.width(), (r.min.y - rect.min.y) / rect.height()),
+ pos2((r.max.x - rect.min.x) / rect.width(), (r.max.y - rect.min.y) / rect.height()),
+ );
+ painter.with_clip_rect(r.intersect(rect)).image(tex.id(), r, ruv, Color32::WHITE);
+ }
+ } else {
+ painter.image(tex.id(), rect, uv, Color32::WHITE);
+ }
+ }
+ if !preview {
+ draw_guides(app, &painter, &xf, &doc, &layout, &t);
+ draw_frames(app, &painter, &xf, &doc, &layout);
+ }
+ draw_selection(app, &painter, &xf, &doc, &layout);
+ draw_tool_overlays(app, &painter, &xf);
+ if rulers {
+ draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t);
+ }
+ // Cursor.
+ if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) {
+ let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text();
+ let c = if space { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) };
+ ui.ctx().set_cursor_icon(cursor_icon(c));
+ }
+}
+
+fn cursor_icon(c: Cursor) -> egui::CursorIcon {
+ use egui::CursorIcon as C;
+ match c {
+ Cursor::Arrow | Cursor::ArrowHollow => C::Default,
+ Cursor::Move => C::Move,
+ Cursor::Crosshair => C::Crosshair,
+ Cursor::ResizeH => C::ResizeHorizontal,
+ Cursor::ResizeV => C::ResizeVertical,
+ Cursor::ResizeNwSe => C::ResizeNwSe,
+ Cursor::ResizeNeSw => C::ResizeNeSw,
+ Cursor::Rotate => C::Alias,
+ Cursor::Pen => C::Crosshair,
+ Cursor::Text => C::Text,
+ Cursor::Hand => C::Grab,
+ Cursor::HandGrab => C::Grabbing,
+ Cursor::ZoomIn => C::ZoomIn,
+ Cursor::ZoomOut => C::ZoomOut,
+ Cursor::Eyedropper => C::Crosshair,
+ Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy,
+ Cursor::NotAllowed => C::NotAllowed,
+ }
+}
+
+fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, layout: &CanvasLayout, preview: bool) {
+ let Some(st) = app.session.active() else { return };
+ let ppp = ctx.pixels_per_point() as f64;
+ let w = (rect.width() as f64 * ppp).round().max(1.0) as u32;
+ let h = (rect.height() as f64 * ppp).round().max(1.0) as u32;
+ let doc_ptr = std::sync::Arc::as_ptr(&st.doc) as u64;
+ let key = (st.uid, doc_ptr, xf.zoom.to_bits(), xf.origin.x.to_bits() ^ xf.origin.y.to_bits().rotate_left(17), st.revision, w, h, preview as u8);
+ if app.canvas.key == Some(key) && app.canvas.texture.is_some() {
+ return;
+ }
+ let t0 = crate::now_ms();
+ let placed: Vec =
+ layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, offset: s.offset }).collect();
+ let view = xf.affine(ppp, rect.min);
+ let opts = designcraft_render::RenderOptions { printing_only: preview, greek_below_px: 3.0 * ppp, ..Default::default() };
+ let img = app.canvas.renderer.render(&st.doc, &app.session.cache, &placed, w, h, view, &opts);
+ let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], &img.pixels);
+ match &mut app.canvas.texture {
+ Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR),
+ None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)),
+ }
+ app.canvas.key = Some(key);
+ app.perf.render_ms = crate::now_ms() - t0;
+}
+
+fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, gap: f32) {
+ painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap));
+}
+
+fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
+ let s = &doc.settings;
+ for slot in &layout.slots {
+ let Some(sp) = doc.spread(slot.spread) else { continue };
+ let off = slot.offset;
+ // Bleed: one rectangle around the spread (inside bleed only applies at the spread's outer edges).
+ let b = s.bleed;
+ if app.ui.guides && b.iter().any(|v| *v > 0.0) && !sp.pages.is_empty() {
+ let first = &sp.pages[0];
+ let last = &sp.pages[sp.pages.len() - 1];
+ let l = if first.side == PageSide::Left { b[3] } else { b[2] };
+ let r = if last.side == PageSide::Right || last.side == PageSide::Single { b[3] } else { b[2] };
+ let sb = sp.bounds() + off;
+ let br = DRect::new(sb.x0 - l, sb.y0 - b[0], sb.x1 + r, sb.y1 + b[1]);
+ painter.rect_stroke(xf.rect(br), 0.0, Stroke::new(1.0, c32(s.bleed_color)), StrokeKind::Middle);
+ }
+ for p in &sp.pages {
+ let pr = p.bounds() + off;
+ if !app.ui.guides {
+ continue;
+ }
+ // Baseline grid.
+ if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold {
+ let g = &s.baseline_grid;
+ let mut y = g.start;
+ while y < p.height {
+ let a = xf.to_screen(Point::new(pr.x0, pr.y0 + y));
+ let bb = xf.to_screen(Point::new(pr.x1, pr.y0 + y));
+ painter.line_segment([a, bb], Stroke::new(1.0, c32(g.color).gamma_multiply(0.8)));
+ y += g.increment.max(1.0);
+ }
+ }
+ // Margins (magenta) and columns (violet).
+ let m = p.margin_rect() + off;
+ painter.rect_stroke(xf.rect(m), 0.0, Stroke::new(1.0, c32(s.margin_color)), StrokeKind::Middle);
+ let cols = p.column_rects();
+ if cols.len() > 1 {
+ for (i, c) in cols.iter().enumerate() {
+ let c = *c + off;
+ let col = Stroke::new(1.0, c32(s.column_color));
+ if i > 0 {
+ painter.line_segment([xf.to_screen(Point::new(c.x0, c.y0)), xf.to_screen(Point::new(c.x0, c.y1))], col);
+ }
+ if i + 1 < cols.len() {
+ painter.line_segment([xf.to_screen(Point::new(c.x1, c.y0)), xf.to_screen(Point::new(c.x1, c.y1))], col);
+ }
+ }
+ }
+ for g in &p.guides {
+ let (a, bb) = match g.orientation {
+ designcraft_doc::Orientation::Horizontal => (Point::new(pr.x0, g.position + off.y), Point::new(pr.x1, g.position + off.y)),
+ designcraft_doc::Orientation::Vertical => (Point::new(g.position + off.x, pr.y0), Point::new(g.position + off.x, pr.y1)),
+ };
+ painter.line_segment([xf.to_screen(a), xf.to_screen(bb)], Stroke::new(1.0, Color32::from_rgb(74, 227, 255)));
+ }
+ }
+ }
+ let _ = t;
+}
+
+/// Spread-space → canvas transform for an item at `loc`.
+fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> {
+ let loc = doc.find(id)?;
+ let off = layout.offset(loc.spread);
+ Some((Affine::translate(off) * doc.parent_xf(&loc), ""))
+}
+
+fn path_screen(it: &Item, xf: &Xf, a: Affine) -> Vec> {
+ let bp = it.path.to_bezpath();
+ let full = a * it.xf;
+ let mut out = Vec::new();
+ let mut cur: Vec = Vec::new();
+ designcraft_geom::kurbo::flatten(&bp, 0.25 / xf.zoom.max(0.01), |el| {
+ use designcraft_geom::PathEl::*;
+ match el {
+ MoveTo(p) => {
+ if cur.len() > 1 {
+ out.push(std::mem::take(&mut cur));
+ }
+ cur.clear();
+ cur.push(xf.to_screen(full * p));
+ }
+ LineTo(p) => cur.push(xf.to_screen(full * p)),
+ ClosePath => {
+ if let Some(f) = cur.first().copied() {
+ cur.push(f);
+ }
+ }
+ _ => {}
+ }
+ });
+ if cur.len() > 1 {
+ out.push(cur);
+ }
+ out
+}
+
+fn draw_frames(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
+ if !app.ui.frame_edges {
+ return;
+ }
+ for slot in &layout.slots {
+ let Some(sp) = doc.spread(slot.spread) else { continue };
+ let a = Affine::translate(slot.offset);
+ // Parent items on document pages: dotted edges.
+ if let SpreadRef::Doc(si) = slot.spread {
+ let first = doc.first_page_of_spread(si);
+ for (pi, page) in sp.pages.iter().enumerate() {
+ let Some((ppi, ppg)) = doc.parent_page_for(first + pi) else { continue };
+ let parent = &doc.parents[ppi];
+ let dx = page.x - parent.pages[ppg].x;
+ for it in &parent.items {
+ if parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppg) {
+ continue;
+ }
+ let col = doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::LIGHT_BLUE);
+ for poly in path_screen(it, xf, a * Affine::translate((dx, 0.0))) {
+ for w in poly.windows(2) {
+ dashed(painter, w[0], w[1], Stroke::new(1.0, col.gamma_multiply(0.8)), 2.0, 2.0);
+ }
+ }
+ }
+ }
+ }
+ for it in &sp.items {
+ draw_item_edges(painter, xf, doc, it, a);
+ }
+ }
+}
+
+fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, a: Affine) {
+ if it.hidden || doc.layer(it.layer).is_some_and(|l| !l.visible) {
+ return;
+ }
+ let col = doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::LIGHT_BLUE);
+ if let Content::Group { items } = &it.content {
+ for c in items {
+ draw_item_edges(painter, xf, doc, c, a * it.xf);
+ }
+ return;
+ }
+ // Frames without a stroke show their edge; shapes with strokes are visible already.
+ let show = it.stroke.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_));
+ if show {
+ for poly in path_screen(it, xf, a) {
+ painter.add(egui::Shape::line(poly, Stroke::new(1.0, col.gamma_multiply(0.75))));
+ }
+ }
+ // Empty graphic frame: the X.
+ if it.object_style == designcraft_doc::BASIC_GRAPHICS_FRAME && matches!(it.content, Content::Unassigned) {
+ let r = it.inner_bounds();
+ let m = a * it.xf;
+ let p = |x: f64, y: f64| xf.to_screen(m * Point::new(x, y));
+ painter.line_segment([p(r.x0, r.y0), p(r.x1, r.y1)], Stroke::new(1.0, col.gamma_multiply(0.75)));
+ painter.line_segment([p(r.x1, r.y0), p(r.x0, r.y1)], Stroke::new(1.0, col.gamma_multiply(0.75)));
+ }
+}
+
+fn layer_color(doc: &Document, it: &Item) -> Color32 {
+ doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::from_rgb(79, 153, 255))
+}
+
+fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
+ let Some(st) = app.session.active() else { return };
+ let sel: &Selection = &st.selection;
+ // Text selection / caret.
+ if let Some(ts) = sel.text {
+ draw_text_selection(app, painter, xf, doc, layout, ts);
+ }
+ let mut union: Option = None;
+ let mut color = Color32::from_rgb(79, 153, 255);
+ for id in &sel.items {
+ let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
+ color = layer_color(doc, it);
+ for poly in path_screen(it, xf, a) {
+ painter.add(egui::Shape::line(poly, Stroke::new(1.0, color)));
+ }
+ let b = xf.rect(a.transform_rect_bbox(it.bounds()));
+ union = Some(union.map_or(b, |u| u.union(b)));
+ // Text frame ports.
+ if let Content::Text(tf) = &it.content {
+ draw_ports(app, painter, xf, doc, layout, it, tf.story, a, color);
+ }
+ if let Content::Graphic(g) = &it.content
+ && sel.content
+ {
+ let gb = xf.rect((a * it.xf).transform_rect_bbox(g.xf.transform_rect_bbox(DRect::new(0.0, 0.0, g.size.0, g.size.1))));
+ painter.rect_stroke(gb, 0.0, Stroke::new(1.0, Color32::from_rgb(196, 111, 43)), StrokeKind::Middle);
+ }
+ }
+ if let Some(u) = union
+ && sel.text.is_none()
+ {
+ if sel.items.len() > 1 {
+ painter.rect_stroke(u, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
+ }
+ let hs = 6.0;
+ for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64)) {
+ let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(hs, hs));
+ painter.rect_filled(r, 0.0, Color32::WHITE);
+ painter.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
+ }
+ // Content grabber on graphic frames.
+ if sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_))) {
+ painter.circle_stroke(u.center(), 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
+ painter.circle_stroke(u.center(), 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
+ }
+ }
+ // Threads.
+ if app.ui.text_threads || sel.items.iter().any(|i| doc.item(*i).is_some_and(|x| x.is_text_frame())) {
+ for id in &sel.items {
+ let Some(sid) = doc.item(*id).and_then(|i| i.text_frame()).map(|t| t.story) else { continue };
+ let Some(story) = doc.story(sid) else { continue };
+ for w in story.frames.windows(2) {
+ let (Some(a), Some(b)) = (port_pos(doc, layout, w[0], true), port_pos(doc, layout, w[1], false)) else { continue };
+ let col = doc.item(w[0]).map(|i| layer_color(doc, i)).unwrap_or(color);
+ painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, col));
+ }
+ }
+ }
+}
+
+/// Port location (canvas): out port at bottom-right, in port at top-left.
+fn port_pos(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId, out: bool) -> Option {
+ let it = doc.item(id)?;
+ let (a, _) = item_canvas_xf(doc, layout, id)?;
+ let r = it.inner_bounds();
+ let p = if out { Point::new(r.x1 - 10.0, r.y1) } else { Point::new(r.x0 + 10.0, r.y0) };
+ Some(a * it.xf * p)
+}
+
+#[allow(clippy::too_many_arguments)]
+fn draw_ports(
+ app: &DesignApp,
+ painter: &egui::Painter,
+ xf: &Xf,
+ doc: &Document,
+ layout: &CanvasLayout,
+ it: &Item,
+ sid: designcraft_doc::StoryId,
+ a: Affine,
+ color: Color32,
+) {
+ let Some(story) = doc.story(sid) else { return };
+ let pos = story.frames.iter().position(|f| *f == it.id).unwrap_or(0);
+ let is_last = pos + 1 == story.frames.len();
+ let cs = app.session.cache.get(doc, sid, None);
+ let overset = is_last && cs.is_overset();
+ let s = 8.0;
+ let _ = layout;
+ let r = it.inner_bounds();
+ let m = a * it.xf;
+ let inp = xf.to_screen(m * Point::new(r.x0 + 12.0, r.y0));
+ let outp = xf.to_screen(m * Point::new(r.x1 - 12.0, r.y1));
+ for (c, has_link, is_out) in [(inp, pos > 0, false), (outp, !is_last, true)] {
+ let pr = Rect::from_center_size(c, vec2(s, s));
+ painter.rect_filled(pr, 0.0, Color32::WHITE);
+ painter.rect_stroke(pr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
+ if is_out && overset {
+ let red = Color32::from_rgb(230, 20, 20);
+ painter.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.5, red));
+ painter.line_segment([c - vec2(0.0, 2.5), c + vec2(0.0, 2.5)], Stroke::new(1.5, red));
+ } else if has_link {
+ painter.add(egui::Shape::convex_polygon(vec![c + vec2(-2.0, -2.5), c + vec2(2.5, 0.0), c + vec2(-2.0, 2.5)], color, Stroke::NONE));
+ }
+ }
+}
+
+fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, ts: designcraft_doc::TextSel) {
+ let cs = app.session.cache.get(doc, ts.story, None);
+ let range = ts.range();
+ for ft in &cs.frames {
+ let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
+ let Some(it) = doc.item(ft.frame) else { continue };
+ let m = a * it.xf;
+ // Show the frame edge while typing.
+ for poly in path_screen(it, xf, a) {
+ painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6))));
+ }
+ if !range.is_empty() {
+ for l in &ft.lines {
+ let s = range.start.max(l.range.start);
+ let e = range.end.min(l.range.end);
+ if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
+ continue;
+ }
+ let x0 = caret_x(l, s);
+ let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) };
+ let q = [
+ xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)),
+ xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)),
+ xf.to_screen(m * Point::new(x1, l.baseline + l.descent)),
+ xf.to_screen(m * Point::new(x0, l.baseline + l.descent)),
+ ];
+ painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
+ }
+ }
+ }
+ if ts.is_caret()
+ && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)
+ && let Some(ft) = cs.frames.get(fi)
+ && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
+ {
+ let m = a * it.xf;
+ let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
+ if blink {
+ let p0 = xf.to_screen(m * Point::new(x, bl - asc));
+ let p1 = xf.to_screen(m * Point::new(x, bl + desc));
+ painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
+ }
+ painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
+ }
+}
+
+fn caret_x(l: &compose::Line, pos: usize) -> f64 {
+ for g in &l.glyphs {
+ if g.len > 0 && pos >= g.byte && pos < g.byte + g.len {
+ return g.x + g.adv * (pos - g.byte) as f64 / g.len as f64;
+ }
+ if g.len > 0 && g.byte >= pos {
+ return g.x;
+ }
+ }
+ l.end_x
+}
+
+fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
+ let ov = app.session.overlays(app.view_info());
+ for o in ov {
+ match o {
+ Overlay::Marquee(r) => {
+ let r = xf.rect(r);
+ painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(80, 140, 255, 30));
+ for (a, b) in [
+ (r.left_top(), r.right_top()),
+ (r.right_top(), r.right_bottom()),
+ (r.right_bottom(), r.left_bottom()),
+ (r.left_bottom(), r.left_top()),
+ ] {
+ dashed(painter, a, b, Stroke::new(1.0, Color32::from_gray(200)), 3.0, 3.0);
+ }
+ }
+ Overlay::Measure { p, text } => {
+ let s = xf.to_screen(p) + vec2(14.0, 14.0);
+ let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), Color32::WHITE);
+ let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0));
+ painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(70, 70, 70, 230));
+ painter.galley(r.min + vec2(5.0, 3.0), g, Color32::WHITE);
+ }
+ Overlay::Line { a, b, color, dashed: d } => {
+ let st = Stroke::new(1.0, c32(color));
+ if d {
+ dashed(painter, xf.to_screen(a), xf.to_screen(b), st, 3.0, 3.0);
+ } else {
+ painter.line_segment([xf.to_screen(a), xf.to_screen(b)], st);
+ }
+ }
+ Overlay::Guide { a, b } => {
+ painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, Color32::from_rgb(0, 200, 83)));
+ }
+ Overlay::Path { .. } => {}
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
+ let painter = ui.painter_at(full);
+ let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y));
+ let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y));
+ painter.rect_filled(top, 0.0, t.ruler);
+ painter.rect_filled(left, 0.0, t.ruler);
+ painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler);
+ painter.line_segment([pos2(rect.min.x, rect.min.y - 0.5), pos2(full.max.x, rect.min.y - 0.5)], Stroke::new(1.0, t.divider));
+ painter.line_segment([pos2(rect.min.x - 0.5, rect.min.y), pos2(rect.min.x - 0.5, full.max.y)], Stroke::new(1.0, t.divider));
+ // Zero-point crosshair box.
+ let zc = Rect::from_min_max(full.min, rect.min).center();
+ painter.line_segment([zc - vec2(4.0, 0.0), zc + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
+ painter.line_segment([zc - vec2(0.0, 4.0), zc + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
+ // Origin: top-left of the current spread's first page.
+ let Some(i) = current_slot(app, layout) else { return };
+ let slot = &layout.slots[i];
+ let origin = Point::new(slot.bounds.x0, slot.bounds.y0);
+ let unit = doc.settings.horizontal_units;
+ let (major, sub) = unit.ruler_ticks(xf.zoom);
+ let font = egui::FontId::proportional(9.0);
+ let tick = Stroke::new(1.0, t.ruler_tick);
+ // Horizontal.
+ let c0 = xf.to_canvas(top.min).x - origin.x;
+ let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x;
+ let mut k = (c0 / major).floor() as i64;
+ while (k as f64) * major <= c1 {
+ let x0 = k as f64 * major;
+ for s in 0..sub {
+ let x = x0 + major * s as f64 / sub as f64;
+ let sx = xf.to_screen(Point::new(origin.x + x, 0.0)).x;
+ let len = if s == 0 {
+ RULER
+ } else if sub % 2 == 0 && s == sub / 2 {
+ 7.0
+ } else {
+ 4.0
+ };
+ painter.line_segment([pos2(sx, top.max.y - len), pos2(sx, top.max.y)], tick);
+ if s == 0 {
+ let v = unit.from_pt(x);
+ painter.text(pos2(sx + 2.0, top.min.y + 1.0), egui::Align2::LEFT_TOP, fmt_tick(v), font.clone(), t.ruler_tick);
+ }
+ }
+ k += 1;
+ }
+ // Vertical.
+ let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y;
+ let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y;
+ let mut k = (c0 / major).floor() as i64;
+ while (k as f64) * major <= c1 {
+ let y0 = k as f64 * major;
+ for s in 0..sub {
+ let y = y0 + major * s as f64 / sub as f64;
+ let sy = xf.to_screen(Point::new(0.0, origin.y + y)).y;
+ let len = if s == 0 {
+ RULER
+ } else if sub % 2 == 0 && s == sub / 2 {
+ 7.0
+ } else {
+ 4.0
+ };
+ painter.line_segment([pos2(left.max.x - len, sy), pos2(left.max.x, sy)], tick);
+ if s == 0 {
+ // Stacked digits like InDesign's vertical ruler.
+ let label = fmt_tick(unit.from_pt(y));
+ for (j, ch) in label.chars().enumerate() {
+ painter.text(pos2(left.min.x + 3.0, sy + 2.0 + j as f32 * 8.5), egui::Align2::LEFT_TOP, ch, font.clone(), t.ruler_tick);
+ }
+ }
+ }
+ k += 1;
+ }
+ // Pointer position markers.
+ if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p)) {
+ let m = Stroke::new(1.0, t.text_dim);
+ dashed(&painter, pos2(p.x, top.min.y), pos2(p.x, top.max.y), m, 1.5, 1.5);
+ dashed(&painter, pos2(left.min.x, p.y), pos2(left.max.x, p.y), m, 1.5, 1.5);
+ }
+}
+
+fn fmt_tick(v: f64) -> String {
+ let r = v.round();
+ if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") }
+}
+
+fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) {
+ let Some(v) = app.view().copied() else { return };
+ let xf = Xf::new(rect, &v);
+ let wants_text = app.session.wants_text();
+ let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().egui_wants_keyboard_input();
+ // Scroll / zoom.
+ if resp.hovered() {
+ let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos()));
+ if let Some(hp) = hover {
+ if zoom_delta != 1.0 {
+ zoom_at(app, hp, zoom_delta as f64);
+ } else if m.command && scroll.y != 0.0 {
+ zoom_at(app, hp, (1.0 + scroll.y as f64 * 0.003).clamp(0.5, 2.0));
+ } else if scroll != egui::Vec2::ZERO
+ && let Some(v) = app.view_mut()
+ {
+ v.origin = Point::new(v.origin.x - scroll.x as f64 / v.zoom, v.origin.y - scroll.y as f64 / v.zoom);
+ }
+ }
+ }
+ // Space-drag = hand.
+ if space && resp.dragged() {
+ let d = resp.drag_delta();
+ if let Some(v) = app.view_mut() {
+ v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom);
+ }
+ return;
+ }
+ let m = ui.input(|i| mods(i, space));
+ let vi = app.view_info();
+ let pos = |p: Pos2| xf.to_canvas(p);
+ let mut events: Vec = Vec::new();
+ let down_id = egui::Id::new("canvas_pointer_down");
+ let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false);
+ let (pressed, released, origin, latest, dbl) = ui.input(|i| {
+ (
+ i.pointer.primary_pressed(),
+ i.pointer.primary_released(),
+ i.pointer.press_origin(),
+ i.pointer.latest_pos(),
+ i.pointer.button_double_clicked(egui::PointerButton::Primary),
+ )
+ });
+ if pressed
+ && !space
+ && let Some(o) = origin.filter(|o| rect.contains(*o) && resp.hovered())
+ {
+ events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m });
+ down = true;
+ }
+ if down
+ && resp.dragged()
+ && resp.drag_delta() != egui::Vec2::ZERO
+ && let Some(p) = latest
+ {
+ events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m });
+ }
+ if down && released {
+ let p = latest.unwrap_or(rect.center());
+ events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m });
+ down = false;
+ } else if !down && let Some(p) = resp.hover_pos() {
+ events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m });
+ }
+ ui.data_mut(|d| d.insert_temp(down_id, down));
+ for e in events {
+ if let Err(err) = app.session.pointer(&e, vi) {
+ app.status(err.to_string());
+ }
+ }
+ app.after_engine();
+ if resp.clicked() || resp.drag_started() {
+ resp.request_focus();
+ }
+ // Keyboard for the active tool.
+ if ui.ctx().egui_wants_keyboard_input() && !resp.has_focus() {
+ return;
+ }
+ let evs = ui.input(|i| i.events.clone());
+ for e in evs {
+ match e {
+ egui::Event::Text(t) if wants_text => {
+ let _ = app.run("text.insert", json!({"text": t}));
+ }
+ egui::Event::Paste(t) if wants_text => {
+ let _ = app.run("text.insert", json!({"text": t}));
+ }
+ egui::Event::Key { key, pressed: true, modifiers, .. } => {
+ let k = match key {
+ egui::Key::ArrowLeft => Some(ToolKey::Left),
+ egui::Key::ArrowRight => Some(ToolKey::Right),
+ egui::Key::ArrowUp => Some(ToolKey::Up),
+ egui::Key::ArrowDown => Some(ToolKey::Down),
+ egui::Key::Enter => Some(ToolKey::Enter),
+ egui::Key::Escape => Some(ToolKey::Escape),
+ egui::Key::Backspace => Some(ToolKey::Backspace),
+ egui::Key::Delete => Some(ToolKey::Delete),
+ egui::Key::Tab => Some(ToolKey::Tab),
+ egui::Key::Home => Some(ToolKey::Home),
+ egui::Key::End => Some(ToolKey::End),
+ _ => None,
+ };
+ if let Some(k) = k {
+ let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false };
+ let handled = app.session.tool_key(k, m, vi).unwrap_or(false);
+ if !handled && k == ToolKey::Escape {
+ let _ = app.run("edit.deselectAll", json!({}));
+ }
+ app.after_engine();
+ }
+ }
+ _ => {}
+ }
+ }
+ let _ = DVec2::ZERO;
+}
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
new file mode 100644
index 00000000..bd1728e5
--- /dev/null
+++ b/crates/ui-egui/src/chrome.rs
@@ -0,0 +1,527 @@
+//! Window chrome: application bar (with menus), Control panel, document tabs, status bar, start screen.
+
+use designcraft_doc::{Align, Content};
+use designcraft_geom::Unit;
+use egui::{Color32, Sense, Stroke, vec2};
+use serde_json::json;
+
+use crate::panels::{self, SelInfo};
+use crate::theme::{Tokens, semibold};
+use crate::widgets::{caption, measure, number};
+use crate::{DesignApp, ScreenMode, icons};
+
+pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let left = if app.integrated_titlebar { 78 } else { 8 };
+ egui::Panel::top("app_bar")
+ .exact_size(36.0)
+ .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
+ .show(ui, |ui| {
+ ui.horizontal_centered(|ui| {
+ // Brand mark: our own "Dc" tile.
+ let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
+ ui.painter().rect_filled(r, 5.0, Color32::from_rgb(214, 44, 104));
+ ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "Dc", semibold(12.5), Color32::WHITE);
+ ui.add_space(4.0);
+ if icons::button(ui, "home", 24.0, app.session.active().is_none(), "Home").clicked() {
+ app.session_home();
+ }
+ ui.add_space(4.0);
+ ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
+ ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
+ ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
+ ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
+ crate::menus::menu_bar(app, ui);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.add_space(4.0);
+ icons::button(ui, "search", 22.0, false, "Search");
+ ui.menu_button(egui::RichText::new("Essentials ▾").color(t.text), |ui| {
+ for w in ["Essentials", "Advanced", "Book", "Digital Publishing", "Printing and Proofing", "Typography"] {
+ if ui.button(w).clicked() {
+ ui.close();
+ }
+ }
+ });
+ if ui
+ .add(egui::Button::new(egui::RichText::new("Share").color(Color32::WHITE)).fill(t.accent_strong).corner_radius(12.0))
+ .clicked()
+ {
+ app.status("Sharing is not part of DesignCraft — export a PDF or package instead.");
+ }
+ ui.add_space(8.0);
+ // Screen mode.
+ let mode = app.ui.screen_mode;
+ for (m, icon, tip) in [(ScreenMode::Preview, "screen-preview", "Preview (W)"), (ScreenMode::Normal, "screen-normal", "Normal")] {
+ if icons::button(ui, icon, 22.0, mode == m, tip).clicked() {
+ app.ui.screen_mode = m;
+ }
+ }
+ ui.add_space(6.0);
+ // Zoom dropdown.
+ let z = app.view().map(|v| v.zoom).unwrap_or(1.0);
+ ui.menu_button(egui::RichText::new(format!("{:.0}% ▾", z * 100.0)).color(t.text), |ui| {
+ for p in [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0] {
+ if ui.button(format!("{:.0}%", p * 100.0)).clicked() {
+ let _ = app.run("view.zoom", json!({"zoom": p}));
+ ui.close();
+ }
+ }
+ });
+ });
+ });
+ });
+}
+
+impl DesignApp {
+ fn session_home(&mut self) {
+ self.ui.status = "Home".into();
+ }
+}
+
+fn vsep(ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let (r, _) = ui.allocate_exact_size(vec2(9.0, 46.0), Sense::hover());
+ ui.painter().line_segment([r.center_top() + vec2(0.0, 4.0), r.center_bottom() - vec2(0.0, 4.0)], Stroke::new(1.0, t.divider));
+}
+
+/// Two-row context-sensitive Control panel.
+pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ egui::Panel::top("control_bar")
+ .exact_size(64.0)
+ .frame(
+ egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)),
+ )
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ let text_mode = app.session.tool_id() == "type" || app.session.active().is_some_and(|d| d.selection.text.is_some());
+ if text_mode {
+ control_text(app, ui);
+ } else {
+ control_object(app, ui);
+ }
+ });
+ });
+}
+
+fn control_object(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
+ let info = panels::sel_info(app);
+ // Reference point proxy.
+ let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover());
+ let t = Tokens::get(ui.ctx());
+ icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(26.0, 26.0)), "ref-point", t.icon);
+ ui.painter().rect_filled(egui::Rect::from_center_size(r.center() + vec2(-7.8, -7.8), vec2(4.0, 4.0)), 0.0, t.text_strong);
+ let (x, y, w, h) = match &info {
+ Some(SelInfo { page_rect, .. }) => (Some(page_rect.x0), Some(page_rect.y0), Some(page_rect.width()), Some(page_rect.height())),
+ None => (None, None, None, None),
+ };
+ egui::Grid::new("ctl_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
+ caption(ui, "X:");
+ if let Some(v) = measure(ui, "cx", x, units, 64.0) {
+ let _ = app.run("transform.set", json!({"x": v}));
+ }
+ caption(ui, "W:");
+ if let Some(v) = measure(ui, "cw", w, units, 64.0) {
+ let _ = app.run("transform.set", json!({"width": v}));
+ }
+ ui.end_row();
+ caption(ui, "Y:");
+ if let Some(v) = measure(ui, "cy", y, units, 64.0) {
+ let _ = app.run("transform.set", json!({"y": v}));
+ }
+ caption(ui, "H:");
+ if let Some(v) = measure(ui, "ch", h, units, 64.0) {
+ let _ = app.run("transform.set", json!({"height": v}));
+ }
+ ui.end_row();
+ });
+ vsep(ui);
+ // Rotate / flip.
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ if icons::button(ui, "tool-rotate", 20.0, false, "Rotate 90° Counterclockwise").clicked() {
+ let _ = app.run("transform.rotate", json!({"angle": 90}));
+ }
+ if ui.small_button("⇋").on_hover_text("Flip Horizontal").clicked() {
+ let _ = app.run("transform.flip", json!({"axis": "horizontal"}));
+ }
+ });
+ ui.horizontal(|ui| {
+ if ui.small_button("↻").on_hover_text("Rotate 90° Clockwise").clicked() {
+ let _ = app.run("transform.rotate", json!({"angle": -90}));
+ }
+ if ui.small_button("⇵").on_hover_text("Flip Vertical").clicked() {
+ let _ = app.run("transform.flip", json!({"axis": "vertical"}));
+ }
+ });
+ });
+ vsep(ui);
+ // Fill / stroke.
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ caption(ui, "Fill");
+ panels::swatch_picker(app, ui, "ctlfill", info.as_ref().map(|i| i.fill.clone()), |app, name| {
+ let _ = app.run("object.fill", json!({"swatch": name}));
+ });
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Stroke");
+ panels::swatch_picker(app, ui, "ctlstroke", info.as_ref().map(|i| i.stroke.clone()), |app, name| {
+ let _ = app.run("object.stroke", json!({"swatch": name}));
+ });
+ if let Some(v) = number(ui, "csw", info.as_ref().map(|i| i.stroke_weight), " pt", 46.0, 3) {
+ let _ = app.run("object.stroke", json!({"weight": v}));
+ }
+ });
+ });
+ vsep(ui);
+ // Opacity, fx.
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ caption(ui, "Opacity");
+ if let Some(v) = number(ui, "cop", info.as_ref().map(|i| i.opacity * 100.0), "%", 42.0, 0) {
+ let _ = app.run("object.opacity", json!({"opacity": v / 100.0}));
+ }
+ });
+ ui.horizontal(|ui| {
+ if icons::button(ui, "panel-effects", 20.0, info.as_ref().is_some_and(|i| i.shadow), "Drop Shadow").clicked() {
+ let _ = app.run("object.dropShadow", json!({}));
+ }
+ });
+ });
+ vsep(ui);
+ // Fitting.
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [
+ ("fit-fill", "fillProportionally", "Fill Frame Proportionally"),
+ ("fit-prop", "fitProportionally", "Fit Content Proportionally"),
+ ("fit-content", "fitContentToFrame", "Fit Content to Frame"),
+ ] {
+ if icons::button(ui, icon, 20.0, false, tip).clicked() {
+ let _ = app.run("object.fit", json!({"mode": mode}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [("fit-frame", "fitFrameToContent", "Fit Frame to Content"), ("fit-center", "centerContent", "Center Content")] {
+ if icons::button(ui, icon, 20.0, false, tip).clicked() {
+ let _ = app.run("object.fit", json!({"mode": mode}));
+ }
+ }
+ });
+ });
+ vsep(ui);
+ // Text wrap.
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [
+ ("wrap-none", "none", "No Text Wrap"),
+ ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"),
+ ("wrap-contour", "contour", "Wrap Around Object Shape"),
+ ] {
+ let on = info.as_ref().is_some_and(|i| i.wrap == mode);
+ if icons::button(ui, icon, 20.0, on, tip).clicked() {
+ let _ = app.run("object.textWrap", json!({"mode": mode}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [("wrap-jump", "jumpObject", "Jump Object"), ("wrap-next", "jumpToNextColumn", "Jump to Next Column")] {
+ let on = info.as_ref().is_some_and(|i| i.wrap == mode);
+ if icons::button(ui, icon, 20.0, on, tip).clicked() {
+ let _ = app.run("object.textWrap", json!({"mode": mode}));
+ }
+ }
+ });
+ });
+ if let Some(i) = &info
+ && let Some(cols) = i.columns
+ {
+ vsep(ui);
+ ui.vertical(|ui| {
+ caption(ui, "Columns");
+ if let Some(v) = number(ui, "ccols", Some(cols as f64), "", 36.0, 0) {
+ let _ = app.run("object.textFrameOptions", json!({"columns": v.max(1.0) as u64}));
+ }
+ });
+ }
+}
+
+fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let attrs = panels::text_attrs(app);
+ let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default();
+ let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default();
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong));
+ });
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new("¶").font(semibold(15.0)).color(t.text_dim));
+ });
+ });
+ vsep(ui);
+ ui.vertical(|ui| {
+ let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();
+ let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();
+ panels::font_family_picker(app, ui, &fam, 170.0);
+ panels::font_style_picker(app, ui, &fam, &sty, 170.0);
+ });
+ vsep(ui);
+ egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
+ caption(ui, "𝐓");
+ if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) {
+ let _ = app.run("type.char", json!({"attrs": {"size": v}}));
+ }
+ caption(ui, "VA");
+ if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) {
+ let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
+ }
+ ui.end_row();
+ caption(ui, "Ā");
+ let lead = match chars["leading"]["kind"].as_str() {
+ Some("points") => chars["leading"]["value"].as_f64(),
+ _ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)),
+ };
+ if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) {
+ let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
+ }
+ caption(ui, "IT");
+ if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) {
+ let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
+ }
+ ui.end_row();
+ });
+ vsep(ui);
+ ui.vertical(|ui| {
+ let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
+ ui.horizontal(|ui| {
+ for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] {
+ if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"align": a}}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] {
+ if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"align": a}}));
+ }
+ }
+ });
+ });
+ vsep(ui);
+ let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
+ egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
+ caption(ui, "→|");
+ if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) {
+ let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}}));
+ }
+ caption(ui, "↑¶");
+ if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) {
+ let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}}));
+ }
+ ui.end_row();
+ caption(ui, "→¶");
+ if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) {
+ let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}}));
+ }
+ caption(ui, "↓¶");
+ if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) {
+ let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}}));
+ }
+ ui.end_row();
+ });
+ vsep(ui);
+ ui.vertical(|ui| {
+ let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string();
+ let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0)
+ + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0);
+ panels::para_style_picker(app, ui, &ps, ov > 0, 170.0);
+ let hy = para["hyphenate"].as_bool().unwrap_or(true);
+ let mut h = hy;
+ if ui.checkbox(&mut h, "Hyphenate").changed() {
+ let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
+ }
+ });
+}
+
+pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
+ ui.painter().rect_filled(bar, 0.0, t.panel_darker);
+ let mut x = bar.min.x;
+ let active = app.session.active_index();
+ let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
+ let mut activate = None;
+ let mut close = None;
+ let titles: Vec<(String, bool)> = app.session.documents().iter().map(|d| (d.title(), d.is_dirty())).collect();
+ for (i, (title, dirty)) in titles.iter().enumerate() {
+ let is_active = Some(i) == active;
+ let label = if is_active {
+ format!("{}{} @ {:.0}%", if *dirty { "*" } else { "" }, title, zoom * 100.0)
+ } else {
+ format!("{}{}", if *dirty { "*" } else { "" }, title)
+ };
+ let galley = ui.painter().layout_no_wrap(label, egui::FontId::proportional(12.0), if is_active { t.text_strong } else { t.text_dim });
+ let w = galley.size().x + 44.0;
+ let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 26.0));
+ let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click());
+ ui.painter().rect_filled(r, 0.0, if is_active { t.pasteboard } else { t.tab_inactive });
+ if is_active {
+ ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(2.0, t.accent));
+ }
+ let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
+ let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click());
+ ui.painter().text(
+ cr.center(),
+ egui::Align2::CENTER_CENTER,
+ "×",
+ egui::FontId::proportional(14.0),
+ if cresp.hovered() { t.text_strong } else { t.text_dim },
+ );
+ ui.painter().galley(egui::pos2(r.min.x + 26.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
+ ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.divider));
+ if cresp.clicked() {
+ close = Some(i);
+ } else if resp.clicked() {
+ activate = Some(i);
+ }
+ x += w;
+ }
+ if let Some(i) = close {
+ let _ = app.run("file.close", json!({"index": i}));
+ } else if let Some(i) = activate {
+ let _ = app.run("file.activate", json!({"index": i}));
+ }
+}
+
+pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ egui::Panel::bottom("status_bar")
+ .exact_size(24.0)
+ .frame(egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::symmetric(8, 0)))
+ .show(ui, |ui| {
+ ui.horizontal_centered(|ui| {
+ if app.session.active().is_none() {
+ ui.label(egui::RichText::new(&app.ui.status).size(11.5).color(t.text_dim));
+ return;
+ }
+ let z = app.view().map(|v| v.zoom).unwrap_or(1.0);
+ ui.label(egui::RichText::new(format!("{:.0}%", z * 100.0)).size(11.5));
+ ui.add_space(8.0);
+ let n = app.session.active().map(|d| d.doc.page_count()).unwrap_or(1);
+ let cur = crate::canvas::current_page(app).unwrap_or(0);
+ if icons::button(ui, "first", 16.0, false, "First Spread").clicked() {
+ crate::canvas::go_to_page(app, 0);
+ }
+ if icons::button(ui, "prev", 16.0, false, "Previous Spread").clicked() {
+ crate::canvas::go_to_page(app, cur.saturating_sub(1));
+ }
+ let name = app.session.active().map(|d| d.doc.page_name(cur)).unwrap_or_default();
+ ui.menu_button(egui::RichText::new(format!(" {name} ▾ ")).size(11.5), |ui| {
+ let names: Vec = app.session.active().map(|d| (0..n).map(|i| d.doc.page_name(i)).collect()).unwrap_or_default();
+ egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
+ for (i, nm) in names.iter().enumerate() {
+ if ui.button(nm).clicked() {
+ crate::canvas::go_to_page(app, i);
+ ui.close();
+ }
+ }
+ });
+ });
+ if icons::button(ui, "next", 16.0, false, "Next Spread").clicked() {
+ crate::canvas::go_to_page(app, (cur + 1).min(n - 1));
+ }
+ if icons::button(ui, "last", 16.0, false, "Last Spread").clicked() {
+ crate::canvas::go_to_page(app, n - 1);
+ }
+ ui.add_space(14.0);
+ // Preflight indicator: overset text is an error.
+ let errors = panels::preflight_errors(app);
+ let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
+ ui.painter().circle_filled(
+ r.center(),
+ 4.5,
+ if errors == 0 { Color32::from_rgb(60, 190, 90) } else { Color32::from_rgb(230, 50, 50) },
+ );
+ ui.label(
+ egui::RichText::new(if errors == 0 {
+ "No errors".to_string()
+ } else {
+ format!("{errors} error{}", if errors == 1 { "" } else { "s" })
+ })
+ .size(11.5),
+ );
+ ui.add_space(14.0);
+ ui.label(egui::RichText::new(&app.ui.status).size(11.5).color(t.text_dim));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.label(
+ egui::RichText::new(format!("{:.1} ms render · {:.0} fps", app.perf.render_ms, app.perf.fps))
+ .size(10.5)
+ .color(t.text_disabled),
+ );
+ });
+ });
+ });
+}
+
+pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let r = ui.available_rect_before_wrap();
+ ui.painter().rect_filled(r, 0.0, t.panel_darker);
+ ui.scope_builder(egui::UiBuilder::new().max_rect(r.shrink(48.0)), |ui| {
+ ui.label(egui::RichText::new("Welcome to DesignCraft").font(semibold(28.0)).color(t.text_strong));
+ ui.label(egui::RichText::new("Page layout for print and screen — fast, open and scriptable.").size(15.0).color(t.text_dim));
+ ui.add_space(24.0);
+ ui.horizontal(|ui| {
+ if ui
+ .add(
+ egui::Button::new(egui::RichText::new(" New file ").size(14.0).color(Color32::WHITE))
+ .fill(t.accent_strong)
+ .corner_radius(16.0)
+ .min_size(vec2(0.0, 32.0)),
+ )
+ .clicked()
+ {
+ let _ = app.run("app.newDocumentDialog", json!({}));
+ }
+ if ui.add(egui::Button::new(egui::RichText::new(" Open ").size(14.0)).corner_radius(16.0).min_size(vec2(0.0, 32.0))).clicked() {
+ let _ = app.run("app.openDialog", json!({}));
+ }
+ if ui
+ .add(egui::Button::new(egui::RichText::new(" Open sample magazine ").size(14.0)).corner_radius(16.0).min_size(vec2(0.0, 32.0)))
+ .clicked()
+ {
+ let _ = app.run("file.newSample", json!({}));
+ }
+ });
+ ui.add_space(28.0);
+ ui.label(egui::RichText::new("Start a new document").font(semibold(15.0)).color(t.text_strong));
+ ui.add_space(8.0);
+ ui.horizontal_wrapped(|ui| {
+ for p in designcraft_doc::build::PRESETS.iter().take(10) {
+ let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
+ let hov = resp.hovered();
+ ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
+ let k = (80.0 / p.width.max(p.height)) as f32;
+ let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
+ ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
+ ui.painter().text(egui::pos2(cr.center().x, cr.max.y - 26.0), egui::Align2::CENTER_CENTER, p.name, semibold(12.0), t.text_strong);
+ ui.painter().text(
+ egui::pos2(cr.center().x, cr.max.y - 11.0),
+ egui::Align2::CENTER_CENTER,
+ format!("{} × {}", designcraft_geom::format_measure(p.width, p.units), designcraft_geom::format_measure(p.height, p.units)),
+ egui::FontId::proportional(10.0),
+ t.text_dim,
+ );
+ if resp.clicked() {
+ let _ = app.run("file.new", json!({"preset": p.name}));
+ }
+ }
+ });
+ });
+ let _ = Content::Unassigned;
+}
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
new file mode 100644
index 00000000..1d5975d1
--- /dev/null
+++ b/crates/ui-egui/src/control.rs
@@ -0,0 +1,348 @@
+//! Programmatic control of the running app (agents, tests, MCP).
+//!
+//! Methods (JSON-lines over the host's transport):
+//! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command
+//! - `engine.commands`: engine + UI commands with enablement
+//! - `document.inspect`: document summary; `ui.inspect`: UI state
+//! - `ui.menu.list`: flattened menu tree
+//! - `ui.tool.select {tool}`, `ui.tool.list`
+//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
+//! drive the active tool through the same path as the mouse
+//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input
+//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`:
+//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs)
+//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
+//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
+//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
+//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
+//! - `app.open {path}` / `app.save {path?}` / `app.export {path, page?, scale?}` (PNG) / `app.quit`
+
+use std::sync::mpsc::Sender;
+
+use designcraft_tools::{Mods, PointerEvent, PointerKind};
+use serde_json::{Value, json};
+
+use crate::DesignApp;
+use crate::canvas::Xf;
+use crate::menus;
+
+pub type ControlResponse = Value;
+
+pub struct ControlRequest {
+ pub method: String,
+ pub params: Value,
+ pub reply: Sender,
+}
+
+impl ControlRequest {
+ pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) {
+ let (tx, rx) = std::sync::mpsc::channel();
+ (Self { method: method.into(), params, reply: tx }, rx)
+ }
+}
+
+pub enum Outcome {
+ Done(Value),
+ Screenshot { path: Option },
+}
+
+fn ok(v: Value) -> Outcome {
+ Outcome::Done(json!({"ok": true, "result": v}))
+}
+fn err(e: impl std::fmt::Display) -> Outcome {
+ Outcome::Done(json!({"ok": false, "error": e.to_string()}))
+}
+fn wrap(r: Result) -> Outcome {
+ match r {
+ Ok(v) => ok(v),
+ Err(e) => err(e),
+ }
+}
+
+pub fn all_commands(app: &DesignApp) -> Value {
+ let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
+ for (id, label, sc, params) in crate::menus::UI_COMMANDS {
+ v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": crate::menus::enabled(app, id), "ui": true}));
+ }
+ Value::Array(v)
+}
+
+pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value {
+ let r = ctx.content_rect();
+ json!({
+ "tool": app.session.tool_id(),
+ "ui": serde_json::to_value(&app.ui).unwrap_or_default(),
+ "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
+ "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
+ "window": [r.width(), r.height()],
+ "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::>(),
+ "currentPage": crate::canvas::current_page(app),
+ "dialog": app.ui.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
+ "activeDocument": app.session.active_index(),
+ "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
+ })
+}
+
+fn key_from(name: &str) -> Option {
+ egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
+ "enter" | "return" => Some(egui::Key::Enter),
+ "esc" | "escape" => Some(egui::Key::Escape),
+ "delete" => Some(egui::Key::Delete),
+ "backspace" => Some(egui::Key::Backspace),
+ "left" => Some(egui::Key::ArrowLeft),
+ "right" => Some(egui::Key::ArrowRight),
+ "up" => Some(egui::Key::ArrowUp),
+ "down" => Some(egui::Key::ArrowDown),
+ "space" => Some(egui::Key::Space),
+ "tab" => Some(egui::Key::Tab),
+ _ => None,
+ })
+}
+
+pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
+ let p = &req.params;
+ let s = |k: &str| p.get(k).and_then(Value::as_str);
+ match req.method.as_str() {
+ "engine.execute" | "ui.menu.invoke" | "command" => {
+ let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
+ let params = p.get("params").cloned().unwrap_or(json!({}));
+ let params = if params.is_null() { json!({}) } else { params };
+ wrap(app.run(id, params))
+ }
+ "engine.commands" => ok(all_commands(app)),
+ "document.inspect" => wrap(app.run("document.inspect", json!({}))),
+ "ui.inspect" => ok(inspect(app, ctx)),
+ "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()),
+ "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
+ "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()),
+ "ui.pointer" => {
+ let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
+ let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
+ let view = app.view_info();
+ let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
+ for e in events {
+ let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
+ "down" => PointerKind::Down,
+ "drag" => PointerKind::Drag,
+ "up" => PointerKind::Up,
+ "move" => PointerKind::Move,
+ "doubleclick" | "dblclick" => PointerKind::DoubleClick,
+ other => return err(format!("unknown pointer kind `{other}`")),
+ };
+ let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
+ let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
+ let pos = if e.get("space").and_then(Value::as_str) == Some("screen") {
+ match xf {
+ Some(xf) => xf.to_canvas(egui::pos2(x as f32, y as f32)),
+ None => return err("no canvas yet"),
+ }
+ } else {
+ designcraft_geom::Point::new(x, y)
+ };
+ let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
+ if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) {
+ return err(e);
+ }
+ app.after_engine();
+ }
+ ctx.request_repaint();
+ wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()})))
+ }
+ "ui.key" => {
+ let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
+ let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
+ let m = egui::Modifiers {
+ alt: b("alt"),
+ ctrl: b("ctrl"),
+ shift: b("shift"),
+ mac_cmd: b("cmd") && cfg!(target_os = "macos"),
+ command: b("cmd"),
+ };
+ app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
+ app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
+ if let Some(t) = s("text") {
+ app.synthetic.push(egui::Event::Text(t.to_string()));
+ }
+ ctx.request_repaint();
+ ok(Value::Null)
+ }
+ "ui.move" => {
+ let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32);
+ app.synthetic.push(egui::Event::PointerMoved(pos));
+ ctx.request_repaint();
+ ok(Value::Null)
+ }
+ "ui.click" | "ui.drag" => {
+ // Screen-space (egui points) pointer input through egui itself: reaches every widget.
+ let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
+ let button = match s("button") {
+ Some("right") | Some("secondary") => egui::PointerButton::Secondary,
+ _ => egui::PointerButton::Primary,
+ };
+ let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
+ let modifiers = egui::Modifiers {
+ alt: b("alt"),
+ ctrl: b("ctrl"),
+ shift: b("shift"),
+ mac_cmd: b("cmd") && cfg!(target_os = "macos"),
+ command: b("cmd"),
+ };
+ let a = egui::pos2(f("x"), f("y"));
+ let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a };
+ app.synthetic.push(egui::Event::PointerMoved(a));
+ app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
+ if req.method == "ui.drag" {
+ let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).max(1);
+ for i in 1..=steps {
+ let t = i as f32 / steps as f32;
+ app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
+ }
+ }
+ app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
+ let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
+ for _ in 1..count {
+ app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
+ app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
+ }
+ ctx.request_repaint();
+ ok(Value::Null)
+ }
+ "ui.text" => {
+ app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
+ ctx.request_repaint();
+ ok(Value::Null)
+ }
+ "ui.set" => {
+ let mut r = Ok(Value::Null);
+ if let Some(b) = s("brightness") {
+ r = app.run("window.brightness", json!({"brightness": b}));
+ }
+ if let Some(pn) = s("panel") {
+ r = app.run("window.panel", json!({"panel": pn}));
+ }
+ if let Some(st) = s("status") {
+ app.ui.status = st.to_string();
+ }
+ for (k, flag) in [
+ ("rulers", "view.rulers"),
+ ("frameEdges", "view.frameEdges"),
+ ("guides", "view.guides"),
+ ("baselineGrid", "view.baselineGrid"),
+ ("textThreads", "view.textThreads"),
+ ] {
+ if let Some(want) = p.get(k).and_then(Value::as_bool) {
+ let cur = match k {
+ "rulers" => app.ui.rulers,
+ "frameEdges" => app.ui.frame_edges,
+ "guides" => app.ui.guides,
+ "baselineGrid" => app.ui.baseline_grid,
+ _ => app.ui.text_threads,
+ };
+ if cur != want {
+ r = app.run(flag, json!({}));
+ }
+ }
+ }
+ if let Some(m) = s("screenMode") {
+ r = app.run("view.screenMode", json!({"mode": m}));
+ }
+ if let Some(z) = p.get("zoom").and_then(Value::as_f64) {
+ r = app.run("view.zoom", json!({"zoom": z}));
+ }
+ if let Some(pg) = p.get("page").and_then(Value::as_u64) {
+ crate::canvas::go_to_page(app, (pg as usize).saturating_sub(1));
+ }
+ if let Some(f) = s("fit") {
+ r = app.run(if f == "page" { "view.fitPage" } else { "view.fitSpread" }, json!({}));
+ }
+ if let Some(v) = p.get("controlBar").and_then(Value::as_bool) {
+ app.ui.control_bar = v;
+ }
+ if p.get("closePanel").is_some() {
+ app.ui.open_panel = None;
+ }
+ if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) {
+ app.ui.dock_expanded = v;
+ }
+ wrap(r)
+ }
+ "ui.dialog.set" => match app.ui.dialog.as_mut() {
+ Some(d) => {
+ let Some(f) = s("field") else { return err("missing `field`") };
+ d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
+ ok(serde_json::to_value(&*d).unwrap_or_default())
+ }
+ None => err("no dialog open"),
+ },
+ "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
+ "ui.dialog.cancel" => {
+ app.ui.dialog = None;
+ ok(Value::Null)
+ }
+ "ui.resize" => {
+ let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32;
+ let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32;
+ ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
+ ok(Value::Null)
+ }
+ "ui.focus" => {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+ ok(Value::Null)
+ }
+ "ui.screenshot" => {
+ if p.get("focus").and_then(Value::as_bool).unwrap_or(true) {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+ }
+ ctx.request_repaint();
+ Outcome::Screenshot { path: s("path").map(str::to_string) }
+ }
+ "ui.render" => {
+ let Some(st) = app.session.active() else { return err("no document") };
+ let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
+ let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
+ let Some(img) = app.canvas.renderer.render_page(
+ &st.doc,
+ &app.session.cache,
+ page,
+ scale,
+ p.get("bleed").and_then(Value::as_bool).unwrap_or(false),
+ &designcraft_render::RenderOptions { printing_only: true, ..Default::default() },
+ ) else {
+ return err("no such page");
+ };
+ let png = img.to_png();
+ match s("path") {
+ Some(path) => match app.services.write.as_mut() {
+ Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))),
+ None => err("no writer"),
+ },
+ None => ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
+ }
+ }
+ "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
+ "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
+ "app.export" => wrap(app.run("app.exportPng", json!({"path": s("path"), "page": p.get("page"), "scale": p.get("scale")}))),
+ "app.quit" => {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+ ok(Value::Null)
+ }
+ other => err(format!("unknown method `{other}`")),
+ }
+}
+
+pub fn save_screenshot(app: &mut DesignApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
+ let [w, h] = image.size;
+ let Some(path) = path else {
+ return json!({"ok": true, "result": {"width": w, "height": h}});
+ };
+ let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect();
+ let img = designcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba };
+ let png = img.to_png();
+ match app.services.write.as_mut() {
+ Some(wr) => match wr(path, &png) {
+ Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
+ Err(e) => json!({"ok": false, "error": e}),
+ },
+ None => json!({"ok": false, "error": "no writer configured"}),
+ }
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
new file mode 100644
index 00000000..b8f6abf1
--- /dev/null
+++ b/crates/ui-egui/src/dialogs.rs
@@ -0,0 +1,269 @@
+//! Modal dialogs. Each dialog keeps its fields in a JSON map so the control channel can set them
+//! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would.
+
+use designcraft_geom::{Unit, format_measure, units::parse_measure};
+use serde::Serialize;
+use serde_json::{Map, Value, json};
+
+use crate::DesignApp;
+use crate::theme::semibold;
+
+#[derive(Clone, Debug, Serialize)]
+pub struct Dialog {
+ pub id: String,
+ pub fields: Map,
+}
+
+impl Dialog {
+ pub fn new(id: &str, params: Value) -> Self {
+ let mut fields = match params {
+ Value::Object(m) => m,
+ _ => Map::new(),
+ };
+ let defaults = match id {
+ "newDocument" => {
+ json!({"preset": "Letter", "width": "51p0", "height": "66p0", "pages": 1, "facingPages": true, "columns": 1, "gutter": "1p0",
+ "marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false})
+ }
+ "goToPage" => json!({"page": 1}),
+ "textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}),
+ "documentSetup" => json!({}),
+ _ => json!({}),
+ };
+ if let Value::Object(d) = defaults {
+ for (k, v) in d {
+ fields.entry(k).or_insert(v);
+ }
+ }
+ if id == "frameSize" {
+ for k in ["width", "height"] {
+ if let Some(v) = fields.get(k).and_then(Value::as_f64) {
+ fields.insert(k.into(), json!(format_measure(v, Unit::Picas)));
+ }
+ }
+ }
+ Dialog { id: id.into(), fields }
+ }
+ fn s(&self, k: &str) -> String {
+ match self.fields.get(k) {
+ Some(Value::String(s)) => s.clone(),
+ Some(v) => v.to_string(),
+ None => String::new(),
+ }
+ }
+ fn m(&self, k: &str) -> Option {
+ match self.fields.get(k) {
+ Some(Value::Number(n)) => n.as_f64(),
+ Some(Value::String(s)) => parse_measure(s, Unit::Picas).ok(),
+ _ => None,
+ }
+ }
+ fn b(&self, k: &str) -> bool {
+ self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
+ }
+ fn n(&self, k: &str) -> Option {
+ match self.fields.get(k) {
+ Some(Value::Number(n)) => n.as_f64(),
+ Some(Value::String(s)) => s.trim().parse().ok(),
+ _ => None,
+ }
+ }
+}
+
+fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
+ let mut s = d.s(key);
+ if ui.add(egui::TextEdit::singleline(&mut s).desired_width(w)).changed() {
+ d.fields.insert(key.into(), Value::String(s));
+ }
+}
+
+fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
+ let mut b = d.b(key);
+ if ui.checkbox(&mut b, label).changed() {
+ d.fields.insert(key.into(), Value::Bool(b));
+ }
+}
+
+pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
+ let Some(mut d) = app.ui.dialog.clone() else { return };
+ let mut result: Option = None;
+ let title = match d.id.as_str() {
+ "newDocument" => "New Document",
+ "frameSize" => "Rectangle",
+ "goToPage" => "Go to Page",
+ "textFrameOptions" => "Text Frame Options",
+ "documentSetup" => "Document Setup",
+ _ => "Dialog",
+ };
+ egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
+ ui.set_min_width(380.0);
+ ui.label(egui::RichText::new(title).font(semibold(16.0)));
+ ui.add_space(10.0);
+ match d.id.as_str() {
+ "newDocument" => {
+ ui.horizontal(|ui| {
+ ui.label("Preset");
+ let cur = d.s("preset");
+ egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| {
+ for p in designcraft_doc::build::PRESETS {
+ if ui.selectable_label(cur == p.name, p.name).clicked() {
+ d.fields.insert("preset".into(), json!(p.name));
+ d.fields.insert("width".into(), json!(format_measure(p.width, p.units)));
+ d.fields.insert("height".into(), json!(format_measure(p.height, p.units)));
+ d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print));
+ }
+ }
+ });
+ });
+ egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
+ ui.label("Width");
+ text_field(ui, &mut d, "width", 80.0);
+ ui.label("Height");
+ text_field(ui, &mut d, "height", 80.0);
+ ui.end_row();
+ ui.label("Pages");
+ text_field(ui, &mut d, "pages", 80.0);
+ ui.label("");
+ check(ui, &mut d, "facingPages", "Facing Pages");
+ ui.end_row();
+ ui.label("Columns");
+ text_field(ui, &mut d, "columns", 80.0);
+ ui.label("Gutter");
+ text_field(ui, &mut d, "gutter", 80.0);
+ ui.end_row();
+ ui.label("Top");
+ text_field(ui, &mut d, "marginTop", 80.0);
+ ui.label("Bottom");
+ text_field(ui, &mut d, "marginBottom", 80.0);
+ ui.end_row();
+ ui.label("Inside");
+ text_field(ui, &mut d, "marginInside", 80.0);
+ ui.label("Outside");
+ text_field(ui, &mut d, "marginOutside", 80.0);
+ ui.end_row();
+ ui.label("Bleed");
+ text_field(ui, &mut d, "bleed", 80.0);
+ ui.label("");
+ check(ui, &mut d, "primaryTextFrame", "Primary Text Frame");
+ ui.end_row();
+ });
+ }
+ "frameSize" => {
+ egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+ ui.label("Width");
+ text_field(ui, &mut d, "width", 90.0);
+ ui.end_row();
+ ui.label("Height");
+ text_field(ui, &mut d, "height", 90.0);
+ ui.end_row();
+ });
+ }
+ "goToPage" => {
+ ui.horizontal(|ui| {
+ ui.label("Page");
+ text_field(ui, &mut d, "page", 80.0);
+ });
+ }
+ "textFrameOptions" => {
+ egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+ ui.label("Number of columns");
+ text_field(ui, &mut d, "columns", 80.0);
+ ui.end_row();
+ ui.label("Gutter");
+ text_field(ui, &mut d, "gutter", 80.0);
+ ui.end_row();
+ ui.label("Inset spacing");
+ text_field(ui, &mut d, "inset", 80.0);
+ ui.end_row();
+ ui.label("Vertical justification");
+ let cur = d.s("verticalJustification");
+ egui::ComboBox::from_id_salt("vj").selected_text(&cur).show_ui(ui, |ui| {
+ for v in ["top", "center", "bottom", "justify"] {
+ if ui.selectable_label(cur == v, v).clicked() {
+ d.fields.insert("verticalJustification".into(), json!(v));
+ }
+ }
+ });
+ ui.end_row();
+ });
+ }
+ "documentSetup" => {
+ let st = app.session.active();
+ let (w, h) = st.map(|s| (s.doc.settings.page_width, s.doc.settings.page_height)).unwrap_or((612.0, 792.0));
+ d.fields.entry("width".to_string()).or_insert(json!(format_measure(w, Unit::Picas)));
+ d.fields.entry("height".to_string()).or_insert(json!(format_measure(h, Unit::Picas)));
+ egui::Grid::new("ds").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+ ui.label("Width");
+ text_field(ui, &mut d, "width", 90.0);
+ ui.end_row();
+ ui.label("Height");
+ text_field(ui, &mut d, "height", 90.0);
+ ui.end_row();
+ });
+ }
+ _ => {}
+ }
+ ui.add_space(12.0);
+ ui.horizontal(|ui| {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui
+ .add(
+ egui::Button::new(egui::RichText::new(" OK ").color(egui::Color32::WHITE))
+ .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong),
+ )
+ .clicked()
+ || ui.input(|i| i.key_pressed(egui::Key::Enter))
+ {
+ result = Some(true);
+ }
+ if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+ result = Some(false);
+ }
+ });
+ });
+ });
+ app.ui.dialog = Some(d);
+ match result {
+ Some(true) => {
+ let _ = confirm(app);
+ }
+ Some(false) => app.ui.dialog = None,
+ None => {}
+ }
+}
+
+/// Apply the open dialog.
+pub fn confirm(app: &mut DesignApp) -> Result {
+ let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
+ match d.id.as_str() {
+ "newDocument" => {
+ let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)});
+ app.run(
+ "file.new",
+ json!({"width": d.m("width"), "height": d.m("height"), "pages": d.n("pages").unwrap_or(1.0) as u64, "facingPages": d.b("facingPages"),
+ "columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "margins": margins, "bleed": d.m("bleed").unwrap_or(0.0),
+ "primaryTextFrame": d.b("primaryTextFrame")}),
+ )
+ }
+ "frameSize" => {
+ let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0);
+ let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0);
+ let w = d.m("width").unwrap_or(72.0);
+ let h = d.m("height").unwrap_or(72.0);
+ app.run(
+ "frame.create",
+ json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}),
+ )
+ }
+ "goToPage" => {
+ crate::canvas::go_to_page(app, (d.n("page").unwrap_or(1.0) as usize).saturating_sub(1));
+ Ok(Value::Null)
+ }
+ "textFrameOptions" => app.run(
+ "object.textFrameOptions",
+ json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}),
+ ),
+ "documentSetup" => app.run("layout.documentSetup", json!({"width": d.m("width"), "height": d.m("height")})),
+ other => Err(format!("unknown dialog {other}")),
+ }
+}
diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs
new file mode 100644
index 00000000..70a997c5
--- /dev/null
+++ b/crates/ui-egui/src/dock.rs
@@ -0,0 +1,126 @@
+//! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon
+//! column whose panels open as flyouts beside the dock.
+
+use egui::{Sense, Stroke, vec2};
+
+use crate::theme::{Tokens, semibold};
+use crate::{DesignApp, icons, panels};
+
+/// (id, label, icon) of the tabs in the expanded group.
+pub const DOCK_TABS: &[(&str, &str, &str)] =
+ &[("properties", "Properties", "panel-properties"), ("pages", "Pages", "panel-pages"), ("layers", "Layers", "panel-layers")];
+
+/// Panels in the collapsed icon column.
+pub const ICON_PANELS: &[(&str, &str, &str)] = &[
+ ("stroke", "Stroke", "panel-stroke"),
+ ("swatches", "Swatches", "panel-swatches"),
+ ("color", "Color", "panel-color"),
+ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"),
+ ("characterStyles", "Character Styles", "panel-cstyles"),
+ ("character", "Character", "panel-character"),
+ ("paragraph", "Paragraph", "panel-paragraph"),
+ ("textWrap", "Text Wrap", "panel-wrap"),
+ ("align", "Align", "panel-align"),
+ ("links", "Links", "panel-links"),
+ ("info", "Info", "panel-info"),
+];
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ // Collapsed icon column (rightmost).
+ egui::Panel::right("icon_column")
+ .exact_size(40.0)
+ .resizable(false)
+ .frame(egui::Frame::NONE.fill(t.panel_darker).stroke(Stroke::new(1.0, t.divider)))
+ .show(ui, |ui| {
+ ui.add_space(6.0);
+ ui.vertical_centered(|ui| {
+ for (id, label, icon) in ICON_PANELS {
+ let open = app.ui.open_panel.as_deref() == Some(*id);
+ if icons::button(ui, icon, 30.0, open, label).clicked() {
+ app.ui.open_panel = if open { None } else { Some(id.to_string()) };
+ }
+ ui.add_space(1.0);
+ }
+ });
+ });
+ if !app.ui.dock_expanded {
+ return;
+ }
+ egui::Panel::right("dock")
+ .default_size(280.0)
+ .size_range(240.0..=420.0)
+ .resizable(true)
+ .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)))
+ .show(ui, |ui| {
+ // Tab strip.
+ let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
+ ui.painter().rect_filled(strip, 0.0, t.panel_darker);
+ let mut x = strip.min.x;
+ for (id, label, _) in DOCK_TABS {
+ let active = app.ui.dock_tab == *id;
+ let g = ui.painter().layout_no_wrap(label.to_string(), semibold(12.0), if active { t.text_strong } else { t.text_dim });
+ let w = g.size().x + 22.0;
+ let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 28.0));
+ if active {
+ ui.painter().rect_filled(r, 0.0, t.panel);
+ }
+ ui.painter().galley(egui::pos2(r.min.x + 11.0, r.center().y - g.size().y / 2.0), g, egui::Color32::WHITE);
+ if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() {
+ app.ui.dock_tab = id.to_string();
+ }
+ x += w;
+ }
+ let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 26.0, strip.min.y + 4.0), vec2(20.0, 20.0));
+ icons::paint(ui.painter(), menu_r, "menu", t.icon);
+ egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 6, bottom: 6 }).show(ui, |ui| {
+ egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() {
+ "pages" => panels::pages::show(app, ui),
+ "layers" => panels::layers::show(app, ui),
+ _ => panels::properties::show(app, ui),
+ });
+ });
+ });
+}
+
+/// A panel opened from the icon column, shown as a floating flyout next to the dock.
+pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
+ let Some(id) = app.ui.open_panel.clone() else { return };
+ let t = Tokens::get(ctx);
+ let label = ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel");
+ let screen = ctx.content_rect();
+ let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };
+ let pos = egui::pos2(screen.max.x - 40.0 - dock_w - 262.0, 130.0);
+ let mut open = true;
+ egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
+ egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| {
+ ui.set_width(256.0);
+ let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
+ ui.painter().rect_filled(strip, 0.0, t.panel_darker);
+ ui.painter().text(strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, label, semibold(12.0), t.text_strong);
+ let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0));
+ if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() {
+ open = false;
+ }
+ ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
+ egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
+ egui::ScrollArea::vertical().max_height(screen.height() - 220.0).show(ui, |ui| match id.as_str() {
+ "swatches" => panels::swatches::show(app, ui),
+ "paragraphStyles" => panels::styles::paragraph(app, ui),
+ "characterStyles" => panels::styles::character(app, ui),
+ "stroke" => panels::properties::stroke_panel(app, ui),
+ "character" => panels::properties::character_panel(app, ui),
+ "paragraph" => panels::properties::paragraph_panel(app, ui),
+ "textWrap" => panels::properties::wrap_panel(app, ui),
+ "align" => panels::properties::align_panel(app, ui),
+ "color" => panels::swatches::color_panel(app, ui),
+ "links" => panels::properties::links_panel(app, ui),
+ _ => panels::properties::info_panel(app, ui),
+ });
+ });
+ });
+ });
+ if !open {
+ app.ui.open_panel = None;
+ }
+}
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
new file mode 100644
index 00000000..4e8470be
--- /dev/null
+++ b/crates/ui-egui/src/icons.rs
@@ -0,0 +1,451 @@
+//! DesignCraft's icon set, drawn in code (original artwork; no external icon assets).
+//!
+//! Icons are designed on a 20×20 grid and scaled to the target rect.
+
+use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
+
+struct Pen<'a> {
+ p: &'a Painter,
+ r: Rect,
+ c: Color32,
+ w: f32,
+}
+
+impl Pen<'_> {
+ fn pt(&self, x: f32, y: f32) -> Pos2 {
+ let s = self.r.width().min(self.r.height()) / 20.0;
+ let o = self.r.center() - vec2(10.0 * s, 10.0 * s);
+ pos2(o.x + x * s, o.y + y * s)
+ }
+ fn s(&self) -> f32 {
+ self.r.width().min(self.r.height()) / 20.0
+ }
+ fn line(&self, pts: &[(f32, f32)]) {
+ let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect();
+ self.p.add(Shape::line(v, Stroke::new(self.w * self.s(), self.c)));
+ }
+ fn closed(&self, pts: &[(f32, f32)]) {
+ let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect();
+ self.p.add(Shape::closed_line(v, Stroke::new(self.w * self.s(), self.c)));
+ }
+ fn fill(&self, pts: &[(f32, f32)]) {
+ let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect();
+ self.p.add(Shape::convex_polygon(v, self.c, Stroke::NONE));
+ }
+ fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) {
+ self.p.rect_stroke(
+ Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)),
+ 0.0,
+ Stroke::new(self.w * self.s(), self.c),
+ egui::StrokeKind::Middle,
+ );
+ }
+ fn frect(&self, x0: f32, y0: f32, x1: f32, y1: f32) {
+ self.p.rect_filled(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), 0.0, self.c);
+ }
+ fn circle(&self, x: f32, y: f32, r: f32) {
+ self.p.circle_stroke(self.pt(x, y), r * self.s(), Stroke::new(self.w * self.s(), self.c));
+ }
+ fn fcircle(&self, x: f32, y: f32, r: f32) {
+ self.p.circle_filled(self.pt(x, y), r * self.s(), self.c);
+ }
+ fn ellipse(&self, cx: f32, cy: f32, rx: f32, ry: f32) {
+ let n = 32;
+ let pts: Vec<(f32, f32)> = (0..n)
+ .map(|i| {
+ let a = i as f32 / n as f32 * std::f32::consts::TAU;
+ (cx + rx * a.cos(), cy + ry * a.sin())
+ })
+ .collect();
+ self.closed(&pts);
+ }
+ fn text(&self, x: f32, y: f32, t: &str, size: f32) {
+ self.p.text(self.pt(x, y), egui::Align2::CENTER_CENTER, t, crate::theme::semibold(size * self.s()), self.c);
+ }
+}
+
+fn arrow(pen: &Pen, hollow: bool) {
+ let pts = [(5.0, 2.5), (5.0, 16.0), (8.4, 12.8), (10.8, 18.0), (13.0, 17.0), (10.6, 11.9), (15.0, 11.6)];
+ if hollow {
+ pen.closed(&pts);
+ } else {
+ let v: Vec = pts.iter().map(|&(x, y)| pen.pt(x, y)).collect();
+ pen.p.add(Shape::Path(egui::epaint::PathShape { points: v, closed: true, fill: pen.c, stroke: egui::epaint::PathStroke::NONE }));
+ }
+}
+
+/// Paint icon `name` into `r`.
+pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
+ let mut pen = Pen { p, r, c, w: 1.3 };
+ match name {
+ "tool-selection" => arrow(&pen, false),
+ "tool-direct" => arrow(&pen, true),
+ "tool-page" => {
+ pen.closed(&[(5.0, 3.0), (12.0, 3.0), (15.0, 6.0), (15.0, 17.0), (5.0, 17.0)]);
+ pen.line(&[(12.0, 3.0), (12.0, 6.0), (15.0, 6.0)]);
+ pen.line(&[(2.0, 17.0), (2.0, 3.0)]);
+ }
+ "tool-gap" => {
+ pen.rect(2.0, 4.0, 7.0, 16.0);
+ pen.rect(13.0, 4.0, 18.0, 16.0);
+ pen.line(&[(8.5, 10.0), (11.5, 10.0)]);
+ pen.line(&[(9.5, 8.5), (8.0, 10.0), (9.5, 11.5)]);
+ pen.line(&[(10.5, 8.5), (12.0, 10.0), (10.5, 11.5)]);
+ }
+ "tool-collector" | "tool-placer" => {
+ pen.rect(3.0, 6.0, 17.0, 16.0);
+ pen.line(&[(10.0, 2.0), (10.0, 11.0)]);
+ pen.line(&[(7.0, 8.0), (10.0, 11.0), (13.0, 8.0)]);
+ }
+ "tool-type" => {
+ pen.line(&[(4.0, 4.0), (16.0, 4.0)]);
+ pen.line(&[(10.0, 4.0), (10.0, 17.0)]);
+ pen.line(&[(7.5, 17.0), (12.5, 17.0)]);
+ pen.line(&[(4.0, 4.0), (4.0, 6.0)]);
+ pen.line(&[(16.0, 4.0), (16.0, 6.0)]);
+ }
+ "tool-type-path" | "tool-type-vertical" => {
+ pen.line(&[(3.0, 15.0), (7.0, 11.0), (13.0, 14.0), (17.0, 9.0)]);
+ pen.line(&[(6.0, 3.0), (14.0, 3.0)]);
+ pen.line(&[(10.0, 3.0), (10.0, 10.0)]);
+ }
+ "tool-line" => pen.line(&[(4.0, 16.0), (16.0, 4.0)]),
+ "tool-pen" | "tool-pen-add" | "tool-pen-delete" | "tool-anchor" => {
+ pen.closed(&[(10.0, 2.5), (14.5, 11.0), (12.0, 15.0), (8.0, 15.0), (5.5, 11.0)]);
+ pen.line(&[(10.0, 2.5), (10.0, 9.5)]);
+ pen.fcircle(10.0, 10.5, 1.1);
+ pen.line(&[(8.0, 15.0), (8.0, 18.0), (12.0, 18.0), (12.0, 15.0)]);
+ if name == "tool-pen-add" {
+ pen.line(&[(15.0, 3.0), (19.0, 3.0)]);
+ pen.line(&[(17.0, 1.0), (17.0, 5.0)]);
+ } else if name == "tool-pen-delete" {
+ pen.line(&[(15.0, 3.0), (19.0, 3.0)]);
+ }
+ }
+ "tool-pencil" | "tool-smooth" | "tool-erase" => {
+ pen.closed(&[(4.0, 16.0), (5.0, 12.0), (14.0, 3.0), (17.0, 6.0), (8.0, 15.0)]);
+ pen.line(&[(12.0, 5.0), (15.0, 8.0)]);
+ }
+ "tool-rect-frame" | "tool-ellipse-frame" | "tool-polygon-frame" => {
+ if name == "tool-ellipse-frame" {
+ pen.ellipse(10.0, 10.0, 7.0, 6.0);
+ } else if name == "tool-polygon-frame" {
+ pen.closed(&[(10.0, 3.0), (17.0, 8.0), (14.5, 16.5), (5.5, 16.5), (3.0, 8.0)]);
+ } else {
+ pen.rect(3.0, 4.0, 17.0, 16.0);
+ }
+ pen.w = 1.0;
+ pen.line(&[(5.0, 6.0), (15.0, 14.0)]);
+ pen.line(&[(15.0, 6.0), (5.0, 14.0)]);
+ }
+ "tool-rect" => pen.rect(3.0, 4.0, 17.0, 16.0),
+ "tool-ellipse" => pen.ellipse(10.0, 10.0, 7.0, 6.0),
+ "tool-polygon" => pen.closed(&[(10.0, 3.0), (17.0, 8.0), (14.5, 16.5), (5.5, 16.5), (3.0, 8.0)]),
+ "tool-scissors" => {
+ pen.circle(6.0, 14.5, 2.5);
+ pen.circle(14.0, 14.5, 2.5);
+ pen.line(&[(7.5, 12.5), (14.0, 3.0)]);
+ pen.line(&[(12.5, 12.5), (6.0, 3.0)]);
+ }
+ "tool-free-transform" | "tool-scale" | "tool-shear" => {
+ pen.rect(4.0, 4.0, 13.0, 13.0);
+ pen.frect(2.8, 2.8, 5.2, 5.2);
+ pen.frect(11.8, 11.8, 14.2, 14.2);
+ pen.line(&[(14.0, 14.0), (18.0, 18.0)]);
+ pen.line(&[(15.0, 18.0), (18.0, 18.0), (18.0, 15.0)]);
+ }
+ "tool-rotate" => {
+ let pts: Vec<(f32, f32)> = (0..24)
+ .map(|i| {
+ let a = (i as f32 / 23.0) * 4.6 - 0.6;
+ (10.0 + 6.5 * a.cos(), 10.0 - 6.5 * a.sin())
+ })
+ .collect();
+ pen.line(&pts);
+ pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]);
+ }
+ "tool-gradient" | "tool-gradient-feather" => {
+ for i in 0..6 {
+ let a = 30 + i * 35;
+ let pc = Pen { p, r, c: c.gamma_multiply(a as f32 / 255.0 * 1.2), w: 1.0 };
+ pc.frect(3.0 + i as f32 * 2.33, 4.0, 3.0 + (i + 1) as f32 * 2.33, 16.0);
+ }
+ pen.rect(3.0, 4.0, 17.0, 16.0);
+ }
+ "tool-note" => {
+ pen.closed(&[(4.0, 3.0), (16.0, 3.0), (16.0, 13.0), (11.0, 17.0), (4.0, 17.0)]);
+ pen.line(&[(7.0, 7.0), (13.0, 7.0)]);
+ pen.line(&[(7.0, 10.0), (13.0, 10.0)]);
+ }
+ "tool-color-theme" | "tool-eyedropper" => {
+ pen.closed(&[(3.0, 17.0), (3.5, 14.5), (11.0, 7.0), (13.0, 9.0), (5.5, 16.5)]);
+ pen.line(&[(11.0, 5.0), (15.0, 9.0)]);
+ pen.closed(&[(12.5, 6.5), (15.0, 3.0), (17.0, 5.0), (13.5, 7.5)]);
+ }
+ "tool-measure" => {
+ pen.closed(&[(2.0, 13.0), (13.0, 2.0), (18.0, 7.0), (7.0, 18.0)]);
+ for k in 0..4 {
+ let t = 4.0 + k as f32 * 2.5;
+ pen.line(&[(t, 13.0 - (t - 2.0)), (t + 1.5, 13.0 - (t - 2.0) + 1.5)]);
+ }
+ }
+ "tool-hand" => {
+ pen.line(&[(6.0, 11.0), (6.0, 5.0)]);
+ pen.line(&[(8.5, 10.0), (8.5, 3.5)]);
+ pen.line(&[(11.0, 10.0), (11.0, 4.0)]);
+ pen.line(&[(13.5, 10.5), (13.5, 6.0)]);
+ pen.line(&[(6.0, 11.0), (4.0, 9.0), (3.0, 10.0), (6.5, 16.5), (13.5, 17.5), (15.0, 13.0), (13.5, 10.5)]);
+ }
+ "tool-zoom" => {
+ pen.circle(8.5, 8.5, 5.5);
+ pen.w = 2.0;
+ pen.line(&[(12.5, 12.5), (17.5, 17.5)]);
+ }
+ // ---- panel icons
+ "panel-properties" => {
+ pen.line(&[(3.0, 5.0), (17.0, 5.0)]);
+ pen.line(&[(3.0, 10.0), (17.0, 10.0)]);
+ pen.line(&[(3.0, 15.0), (17.0, 15.0)]);
+ pen.fcircle(7.0, 5.0, 1.6);
+ pen.fcircle(13.0, 10.0, 1.6);
+ pen.fcircle(9.0, 15.0, 1.6);
+ }
+ "panel-pages" => {
+ pen.rect(3.0, 3.0, 9.0, 11.0);
+ pen.rect(11.0, 3.0, 17.0, 11.0);
+ pen.rect(7.0, 13.0, 13.0, 18.0);
+ }
+ "panel-layers" => {
+ pen.closed(&[(10.0, 3.0), (18.0, 7.0), (10.0, 11.0), (2.0, 7.0)]);
+ pen.line(&[(2.0, 10.5), (10.0, 14.5), (18.0, 10.5)]);
+ pen.line(&[(2.0, 14.0), (10.0, 18.0), (18.0, 14.0)]);
+ }
+ "panel-links" => {
+ pen.closed(&[
+ (3.0, 10.0),
+ (6.0, 7.0),
+ (10.0, 7.0),
+ (10.0, 9.0),
+ (6.5, 9.0),
+ (5.0, 10.0),
+ (6.5, 11.0),
+ (10.0, 11.0),
+ (10.0, 13.0),
+ (6.0, 13.0),
+ ]);
+ pen.closed(&[(17.0, 10.0), (14.0, 7.0), (10.0, 7.0)]);
+ pen.line(&[(8.0, 10.0), (12.0, 10.0)]);
+ pen.line(&[(17.0, 10.0), (14.0, 13.0), (10.0, 13.0)]);
+ }
+ "panel-stroke" => {
+ pen.w = 0.8;
+ pen.line(&[(3.0, 5.0), (17.0, 5.0)]);
+ pen.w = 1.6;
+ pen.line(&[(3.0, 9.5), (17.0, 9.5)]);
+ pen.w = 2.8;
+ pen.line(&[(3.0, 15.0), (17.0, 15.0)]);
+ }
+ "panel-swatches" => {
+ pen.frect(3.0, 3.0, 9.0, 9.0);
+ pen.rect(11.0, 3.0, 17.0, 9.0);
+ pen.rect(3.0, 11.0, 9.0, 17.0);
+ pen.frect(11.0, 11.0, 17.0, 17.0);
+ }
+ "panel-color" => {
+ pen.circle(7.5, 8.0, 4.5);
+ pen.circle(12.5, 8.0, 4.5);
+ pen.circle(10.0, 12.5, 4.5);
+ }
+ "panel-gradient" => {
+ for i in 0..7 {
+ let pc = Pen { p, r, c: c.gamma_multiply(0.15 + i as f32 * 0.14), w: 1.0 };
+ pc.frect(3.0 + i as f32 * 2.0, 5.0, 5.0 + i as f32 * 2.0, 15.0);
+ }
+ }
+ "panel-effects" => pen.text(10.0, 10.0, "fx", 11.0),
+ "panel-character" => pen.text(10.0, 10.5, "A", 15.0),
+ "panel-paragraph" => pen.text(10.0, 10.5, "¶", 15.0),
+ "panel-pstyles" => {
+ pen.text(8.0, 10.0, "¶", 13.0);
+ pen.line(&[(13.0, 6.0), (18.0, 6.0)]);
+ pen.line(&[(13.0, 10.0), (18.0, 10.0)]);
+ pen.line(&[(13.0, 14.0), (18.0, 14.0)]);
+ }
+ "panel-cstyles" => {
+ pen.text(8.0, 10.0, "A", 13.0);
+ pen.line(&[(13.0, 6.0), (18.0, 6.0)]);
+ pen.line(&[(13.0, 10.0), (18.0, 10.0)]);
+ pen.line(&[(13.0, 14.0), (18.0, 14.0)]);
+ }
+ "panel-wrap" => {
+ pen.frect(7.0, 7.0, 13.0, 13.0);
+ for y in [3.0, 17.0] {
+ pen.line(&[(3.0, y), (17.0, y)]);
+ }
+ pen.line(&[(3.0, 10.0), (5.0, 10.0)]);
+ pen.line(&[(15.0, 10.0), (17.0, 10.0)]);
+ }
+ "panel-align" => {
+ pen.line(&[(3.0, 3.0), (3.0, 17.0)]);
+ pen.frect(5.0, 5.0, 15.0, 9.0);
+ pen.frect(5.0, 11.0, 11.0, 15.0);
+ }
+ "panel-transform" => {
+ pen.rect(4.0, 4.0, 16.0, 16.0);
+ for (x, y) in [(4.0, 4.0), (16.0, 4.0), (4.0, 16.0), (16.0, 16.0), (10.0, 10.0)] {
+ pen.fcircle(x, y, 1.4);
+ }
+ }
+ "panel-info" => {
+ pen.circle(10.0, 10.0, 7.0);
+ pen.text(10.0, 10.5, "i", 11.0);
+ }
+ "panel-preflight" => {
+ pen.circle(10.0, 10.0, 7.0);
+ pen.line(&[(6.5, 10.0), (9.0, 12.5), (13.5, 7.5)]);
+ }
+ "panel-text-frame" => {
+ pen.rect(3.0, 4.0, 17.0, 16.0);
+ pen.text(10.0, 10.0, "T", 9.0);
+ }
+ // ---- small UI glyphs
+ "chevron-down" => pen.line(&[(6.0, 8.0), (10.0, 12.0), (14.0, 8.0)]),
+ "chevron-right" => pen.line(&[(8.0, 6.0), (12.0, 10.0), (8.0, 14.0)]),
+ "chevron-left" => pen.line(&[(12.0, 6.0), (8.0, 10.0), (12.0, 14.0)]),
+ "double-chevron-left" => {
+ pen.line(&[(10.0, 6.0), (6.0, 10.0), (10.0, 14.0)]);
+ pen.line(&[(15.0, 6.0), (11.0, 10.0), (15.0, 14.0)]);
+ }
+ "double-chevron-right" => {
+ pen.line(&[(5.0, 6.0), (9.0, 10.0), (5.0, 14.0)]);
+ pen.line(&[(10.0, 6.0), (14.0, 10.0), (10.0, 14.0)]);
+ }
+ "first" => {
+ pen.line(&[(5.0, 5.0), (5.0, 15.0)]);
+ pen.fill(&[(15.0, 5.0), (15.0, 15.0), (7.0, 10.0)]);
+ }
+ "prev" => pen.fill(&[(13.0, 5.0), (13.0, 15.0), (6.0, 10.0)]),
+ "next" => pen.fill(&[(7.0, 5.0), (7.0, 15.0), (14.0, 10.0)]),
+ "last" => {
+ pen.line(&[(15.0, 5.0), (15.0, 15.0)]);
+ pen.fill(&[(5.0, 5.0), (5.0, 15.0), (13.0, 10.0)]);
+ }
+ "eye" => {
+ pen.closed(&[(2.0, 10.0), (6.0, 6.0), (10.0, 5.0), (14.0, 6.0), (18.0, 10.0), (14.0, 14.0), (10.0, 15.0), (6.0, 14.0)]);
+ pen.fcircle(10.0, 10.0, 2.5);
+ }
+ "lock" => {
+ pen.frect(5.0, 9.0, 15.0, 17.0);
+ pen.line(&[(7.0, 9.0), (7.0, 6.0), (8.5, 4.0), (11.5, 4.0), (13.0, 6.0), (13.0, 9.0)]);
+ }
+ "plus" => {
+ pen.line(&[(10.0, 4.0), (10.0, 16.0)]);
+ pen.line(&[(4.0, 10.0), (16.0, 10.0)]);
+ }
+ "trash" => {
+ pen.line(&[(4.0, 5.0), (16.0, 5.0)]);
+ pen.closed(&[(5.5, 5.0), (6.5, 17.0), (13.5, 17.0), (14.5, 5.0)]);
+ pen.line(&[(8.0, 5.0), (8.5, 3.0), (11.5, 3.0), (12.0, 5.0)]);
+ }
+ "menu" => {
+ for y in [6.0, 10.0, 14.0] {
+ pen.line(&[(4.0, y), (16.0, y)]);
+ }
+ }
+ "home" => {
+ pen.closed(&[(3.0, 9.0), (10.0, 3.0), (17.0, 9.0), (17.0, 17.0), (3.0, 17.0)]);
+ pen.rect(8.0, 11.0, 12.0, 17.0);
+ }
+ "search" => {
+ pen.circle(8.5, 8.5, 5.0);
+ pen.line(&[(12.5, 12.5), (17.0, 17.0)]);
+ }
+ "share" => {
+ pen.line(&[(10.0, 3.0), (10.0, 13.0)]);
+ pen.line(&[(6.5, 6.5), (10.0, 3.0), (13.5, 6.5)]);
+ pen.line(&[(5.0, 10.0), (4.0, 10.0), (4.0, 17.0), (16.0, 17.0), (16.0, 10.0), (15.0, 10.0)]);
+ }
+ "screen-normal" => pen.rect(3.0, 4.0, 17.0, 16.0),
+ "screen-preview" => {
+ pen.rect(3.0, 4.0, 17.0, 16.0);
+ pen.frect(6.0, 7.0, 14.0, 13.0);
+ }
+ "align-left" | "align-center" | "align-right" | "align-justify" | "align-justify-all" => {
+ let rows = [(3.0, 14.0), (3.0, 17.0), (3.0, 12.0), (3.0, 16.0)];
+ for (i, (a, b)) in rows.iter().enumerate() {
+ let y = 4.5 + i as f32 * 3.7;
+ let w = b - a;
+ let (x0, x1) = match name {
+ "align-center" => (10.0 - w / 2.0, 10.0 + w / 2.0),
+ "align-right" => (17.0 - w, 17.0),
+ "align-justify" if i < 3 => (3.0, 17.0),
+ "align-justify-all" => (3.0, 17.0),
+ _ => (*a, *b),
+ };
+ pen.line(&[(x0, y), (x1, y)]);
+ }
+ }
+ "ref-point" => {
+ for i in 0..9 {
+ let (x, y) = (4.0 + (i % 3) as f32 * 6.0, 4.0 + (i / 3) as f32 * 6.0);
+ pen.rect(x - 1.2, y - 1.2, x + 1.2, y + 1.2);
+ }
+ }
+ "fill-proxy" => pen.frect(3.0, 3.0, 13.0, 13.0),
+ "swap" => {
+ pen.line(&[(5.0, 15.0), (5.0, 8.0), (8.0, 5.0), (15.0, 5.0)]);
+ pen.line(&[(12.5, 2.5), (15.0, 5.0), (12.5, 7.5)]);
+ pen.line(&[(2.5, 12.5), (5.0, 15.0), (7.5, 12.5)]);
+ }
+ "none" => {
+ pen.rect(3.0, 3.0, 17.0, 17.0);
+ pen.p.line_segment([pen.pt(3.0, 17.0), pen.pt(17.0, 3.0)], Stroke::new(1.6 * pen.s(), Color32::from_rgb(230, 40, 40)));
+ }
+ "fit-fill" | "fit-content" | "fit-frame" | "fit-center" | "fit-prop" => {
+ pen.rect(3.0, 5.0, 17.0, 15.0);
+ match name {
+ "fit-fill" => pen.frect(5.0, 3.0, 15.0, 17.0),
+ "fit-prop" => pen.frect(7.0, 6.5, 13.0, 13.5),
+ "fit-content" => pen.frect(4.5, 6.5, 15.5, 13.5),
+ "fit-center" => pen.fcircle(10.0, 10.0, 2.0),
+ _ => pen.rect(5.0, 7.0, 15.0, 13.0),
+ }
+ }
+ "wrap-none" | "wrap-bbox" | "wrap-contour" | "wrap-jump" | "wrap-next" => {
+ match name {
+ "wrap-contour" => {
+ pen.ellipse(10.0, 10.0, 4.0, 4.0);
+ }
+ _ => pen.frect(7.5, 7.5, 12.5, 12.5),
+ }
+ for y in [3.0, 5.5, 14.5, 17.0] {
+ pen.line(&[(3.0, y), (17.0, y)]);
+ }
+ match name {
+ "wrap-none" => pen.line(&[(3.0, 10.0), (17.0, 10.0)]),
+ "wrap-bbox" | "wrap-contour" => {
+ pen.line(&[(3.0, 10.0), (5.5, 10.0)]);
+ pen.line(&[(14.5, 10.0), (17.0, 10.0)]);
+ }
+ _ => {}
+ }
+ }
+ _ => {
+ pen.rect(4.0, 4.0, 16.0, 16.0);
+ }
+ }
+}
+
+/// An icon-sized clickable button. Returns the response.
+pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &str) -> egui::Response {
+ let t = crate::theme::Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(size, size), egui::Sense::click());
+ if selected {
+ ui.painter().rect_filled(r, 3.0, t.tool_active);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, 3.0, t.hover);
+ }
+ let pad = size * 0.16;
+ paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon });
+ if tip.is_empty() { resp } else { resp.on_hover_text(tip) }
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
new file mode 100644
index 00000000..bec685bb
--- /dev/null
+++ b/crates/ui-egui/src/lib.rs
@@ -0,0 +1,419 @@
+//! DesignCraft's egui frontend: an InDesign-style UI over `designcraft-engine`.
+//!
+//! The UI is thin: every action goes through [`DesignApp::run`], which dispatches UI commands
+//! (view/window) here and everything else to the engine. Menus, shortcuts, the ⌘K palette and
+//! the control channel ([`control`]) share that entry point.
+#![forbid(unsafe_code)]
+
+pub mod canvas;
+pub mod chrome;
+pub mod control;
+pub mod dialogs;
+pub mod dock;
+pub mod icons;
+pub mod menus;
+pub mod panels;
+pub mod theme;
+pub mod toolbar;
+pub mod widgets;
+
+use std::collections::HashMap;
+use std::sync::mpsc::{Receiver, Sender};
+
+use designcraft_engine::{Session, UiRequest, ViewInfo};
+use designcraft_geom::{Point, Unit};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+pub use control::{ControlRequest, ControlResponse};
+
+pub type ReadFn = Box Result, String>>;
+pub type WriteFn = Box Result<(), String>>;
+pub type PickFn = Box Option>;
+
+/// Platform services injected by the host (desktop or web).
+#[derive(Default)]
+pub struct Services {
+ /// Open-file dialog for a purpose (`open`, `place`) → path.
+ pub pick_open: Option,
+ /// Save dialog with a suggested name → path.
+ pub pick_save: Option,
+ pub read: Option,
+ pub write: Option,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ScreenMode {
+ #[default]
+ Normal,
+ Preview,
+ Bleed,
+ Slug,
+ Presentation,
+}
+
+/// Persisted UI state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct UiState {
+ pub brightness: theme::Brightness,
+ pub screen_mode: ScreenMode,
+ pub frame_edges: bool,
+ pub rulers: bool,
+ pub guides: bool,
+ pub baseline_grid: bool,
+ pub document_grid: bool,
+ pub text_threads: bool,
+ pub hidden_characters: bool,
+ pub control_bar: bool,
+ pub tools_double_column: bool,
+ /// Expanded right-dock panel group tab.
+ pub dock_tab: String,
+ /// Panel opened from the collapsed icon column (flyout).
+ pub open_panel: Option,
+ pub dock_expanded: bool,
+ pub units: Unit,
+ #[serde(skip)]
+ pub status: String,
+ #[serde(skip)]
+ pub dialog: Option,
+ #[serde(skip)]
+ pub palette: Option,
+ #[serde(skip)]
+ pub flyout: Option,
+}
+
+impl Default for UiState {
+ fn default() -> Self {
+ UiState {
+ brightness: theme::Brightness::Dark,
+ screen_mode: ScreenMode::Normal,
+ frame_edges: true,
+ rulers: true,
+ guides: true,
+ baseline_grid: false,
+ document_grid: false,
+ text_threads: false,
+ hidden_characters: false,
+ control_bar: true,
+ tools_double_column: false,
+ dock_tab: "properties".into(),
+ open_panel: None,
+ dock_expanded: true,
+ units: Unit::Picas,
+ status: String::new(),
+ dialog: None,
+ palette: None,
+ flyout: None,
+ }
+ }
+}
+
+/// Per-document view: zoom (screen points per document point) and the canvas point at the
+/// top-left of the canvas area.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
+pub struct View {
+ pub zoom: f64,
+ pub origin: Point,
+ pub fitted: bool,
+}
+
+impl Default for View {
+ fn default() -> Self {
+ View { zoom: 0.5, origin: Point::new(-100.0, -100.0), fitted: false }
+ }
+}
+
+pub struct CanvasCache {
+ pub renderer: designcraft_render::Renderer,
+ pub texture: Option,
+ pub key: Option<(u64, u64, u64, u64, u64, u32, u32, u8)>,
+ pub last_ms: f64,
+}
+
+#[derive(Default, Clone, Copy, Debug, Serialize)]
+pub struct Perf {
+ pub frame_ms: f64,
+ pub render_ms: f64,
+ pub fps: f64,
+}
+
+pub struct DesignApp {
+ pub session: Session,
+ pub ui: UiState,
+ pub services: Services,
+ pub views: HashMap,
+ pub canvas: CanvasCache,
+ pub canvas_rect: Option,
+ pub perf: Perf,
+ pub synthetic: Vec,
+ control_rx: Option>,
+ pending_shots: Vec<(u64, Option, Sender, f64)>,
+ queued_shots: Vec<(u64, f64, u32)>,
+ shot_token: u64,
+ styled: bool,
+ pub restyle: bool,
+ fonts_ready: bool,
+ pub integrated_titlebar: bool,
+ last_time: f64,
+}
+
+impl DesignApp {
+ pub fn new(session: Session, services: Services) -> Self {
+ let mut renderer = designcraft_render::Renderer::new();
+ renderer.threads = designcraft_render::default_threads();
+ DesignApp {
+ session,
+ ui: UiState::default(),
+ services,
+ views: HashMap::new(),
+ canvas: CanvasCache { renderer, texture: None, key: None, last_ms: 0.0 },
+ canvas_rect: None,
+ perf: Perf::default(),
+ synthetic: vec![],
+ control_rx: None,
+ pending_shots: vec![],
+ queued_shots: vec![],
+ shot_token: 0,
+ styled: false,
+ restyle: false,
+ fonts_ready: false,
+ integrated_titlebar: false,
+ last_time: 0.0,
+ }
+ }
+
+ pub fn with_control(mut self, rx: Receiver) -> Self {
+ self.control_rx = Some(rx);
+ self
+ }
+
+ pub fn view(&self) -> Option<&View> {
+ let uid = self.session.active()?.uid;
+ self.views.get(&uid)
+ }
+ pub fn view_mut(&mut self) -> Option<&mut View> {
+ let uid = self.session.active()?.uid;
+ Some(self.views.entry(uid).or_default())
+ }
+ pub fn view_info(&self) -> ViewInfo {
+ ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0) }
+ }
+
+ pub fn status(&mut self, s: impl Into) {
+ self.ui.status = s.into();
+ }
+
+ /// THE entry point for every action (menus, shortcuts, palette, panels, control channel).
+ pub fn run(&mut self, id: &str, params: Value) -> Result {
+ if let Some(r) = menus::run_ui(self, id, ¶ms) {
+ return r;
+ }
+ let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
+ self.after_engine();
+ if let Err(e) = &r {
+ self.status(e.clone());
+ }
+ r
+ }
+
+ /// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
+ pub fn after_engine(&mut self) {
+ let reqs = std::mem::take(&mut self.session.ui_requests);
+ for r in reqs {
+ match r {
+ UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)),
+ UiRequest::View { params } => canvas::apply_view_request(self, ¶ms),
+ UiRequest::Pick { purpose, params } => {
+ let _ = params;
+ if let Some(path) = self.services.pick_open.as_mut().and_then(|f| f(&purpose)) {
+ let cmd = if purpose == "place" { "file.place" } else { "file.open" };
+ let _ = self.run(cmd, json!({"path": path}));
+ }
+ }
+ }
+ }
+ // New documents get a fitted view.
+ if let Some(st) = self.session.active() {
+ let uid = st.uid;
+ self.views.entry(uid).or_default();
+ }
+ }
+
+ pub fn select_tool(&mut self, id: &str) {
+ let _ = self.run("tool.select", json!({"tool": id}));
+ self.ui.flyout = None;
+ }
+
+ /// Per-frame logic before layout.
+ pub fn logic(&mut self, ctx: &egui::Context) {
+ if !self.styled {
+ theme::install_fonts(ctx);
+ self.styled = true;
+ self.restyle = true;
+ } else {
+ self.fonts_ready = true;
+ }
+ if self.restyle {
+ theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness));
+ self.restyle = false;
+ }
+ let now = ctx.input(|i| i.time);
+ let dt = now - self.last_time;
+ if dt > 0.0 {
+ self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
+ }
+ self.last_time = now;
+ self.drain_control(ctx);
+ if !self.synthetic.is_empty() {
+ ctx.request_repaint();
+ }
+ self.collect_screenshots(ctx);
+ self.issue_screenshots(ctx);
+ if self.fonts_ready {
+ menus::shortcuts(self, ctx);
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ for f in ctx.input(|i| i.raw.dropped_files.clone()) {
+ {
+ let p = f.path().to_string_lossy().to_string();
+ if p.is_empty() {
+ continue;
+ }
+ let cmd = if p.ends_with(".designcraft") { "file.open" } else { "file.place" };
+ let _ = self.run(cmd, json!({"path": p}));
+ }
+ }
+ }
+
+ /// Inject synthetic events (one pointer event per frame).
+ pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
+ if self.synthetic.is_empty() {
+ return;
+ }
+ let n = match self.synthetic[0] {
+ egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1,
+ _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
+ };
+ if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
+ raw.events.push(egui::Event::PointerMoved(*p));
+ }
+ raw.events.extend(self.synthetic.drain(..n));
+ }
+
+ /// Lay out the whole window.
+ pub fn ui(&mut self, ui: &mut egui::Ui) {
+ let ctx = ui.ctx().clone();
+ if !self.fonts_ready {
+ ctx.request_repaint();
+ return;
+ }
+ let t0 = now_ms();
+ let t = theme::Tokens::get(&ctx);
+ let presenting = self.ui.screen_mode == ScreenMode::Presentation;
+ if !presenting {
+ chrome::app_bar(self, ui);
+ if self.ui.control_bar && self.session.active().is_some() {
+ chrome::control_bar(self, ui);
+ }
+ chrome::status_bar(self, ui);
+ toolbar::show(self, ui);
+ dock::show(self, ui);
+ }
+ egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
+ if self.session.active().is_none() {
+ chrome::start_screen(self, ui);
+ return;
+ }
+ if !presenting {
+ chrome::doc_tabs(self, ui);
+ }
+ canvas::show(self, ui);
+ });
+ dock::flyout(self, &ctx);
+ dialogs::show(self, &ctx);
+ menus::palette(self, &ctx);
+ self.perf.frame_ms = now_ms() - t0;
+ }
+
+ fn drain_control(&mut self, ctx: &egui::Context) {
+ let Some(rx) = self.control_rx.take() else { return };
+ while let Ok(req) = rx.try_recv() {
+ let reply = req.reply.clone();
+ match control::handle(self, ctx, &req) {
+ control::Outcome::Done(v) => {
+ let _ = reply.send(v);
+ }
+ control::Outcome::Screenshot { path } => {
+ self.shot_token += 1;
+ let token = self.shot_token;
+ self.queued_shots.push((token, now_ms() + 120.0, 0));
+ self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
+ }
+ }
+ }
+ self.control_rx = Some(rx);
+ }
+
+ fn issue_screenshots(&mut self, ctx: &egui::Context) {
+ let now = now_ms();
+ self.queued_shots.retain_mut(|(token, at, frames)| {
+ *frames += 1;
+ if now >= *at && *frames >= 3 {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
+ false
+ } else {
+ true
+ }
+ });
+ if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
+ ctx.request_repaint_after(std::time::Duration::from_millis(16));
+ }
+ }
+
+ fn collect_screenshots(&mut self, ctx: &egui::Context) {
+ if self.pending_shots.is_empty() {
+ return;
+ }
+ let events: Vec<_> = ctx.input(|i| {
+ i.raw
+ .events
+ .iter()
+ .filter_map(|e| match e {
+ egui::Event::Screenshot { user_data, image, .. } => {
+ let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?;
+ Some((token, image.clone()))
+ }
+ _ => None,
+ })
+ .collect()
+ });
+ for (token, image) in events {
+ if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
+ let (_, path, reply, _) = self.pending_shots.remove(i);
+ let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
+ }
+ }
+ let now = now_ms();
+ self.pending_shots.retain(|(_, _, reply, deadline)| {
+ if now < *deadline {
+ return true;
+ }
+ let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"}));
+ false
+ });
+ }
+}
+
+pub fn now_ms() -> f64 {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ 0.0
+ }
+}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
new file mode 100644
index 00000000..e78678a3
--- /dev/null
+++ b/crates/ui-egui/src/menus.rs
@@ -0,0 +1,538 @@
+//! The menu bar tree (InDesign order), UI-only commands, keyboard shortcuts and the ⌘K palette.
+
+use serde_json::{Value, json};
+
+use crate::{DesignApp, ScreenMode};
+
+/// UI commands: (id, label, shortcut, params).
+pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
+ ("app.newDocumentDialog", "New Document…", Some("Cmd+N"), "{}"),
+ ("app.openDialog", "Open…", Some("Cmd+O"), "{}"),
+ ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"),
+ ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
+ ("app.save", "Save", Some("Cmd+S"), "{}"),
+ ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
+ ("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
+ ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
+ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
+ ("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
+ ("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"),
+ ("view.actualSize", "Actual Size", Some("Cmd+1"), "{}"),
+ ("view.entirePasteboard", "Entire Pasteboard", Some("Cmd+Alt+Shift+0"), "{}"),
+ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"),
+ ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"),
+ ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"),
+ ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"),
+ ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"),
+ ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"),
+ ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"),
+ ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"),
+ ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"),
+ ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page}"),
+ (
+ "window.panel",
+ "Show Panel",
+ None,
+ "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links}",
+ ),
+ ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
+ ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
+ ("window.brightness", "Interface Color Theme", None, "{brightness: dark|mediumDark|mediumLight|light}"),
+];
+
+/// Menu bar: (menu, entries). Entries: `cmd:`, `ui:`, `-` separator, `>Submenu` … `<`.
+pub const MENUS: &[(&str, &[&str])] = &[
+ (
+ "File",
+ &[
+ "ui:app.newDocumentDialog",
+ "ui:app.openDialog",
+ "-",
+ "cmd:file.close",
+ "ui:app.save",
+ "ui:app.saveDialog",
+ "-",
+ "ui:app.placeDialog",
+ "-",
+ "ui:app.exportPng",
+ "-",
+ "cmd:layout.documentSetup",
+ ],
+ ),
+ (
+ "Edit",
+ &[
+ "cmd:edit.undo",
+ "cmd:edit.redo",
+ "-",
+ "cmd:edit.cut",
+ "cmd:edit.copy",
+ "cmd:edit.paste",
+ "cmd:edit.pasteInPlace",
+ "cmd:edit.clear",
+ "-",
+ "cmd:edit.duplicate",
+ "-",
+ "cmd:edit.selectAll",
+ "cmd:edit.deselectAll",
+ "-",
+ "ui:app.palette",
+ ],
+ ),
+ ("Layout", &[">Pages", "cmd:layout.pages.insert", "cmd:layout.parents.new", "<", "cmd:layout.marginsAndColumns", "-", "ui:view.goToPage"]),
+ (
+ "Type",
+ &[
+ "cmd:type.fillWithPlaceholder",
+ "-",
+ "cmd:type.alignLeft",
+ "cmd:type.alignCenter",
+ "cmd:type.alignRight",
+ "cmd:type.justify",
+ "-",
+ "cmd:type.bold",
+ "cmd:type.italic",
+ "cmd:type.underline",
+ "cmd:type.allCaps",
+ "-",
+ "cmd:type.sizeUp",
+ "cmd:type.sizeDown",
+ "-",
+ "ui:view.hiddenCharacters",
+ ],
+ ),
+ (
+ "Object",
+ &[
+ ">Transform",
+ "cmd:transform.flip",
+ "<",
+ ">Arrange",
+ "cmd:object.bringToFront",
+ "cmd:object.bringForward",
+ "cmd:object.sendBackward",
+ "cmd:object.sendToBack",
+ "<",
+ "-",
+ "cmd:object.group",
+ "cmd:object.ungroup",
+ "cmd:object.lock",
+ "cmd:object.unlockAll",
+ "cmd:object.hide",
+ "cmd:object.showAll",
+ "-",
+ "cmd:object.textFrameOptions",
+ "cmd:object.cornerOptions",
+ "-",
+ ">Effects",
+ "cmd:object.dropShadow",
+ "<",
+ ],
+ ),
+ ("Table", &[]),
+ (
+ "View",
+ &[
+ "ui:view.zoomIn",
+ "ui:view.zoomOut",
+ "ui:view.fitPage",
+ "ui:view.fitSpread",
+ "ui:view.actualSize",
+ "ui:view.entirePasteboard",
+ "-",
+ "ui:view.togglePreview",
+ "-",
+ "ui:view.rulers",
+ "ui:view.frameEdges",
+ "ui:view.textThreads",
+ "ui:view.hiddenCharacters",
+ "-",
+ "ui:view.guides",
+ "ui:view.baselineGrid",
+ ],
+ ),
+ ("Window", &["ui:window.controlBar", "ui:window.toolsDoubleColumn", "-", "ui:window.panel", "-", "ui:window.brightness"]),
+ ("Help", &["cmd:file.newSample"]),
+];
+
+pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> {
+ UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.2))
+}
+
+/// Run a UI command; `None` if `id` isn't one.
+pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> {
+ let rect = app.canvas_rect;
+ let flag = |b: &mut bool| {
+ *b = !*b;
+ Ok(json!(*b))
+ };
+ Some(match id {
+ "app.newDocumentDialog" => {
+ app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
+ Ok(Value::Null)
+ }
+ "app.openDialog" => {
+ if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) {
+ return Some(app.run("file.open", json!({"path": path})));
+ }
+ Ok(Value::Null)
+ }
+ "app.placeDialog" => {
+ if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) {
+ return Some(app.run("file.place", json!({"path": path})));
+ }
+ Ok(Value::Null)
+ }
+ "app.save" => {
+ if app.session.active().is_some_and(|d| d.path.is_some()) {
+ return Some(app.run("file.save", json!({})));
+ }
+ return run_ui(app, "app.saveDialog", p);
+ }
+ "app.saveDialog" => {
+ let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default();
+ if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
+ return Some(app.run("file.saveAs", json!({"path": path})));
+ }
+ Ok(Value::Null)
+ }
+ "app.exportPng" => export_png(app, p),
+ "app.palette" => {
+ app.ui.palette = Some(String::new());
+ Ok(Value::Null)
+ }
+ "view.zoomIn" | "view.zoomOut" => {
+ if let Some(r) = rect {
+ crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
+ }
+ Ok(Value::Null)
+ }
+ "view.fitPage" | "view.fitSpread" | "view.entirePasteboard" => {
+ if let Some(r) = rect {
+ crate::canvas::fit(
+ app,
+ r,
+ if id == "view.fitPage" {
+ "page"
+ } else if id == "view.fitSpread" {
+ "spread"
+ } else {
+ "all"
+ },
+ );
+ }
+ Ok(Value::Null)
+ }
+ "view.actualSize" => {
+ crate::canvas::set_zoom(app, 1.0);
+ Ok(Value::Null)
+ }
+ "view.zoom" => {
+ crate::canvas::set_zoom(app, p.get("zoom").and_then(Value::as_f64).unwrap_or(1.0));
+ Ok(Value::Null)
+ }
+ "view.screenMode" => {
+ app.ui.screen_mode = match p.get("mode").and_then(Value::as_str).unwrap_or("normal") {
+ "preview" => ScreenMode::Preview,
+ "bleed" => ScreenMode::Bleed,
+ "slug" => ScreenMode::Slug,
+ "presentation" => ScreenMode::Presentation,
+ _ => ScreenMode::Normal,
+ };
+ Ok(Value::Null)
+ }
+ "view.togglePreview" => {
+ app.ui.screen_mode = if app.ui.screen_mode == ScreenMode::Normal { ScreenMode::Preview } else { ScreenMode::Normal };
+ Ok(Value::Null)
+ }
+ "view.frameEdges" => flag(&mut app.ui.frame_edges),
+ "view.rulers" => flag(&mut app.ui.rulers),
+ "view.guides" => flag(&mut app.ui.guides),
+ "view.baselineGrid" => flag(&mut app.ui.baseline_grid),
+ "view.textThreads" => flag(&mut app.ui.text_threads),
+ "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters),
+ "view.goToPage" => {
+ match p.get("page").and_then(Value::as_u64) {
+ Some(n) => crate::canvas::go_to_page(app, (n as usize).saturating_sub(1)),
+ None => app.ui.dialog = Some(crate::dialogs::Dialog::new("goToPage", json!({}))),
+ }
+ Ok(Value::Null)
+ }
+ "window.panel" => {
+ let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string();
+ if crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) {
+ app.ui.dock_tab = panel;
+ app.ui.dock_expanded = true;
+ app.ui.open_panel = None;
+ } else {
+ app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) };
+ }
+ Ok(Value::Null)
+ }
+ "window.controlBar" => flag(&mut app.ui.control_bar),
+ "window.toolsDoubleColumn" => flag(&mut app.ui.tools_double_column),
+ "window.brightness" => {
+ let b = p.get("brightness").and_then(Value::as_str).and_then(crate::theme::Brightness::parse).unwrap_or(crate::theme::Brightness::Dark);
+ app.ui.brightness = b;
+ app.restyle = true;
+ Ok(Value::Null)
+ }
+ _ => return None,
+ })
+}
+
+fn export_png(app: &mut DesignApp, p: &Value) -> Result {
+ let st = app.session.active().ok_or("no document")?;
+ let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
+ let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0);
+ let mut r = designcraft_render::Renderer::new();
+ let img = r
+ .render_page(
+ &st.doc,
+ &app.session.cache,
+ page,
+ scale,
+ false,
+ &designcraft_render::RenderOptions { printing_only: true, ..Default::default() },
+ )
+ .ok_or("no such page")?;
+ let png = img.to_png();
+ let path = match p.get("path").and_then(Value::as_str) {
+ Some(s) => Some(s.to_string()),
+ None => {
+ let name = format!("{}-p{}.png", st.doc.title, page + 1);
+ app.services.pick_save.as_mut().and_then(|f| f(&name))
+ }
+ };
+ let Some(path) = path else { return Ok(Value::Null) };
+ match app.services.write.as_mut() {
+ Some(w) => w(&path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height})),
+ None => Err("no writer".into()),
+ }
+}
+
+/// Is a command enabled (for menu greying)?
+pub fn enabled(app: &DesignApp, id: &str) -> bool {
+ match designcraft_engine::find_command(id) {
+ Some(c) => (c.enabled)(&app.session).is_ok(),
+ None => true,
+ }
+}
+
+fn shortcut_text(sc: &str) -> String {
+ let mac = cfg!(target_os = "macos");
+ sc.split('+')
+ .map(|k| match k {
+ "Cmd" => if mac { "⌘" } else { "Ctrl+" }.to_string(),
+ "Shift" => if mac { "⇧" } else { "Shift+" }.to_string(),
+ "Alt" => if mac { "⌥" } else { "Alt+" }.to_string(),
+ k => k.to_string(),
+ })
+ .collect()
+}
+
+/// The menu bar contents (inside the app bar).
+pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
+ for (menu, entries) in MENUS {
+ ui.menu_button(*menu, |ui| {
+ ui.set_min_width(240.0);
+ menu_entries(app, ui, entries);
+ });
+ }
+}
+
+fn menu_entries(app: &mut DesignApp, ui: &mut egui::Ui, entries: &[&str]) {
+ let mut i = 0;
+ while i < entries.len() {
+ let e = entries[i];
+ i += 1;
+ if e == "-" {
+ ui.separator();
+ continue;
+ }
+ if let Some(name) = e.strip_prefix('>') {
+ let start = i;
+ while i < entries.len() && entries[i] != "<" {
+ i += 1;
+ }
+ let sub = &entries[start..i];
+ i += 1;
+ ui.menu_button(name, |ui| menu_entries(app, ui, sub));
+ continue;
+ }
+ if e == "ui:window.panel" {
+ ui.menu_button("Panels", |ui| {
+ for (id, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
+ if ui.button(*label).clicked() {
+ let _ = app.run("window.panel", json!({"panel": id}));
+ ui.close();
+ }
+ }
+ });
+ continue;
+ }
+ if e == "ui:window.brightness" {
+ ui.menu_button("Interface Color Theme", |ui| {
+ for b in crate::theme::Brightness::ALL {
+ if ui.radio(app.ui.brightness == b, b.label()).clicked() {
+ let _ = app.run("window.brightness", json!({"brightness": b.id()}));
+ ui.close();
+ }
+ }
+ });
+ continue;
+ }
+ let (kind, id) = e.split_once(':').unwrap_or(("cmd", e));
+ let (label, sc, en) = if kind == "ui" {
+ let (l, sc) = ui_label(id).unwrap_or((id, None));
+ (l.to_string(), sc, app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."))
+ } else {
+ match designcraft_engine::find_command(id) {
+ Some(c) => (c.label.to_string(), c.shortcut, enabled(app, id)),
+ None => (id.to_string(), None, false),
+ }
+ };
+ let checked = match id {
+ "view.rulers" => Some(app.ui.rulers),
+ "view.frameEdges" => Some(app.ui.frame_edges),
+ "view.guides" => Some(app.ui.guides),
+ "view.baselineGrid" => Some(app.ui.baseline_grid),
+ "view.textThreads" => Some(app.ui.text_threads),
+ "view.hiddenCharacters" => Some(app.ui.hidden_characters),
+ "window.controlBar" => Some(app.ui.control_bar),
+ _ => None,
+ };
+ let text = match checked {
+ Some(true) => format!("✓ {label}"),
+ Some(false) => format!(" {label}"),
+ None => label,
+ };
+ let mut b = egui::Button::new(text);
+ if let Some(sc) = sc {
+ b = b.shortcut_text(shortcut_text(sc));
+ }
+ if ui.add_enabled(en, b).clicked() {
+ let _ = app.run(id, json!({}));
+ ui.close();
+ }
+ }
+}
+
+fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
+ let mut m = egui::Modifiers::NONE;
+ let mut key = None;
+ for part in sc.split('+').filter(|s| !s.is_empty()) {
+ match part {
+ "Cmd" => m.command = true,
+ "Shift" => m.shift = true,
+ "Alt" => m.alt = true,
+ "Ctrl" => m.ctrl = true,
+ "=" => key = Some(egui::Key::Equals),
+ "-" => key = Some(egui::Key::Minus),
+ "[" => key = Some(egui::Key::OpenBracket),
+ "]" => key = Some(egui::Key::CloseBracket),
+ ";" => key = Some(egui::Key::Semicolon),
+ "'" => key = Some(egui::Key::Quote),
+ "," => key = Some(egui::Key::Comma),
+ "." => key = Some(egui::Key::Period),
+ "\\" => key = Some(egui::Key::Backslash),
+ "Delete" => key = Some(egui::Key::Delete),
+ k => key = egui::Key::from_name(k),
+ }
+ }
+ if sc.ends_with("+-") || sc == "-" {
+ key = Some(egui::Key::Minus);
+ }
+ key.map(|k| (m, k))
+}
+
+/// Global keyboard shortcuts: menu commands and single-key tool shortcuts.
+pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
+ if ctx.egui_wants_keyboard_input() || app.ui.dialog.is_some() || app.ui.palette.is_some() {
+ return;
+ }
+ let typing = app.session.wants_text();
+ let events = ctx.input(|i| i.events.clone());
+ for e in events {
+ let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue };
+ // Command shortcuts.
+ let mut fired = None;
+ let all = designcraft_engine::command_specs()
+ .iter()
+ .filter_map(|c| c.shortcut.map(|s| (c.id, s)))
+ .chain(UI_COMMANDS.iter().filter_map(|c| c.2.map(|s| (c.0, s))));
+ for (id, sc) in all {
+ if let Some((m, k)) = parse_shortcut(sc)
+ && k == key
+ && m.command == modifiers.command
+ && m.shift == modifiers.shift
+ && m.alt == modifiers.alt
+ && (m.command || m.alt || !typing)
+ && !(id == "edit.clear" && typing)
+ {
+ // Single-key shortcuts (W) only when not typing.
+ if !m.command && !m.alt && !m.shift && typing {
+ continue;
+ }
+ if id == "edit.clear" {
+ continue; // Delete is handled by the active tool.
+ }
+ fired = Some(id);
+ break;
+ }
+ }
+ if let Some(id) = fired {
+ let _ = app.run(id, json!({}));
+ continue;
+ }
+ // Tool shortcuts (single keys, Shift+key).
+ if !typing && !modifiers.command && !modifiers.alt {
+ let name = key.name();
+ let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() };
+ if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) {
+ app.select_tool(tool);
+ } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() {
+ app.select_tool("selection");
+ }
+ }
+ }
+}
+
+/// ⌘K command palette.
+pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
+ let Some(mut q) = app.ui.palette.clone() else { return };
+ let mut close = false;
+ let mut run: Option = None;
+ egui::Modal::new(egui::Id::new("palette")).show(ctx, |ui| {
+ ui.set_width(480.0);
+ let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands…").desired_width(f32::INFINITY));
+ r.request_focus();
+ let ql = q.to_lowercase();
+ let mut items: Vec<(String, String, Option<&str>)> = designcraft_engine::command_specs()
+ .iter()
+ .filter(|c| !c.menu.is_empty() || c.shortcut.is_some())
+ .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut))
+ .chain(UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2)))
+ .filter(|(id, l, _)| ql.is_empty() || l.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql))
+ .collect();
+ items.truncate(14);
+ egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| {
+ for (i, (id, l, sc)) in items.iter().enumerate() {
+ let mut b = egui::Button::new(l.as_str()).frame(false);
+ if let Some(sc) = sc {
+ b = b.shortcut_text(shortcut_text(sc));
+ }
+ if ui.add_sized([460.0, 22.0], b).clicked() || (i == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
+ run = Some(id.clone());
+ }
+ }
+ });
+ if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+ close = true;
+ }
+ });
+ app.ui.palette = if close || run.is_some() { None } else { Some(q) };
+ if let Some(id) = run {
+ let _ = app.run(&id, json!({}));
+ }
+}
diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs
new file mode 100644
index 00000000..afc8f623
--- /dev/null
+++ b/crates/ui-egui/src/panels/layers.rs
@@ -0,0 +1,73 @@
+//! Layers panel.
+
+use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
+use serde_json::json;
+
+use crate::theme::Tokens;
+use crate::{DesignApp, icons};
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let Some(st) = app.session.active() else { return };
+ let doc = st.doc.clone();
+ let active = st.active_layer;
+ let sel_layers: Vec<_> = st.selection.items.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect();
+ for l in &doc.layers {
+ let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
+ if l.id == active {
+ ui.painter().rect_filled(row, 0.0, t.row_selected);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(row, 0.0, t.hover);
+ }
+ let eye = egui::Rect::from_min_size(row.min + vec2(2.0, 4.0), vec2(18.0, 18.0));
+ let lock = egui::Rect::from_min_size(row.min + vec2(24.0, 4.0), vec2(18.0, 18.0));
+ if l.visible {
+ icons::paint(ui.painter(), eye, "eye", t.icon);
+ }
+ if l.locked {
+ icons::paint(ui.painter(), lock, "lock", t.icon);
+ }
+ ui.painter().rect_stroke(lock.expand(1.0), 0.0, Stroke::new(1.0, t.divider), StrokeKind::Inside);
+ let chip = egui::Rect::from_min_size(row.min + vec2(48.0, 8.0), vec2(4.0, 10.0));
+ ui.painter().rect_filled(chip, 0.0, Color32::from_rgb(l.color[0], l.color[1], l.color[2]));
+ ui.painter().text(row.min + vec2(58.0, 13.0), egui::Align2::LEFT_CENTER, &l.name, egui::FontId::proportional(12.5), t.text);
+ // Selection square in the layer colour.
+ if sel_layers.contains(&l.id) {
+ let sq = egui::Rect::from_center_size(egui::pos2(row.max.x - 12.0, row.center().y), vec2(7.0, 7.0));
+ ui.painter().rect_filled(sq, 0.0, Color32::from_rgb(l.color[0], l.color[1], l.color[2]));
+ }
+ let eye_r = ui.interact(eye, ui.id().with(("eye", l.id.0)), Sense::click());
+ let lock_r = ui.interact(lock, ui.id().with(("lock", l.id.0)), Sense::click());
+ if eye_r.clicked() {
+ let _ = app.run("layer.set", json!({"id": l.id.0, "visible": !l.visible}));
+ } else if lock_r.clicked() {
+ let _ = app.run("layer.set", json!({"id": l.id.0, "locked": !l.locked}));
+ } else if resp.clicked() {
+ let _ = app.run("layer.activate", json!({"id": l.id.0}));
+ }
+ resp.context_menu(|ui| {
+ if ui.button("Delete Layer").clicked() {
+ let _ = app.run("layer.delete", json!({"id": l.id.0}));
+ ui.close();
+ }
+ if ui.button("Move Selection Here").clicked() {
+ let _ = app.run("object.setLayer", json!({"layer": l.id.0}));
+ ui.close();
+ }
+ });
+ }
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new(format!("{} Layer{}", doc.layers.len(), if doc.layers.len() == 1 { "" } else { "s" })).size(11.0).color(t.text_dim),
+ );
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if icons::button(ui, "trash", 20.0, false, "Delete Layer").clicked() {
+ let _ = app.run("layer.delete", json!({"id": active.0}));
+ }
+ if icons::button(ui, "plus", 20.0, false, "Create New Layer").clicked() {
+ let _ = app.run("layer.new", json!({}));
+ }
+ });
+ });
+}
diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs
new file mode 100644
index 00000000..5f20e4ab
--- /dev/null
+++ b/crates/ui-egui/src/panels/mod.rs
@@ -0,0 +1,178 @@
+//! Panels and shared panel helpers.
+
+pub mod layers;
+pub mod pages;
+pub mod properties;
+pub mod styles;
+pub mod swatches;
+
+use designcraft_doc::{Content, StrokeAlign, WrapMode};
+use designcraft_geom::Rect;
+use egui::vec2;
+use serde_json::{Value, json};
+
+use crate::DesignApp;
+use crate::theme::Tokens;
+
+/// Summary of the current object selection (for the Control panel and Properties).
+#[derive(Clone, Debug)]
+pub struct SelInfo {
+ /// Bounds relative to the page the selection is on.
+ pub page_rect: Rect,
+ pub count: usize,
+ pub fill: String,
+ pub stroke: String,
+ pub stroke_weight: f64,
+ pub stroke_align: StrokeAlign,
+ pub opacity: f64,
+ pub shadow: bool,
+ pub wrap: &'static str,
+ pub columns: Option,
+ pub kind: &'static str,
+ pub is_graphic: bool,
+ pub is_text: bool,
+}
+
+pub fn sel_info(app: &DesignApp) -> Option {
+ let st = app.session.active()?;
+ if st.selection.items.is_empty() {
+ return None;
+ }
+ let d = &st.doc;
+ let mut b: Option = None;
+ for id in &st.selection.items {
+ let it = d.item(*id)?;
+ b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds())));
+ }
+ let b = b?;
+ let first = d.item(st.selection.items[0])?;
+ let loc = d.find(first.id)?;
+ let sp = d.spread(loc.spread)?;
+ let pi = sp.page_at_x(b.center().x).unwrap_or(0);
+ let px = sp.pages.get(pi).map(|p| p.x).unwrap_or(0.0);
+ let wrap = match first.wrap.mode {
+ WrapMode::None => "none",
+ WrapMode::BoundingBox => "boundingBox",
+ WrapMode::Contour => "contour",
+ WrapMode::JumpObject => "jumpObject",
+ WrapMode::JumpToNextColumn => "jumpToNextColumn",
+ };
+ Some(SelInfo {
+ page_rect: Rect::new(b.x0 - px, b.y0, b.x1 - px, b.y1),
+ count: st.selection.items.len(),
+ fill: first.fill.swatch.clone(),
+ stroke: first.stroke.swatch.clone(),
+ stroke_weight: first.stroke.weight,
+ stroke_align: first.stroke.align,
+ opacity: first.opacity as f64,
+ shadow: first.effects.drop_shadow.on,
+ wrap,
+ columns: first.text_frame().map(|t| t.options.columns),
+ kind: first.default_label(),
+ is_graphic: matches!(first.content, Content::Graphic(_)),
+ is_text: first.is_text_frame(),
+ })
+}
+
+/// Resolved attributes at the text selection (or the selected text frames).
+pub fn text_attrs(app: &mut DesignApp) -> Option {
+ let v = app.session.execute("type.selectionAttrs", &json!({})).ok()?;
+ if v.is_null() { None } else { Some(serde_json::to_value(v).ok()?) }
+}
+
+/// Count preflight errors: overset stories (more checks land with the Preflight panel).
+pub fn preflight_errors(app: &DesignApp) -> usize {
+ let Some(st) = app.session.active() else { return 0 };
+ st.doc.stories.keys().filter(|sid| app.session.cache.get(&st.doc, **sid, None).is_overset()).count()
+}
+
+/// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name.
+pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option, on_pick: impl FnOnce(&mut DesignApp, String)) {
+ let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
+ let cur = current.unwrap_or_default();
+ let (c, g) = crate::widgets::swatch_colors(&doc, &cur, 1.0);
+ let mut picked = None;
+ ui.push_id(id, |ui| {
+ let (r, resp) = ui.allocate_exact_size(vec2(30.0, 18.0), egui::Sense::click());
+ crate::widgets::paint_chip(
+ ui.painter(),
+ egui::Rect::from_min_size(r.min, vec2(18.0, 18.0)),
+ if cur.is_empty() { Some(egui::Color32::GRAY) } else { c },
+ g,
+ );
+ crate::icons::paint(
+ ui.painter(),
+ egui::Rect::from_min_size(r.min + vec2(18.0, 3.0), vec2(12.0, 12.0)),
+ "chevron-down",
+ Tokens::get(ui.ctx()).icon,
+ );
+ egui::Popup::menu(&resp).show(|ui| {
+ ui.set_min_width(200.0);
+ egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
+ for sw in &doc.swatches {
+ let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0);
+ let row = ui.horizontal(|ui| {
+ let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
+ crate::widgets::paint_chip(ui.painter(), cr, c, g);
+ ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur))
+ });
+ if row.inner.clicked() {
+ picked = Some(sw.name.clone());
+ ui.close();
+ }
+ }
+ });
+ });
+ });
+ if let Some(p) = picked {
+ on_pick(app, p);
+ }
+}
+
+pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
+ let fams = designcraft_fonts::FontDb::global().families();
+ let mut pick = None;
+ egui::ComboBox::from_id_salt("font_family").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| {
+ for f in &fams {
+ if ui.selectable_label(f == current, f).clicked() {
+ pick = Some(f.clone());
+ }
+ }
+ });
+ if let Some(f) = pick {
+ let styles = designcraft_fonts::FontDb::global().styles(&f);
+ let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
+ let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
+ }
+}
+
+pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) {
+ let styles = designcraft_fonts::FontDb::global().styles(family);
+ let mut pick = None;
+ egui::ComboBox::from_id_salt("font_style").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| {
+ for s in &styles {
+ if ui.selectable_label(s == current, s).clicked() {
+ pick = Some(s.clone());
+ }
+ }
+ });
+ if let Some(s) = pick {
+ let _ = app.run("type.char", json!({"attrs": {"fontStyle": s}}));
+ }
+}
+
+pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, overridden: bool, width: f32) {
+ let names: Vec = app.session.active().map(|d| d.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default();
+ let mut pick = None;
+ let label = if overridden { format!("{current}+") } else { current.to_string() };
+ egui::ComboBox::from_id_salt("para_style").selected_text(label).width(width).show_ui(ui, |ui| {
+ for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) {
+ if ui.selectable_label(n == current, n).clicked() {
+ pick = Some(n.clone());
+ }
+ }
+ });
+ if let Some(n) = pick {
+ let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": false}));
+ }
+}
diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs
new file mode 100644
index 00000000..2afade0c
--- /dev/null
+++ b/crates/ui-egui/src/panels/pages.rs
@@ -0,0 +1,166 @@
+//! Pages panel: parents at the top, document spreads with live thumbnails below.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use designcraft_doc::SpreadRef;
+use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
+use serde_json::json;
+
+use crate::theme::{Tokens, semibold};
+use crate::{DesignApp, icons};
+
+type ThumbKey = (u64, u64, usize);
+
+static THUMBS: Mutex>> = Mutex::new(None);
+
+fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option {
+ let st = app.session.active()?;
+ let key = (st.uid, st.revision, abs);
+ let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner());
+ let map = g.get_or_insert_with(HashMap::new);
+ if let Some(t) = map.get(&key) {
+ return Some(t.clone());
+ }
+ let page = st.doc.page(abs)?;
+ let scale = (h as f64 * ctx.pixels_per_point() as f64) / page.height;
+ let mut r = designcraft_render::Renderer::new();
+ r.threads = 0;
+ let img = r.render_page(
+ &st.doc,
+ &app.session.cache,
+ abs,
+ scale,
+ false,
+ &designcraft_render::RenderOptions { printing_only: true, ..Default::default() },
+ )?;
+ let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
+ let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR);
+ // Drop thumbnails of older revisions.
+ let (uid, rev) = (st.uid, st.revision);
+ map.retain(|k, _| !(k.0 == uid && k.1 != rev));
+ map.insert(key, tex.clone());
+ Some(tex)
+}
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let Some(st) = app.session.active() else { return };
+ let doc = st.doc.clone();
+ let editing_parents = st.editing_parents;
+ // Parents.
+ ui.label(egui::RichText::new("Parents").font(semibold(11.5)).color(t.text_dim));
+ ui.horizontal_wrapped(|ui| {
+ let none = ui.add(egui::Button::new("[None]").frame(false));
+ if none.clicked() {
+ let _ = app.run("layout.parents.edit", json!({"on": false}));
+ }
+ for p in &doc.parents {
+ let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default();
+ let resp =
+ ui.add(egui::Button::new(egui::RichText::new(&label).color(if editing_parents { t.text_strong } else { t.text })).frame(false));
+ if resp.double_clicked() || resp.clicked() {
+ let _ = app.run("layout.parents.edit", json!({"on": true}));
+ app.views.clear();
+ }
+ }
+ });
+ let r = ui.available_rect_before_wrap();
+ ui.painter().line_segment([egui::pos2(r.min.x, r.min.y + 2.0), egui::pos2(r.max.x, r.min.y + 2.0)], Stroke::new(1.0, t.divider));
+ ui.add_space(8.0);
+ let cur = crate::canvas::current_page(app).unwrap_or(0);
+ let th = 76.0;
+ let center = ui.available_width() / 2.0;
+ for (si, sp) in doc.spreads.iter().enumerate() {
+ let first = doc.first_page_of_spread(si);
+ let scale = th / doc.settings.page_height.max(1.0) as f32;
+ // Pages relative to the spine.
+ let spine = sp.spine_x() as f32;
+ let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), th + 22.0), Sense::hover());
+ for (pi, p) in sp.pages.iter().enumerate() {
+ let abs = first + pi;
+ let x0 = row.min.x + center + (p.x as f32 - spine) * scale;
+ let pr = egui::Rect::from_min_size(egui::pos2(x0, row.min.y + 2.0), vec2(p.width as f32 * scale, p.height as f32 * scale));
+ let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click());
+ if let Some(tex) = thumb(app, ui.ctx(), abs, th) {
+ ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
+ } else {
+ ui.painter().rect_filled(pr, 0.0, Color32::WHITE);
+ }
+ let selected = abs == cur && !editing_parents;
+ ui.painter().rect_stroke(
+ pr,
+ 0.0,
+ Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.divider }),
+ StrokeKind::Outside,
+ );
+ // Parent prefix in the top corner.
+ if let Some(pid) = p.parent
+ && let Some(pp) = doc.parents.iter().find(|x| x.id == pid)
+ {
+ let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default();
+ let corner =
+ if p.side == designcraft_doc::PageSide::Left { pr.left_top() + vec2(3.0, 2.0) } else { pr.right_top() + vec2(-9.0, 2.0) };
+ ui.painter().text(corner, egui::Align2::LEFT_TOP, prefix, egui::FontId::proportional(9.0), Color32::from_gray(90));
+ }
+ if resp.clicked() || resp.double_clicked() {
+ if editing_parents {
+ let _ = app.run("layout.parents.edit", json!({"on": false}));
+ app.views.clear();
+ }
+ crate::canvas::go_to_page(app, abs);
+ }
+ resp.context_menu(|ui| {
+ if ui.button("Insert Page After").clicked() {
+ let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1}));
+ ui.close();
+ }
+ if ui.button("Delete Page").clicked() {
+ let _ = app.run("layout.pages.delete", json!({"pages": [abs]}));
+ ui.close();
+ }
+ if ui.button("Duplicate Spread").clicked() {
+ let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si}));
+ ui.close();
+ }
+ ui.menu_button("Apply Parent", |ui| {
+ if ui.button("[None]").clicked() {
+ let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null}));
+ ui.close();
+ }
+ for pp in &doc.parents {
+ let l = pp.parent.as_ref().map(|i| i.label()).unwrap_or_default();
+ if ui.button(&l).clicked() {
+ let _ =
+ app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.parent.as_ref().map(|i| i.prefix.clone())}));
+ ui.close();
+ }
+ }
+ });
+ });
+ }
+ // Page numbers under the spread.
+ let names: Vec = (0..sp.pages.len()).map(|i| doc.page_name(first + i)).collect();
+ let label = if names.len() > 1 { format!("{}–{}", names[0], names[names.len() - 1]) } else { names[0].clone() };
+ ui.painter().text(
+ egui::pos2(row.min.x + center, row.max.y - 8.0),
+ egui::Align2::CENTER_CENTER,
+ label,
+ egui::FontId::proportional(11.0),
+ t.text_dim,
+ );
+ let _ = SpreadRef::Doc(si);
+ }
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(format!("{} Pages in {} Spreads", doc.page_count(), doc.spreads.len())).size(11.0).color(t.text_dim));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if icons::button(ui, "trash", 20.0, false, "Delete Page").clicked() {
+ let _ = app.run("layout.pages.delete", json!({"pages": [cur]}));
+ }
+ if icons::button(ui, "plus", 20.0, false, "Insert Page").clicked() {
+ let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1}));
+ }
+ });
+ });
+}
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
new file mode 100644
index 00000000..0094e2a4
--- /dev/null
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -0,0 +1,596 @@
+//! The Properties panel (context-sensitive) and small single-purpose panels (Stroke, Character,
+//! Paragraph, Text Wrap, Align, Links, Info).
+
+use designcraft_doc::Align;
+use designcraft_geom::Unit;
+use egui::vec2;
+use serde_json::json;
+
+use super::{SelInfo, sel_info, text_attrs};
+use crate::theme::Tokens;
+use crate::widgets::{caption, divider, measure, number, section};
+use crate::{DesignApp, icons};
+
+fn units(app: &DesignApp) -> Unit {
+ app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas)
+}
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let Some(st) = app.session.active() else {
+ ui.label(egui::RichText::new("No document open").color(t.text_dim));
+ return;
+ };
+ let has_text = st.selection.text.is_some();
+ let info = sel_info(app);
+ if has_text {
+ ui.label(egui::RichText::new("Text").color(t.text_dim));
+ character_panel(app, ui);
+ divider(ui);
+ paragraph_panel(app, ui);
+ divider(ui);
+ if section(ui, "Text Frame", false) {
+ text_frame_section(app, ui);
+ }
+ return;
+ }
+ match info {
+ Some(i) => {
+ let kind = if i.count > 1 { format!("{} objects", i.count) } else { i.kind.trim_matches(['<', '>']).to_string() };
+ ui.label(egui::RichText::new(capitalize(&kind)).color(t.text_dim));
+ if section(ui, "Transform", true) {
+ transform_section(app, ui, &i);
+ }
+ divider(ui);
+ if section(ui, "Appearance", true) {
+ appearance_section(app, ui, &i);
+ }
+ divider(ui);
+ if i.count > 1 && section(ui, "Align", true) {
+ align_panel(app, ui);
+ divider(ui);
+ }
+ if i.is_text {
+ if section(ui, "Text Frame", true) {
+ text_frame_section(app, ui);
+ }
+ divider(ui);
+ }
+ if i.is_graphic {
+ if section(ui, "Frame Fitting", true) {
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [
+ ("fit-fill", "fillProportionally", "Fill Frame Proportionally"),
+ ("fit-prop", "fitProportionally", "Fit Content Proportionally"),
+ ("fit-content", "fitContentToFrame", "Fit Content to Frame"),
+ ("fit-frame", "fitFrameToContent", "Fit Frame to Content"),
+ ("fit-center", "centerContent", "Center Content"),
+ ] {
+ if icons::button(ui, icon, 24.0, false, tip).clicked() {
+ let _ = app.run("object.fit", json!({"mode": mode}));
+ }
+ }
+ });
+ }
+ divider(ui);
+ }
+ if section(ui, "Text Wrap", false) {
+ wrap_panel(app, ui);
+ }
+ divider(ui);
+ if section(ui, "Quick Actions", true) {
+ ui.horizontal_wrapped(|ui| {
+ for (label, cmd) in [
+ ("Group", "object.group"),
+ ("Ungroup", "object.ungroup"),
+ ("Lock", "object.lock"),
+ ("Bring to Front", "object.bringToFront"),
+ ("Send to Back", "object.sendToBack"),
+ ] {
+ if ui.button(label).clicked() {
+ let _ = app.run(cmd, json!({}));
+ }
+ }
+ if i.is_text && ui.button("Fill with Placeholder Text").clicked() {
+ let _ = app.run("type.fillWithPlaceholder", json!({}));
+ }
+ });
+ }
+ }
+ None => document_section(app, ui),
+ }
+}
+
+fn capitalize(s: &str) -> String {
+ let mut c = s.chars();
+ match c.next() {
+ Some(f) => f.to_uppercase().collect::() + c.as_str(),
+ None => String::new(),
+ }
+}
+
+fn document_section(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let u = units(app);
+ let Some(st) = app.session.active() else { return };
+ let d = st.doc.clone();
+ ui.label(egui::RichText::new("Document").color(t.text_dim));
+ if section(ui, "Document", true) {
+ egui::Grid::new("docgrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
+ caption(ui, "W");
+ if let Some(v) = measure(ui, "dw", Some(d.settings.page_width), u, 70.0) {
+ let _ = app.run("layout.documentSetup", json!({"width": v}));
+ }
+ caption(ui, "H");
+ if let Some(v) = measure(ui, "dh", Some(d.settings.page_height), u, 70.0) {
+ let _ = app.run("layout.documentSetup", json!({"height": v}));
+ }
+ ui.end_row();
+ caption(ui, "Pages");
+ ui.label(d.page_count().to_string());
+ caption(ui, "");
+ let mut facing = d.settings.facing_pages;
+ if ui.checkbox(&mut facing, "Facing").changed() {
+ let _ = app.run("layout.documentSetup", json!({"facingPages": facing}));
+ }
+ ui.end_row();
+ });
+ if ui.button("Document Setup…").clicked() {
+ app.ui.dialog = Some(crate::dialogs::Dialog::new("documentSetup", json!({})));
+ }
+ }
+ divider(ui);
+ if section(ui, "Margins", true) {
+ let p = d.page(crate::canvas::current_page(app).unwrap_or(0)).cloned();
+ if let Some(p) = p {
+ egui::Grid::new("margins").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
+ for (row, (a, av, b, bv)) in
+ [("Top", p.margins.top, "Bottom", p.margins.bottom), ("Inside", p.margins.inside, "Outside", p.margins.outside)]
+ .into_iter()
+ .enumerate()
+ {
+ caption(ui, a);
+ if let Some(v) = measure(ui, &format!("m{row}a"), Some(av), u, 64.0) {
+ let mut m = p.margins;
+ if row == 0 {
+ m.top = v
+ } else {
+ m.inside = v
+ }
+ let _ = app.run("layout.marginsAndColumns", json!({"margins": m}));
+ }
+ caption(ui, b);
+ if let Some(v) = measure(ui, &format!("m{row}b"), Some(bv), u, 64.0) {
+ let mut m = p.margins;
+ if row == 0 {
+ m.bottom = v
+ } else {
+ m.outside = v
+ }
+ let _ = app.run("layout.marginsAndColumns", json!({"margins": m}));
+ }
+ ui.end_row();
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Columns");
+ if let Some(v) = number(ui, "cols", Some(p.columns.count as f64), "", 36.0, 0) {
+ let _ = app.run("layout.marginsAndColumns", json!({"columns": v.max(1.0) as u64}));
+ }
+ caption(ui, "Gutter");
+ if let Some(v) = measure(ui, "gut", Some(p.columns.gutter), u, 56.0) {
+ let _ = app.run("layout.marginsAndColumns", json!({"gutter": v}));
+ }
+ });
+ }
+ }
+ divider(ui);
+ if section(ui, "Rulers & Grids", true) {
+ ui.horizontal(|ui| {
+ ui.checkbox(&mut app.ui.rulers, "Rulers");
+ ui.checkbox(&mut app.ui.baseline_grid, "Baseline grid");
+ });
+ }
+ divider(ui);
+ if section(ui, "Guides", true) {
+ ui.horizontal(|ui| {
+ ui.checkbox(&mut app.ui.guides, "Guides");
+ ui.checkbox(&mut app.ui.frame_edges, "Frame edges");
+ });
+ }
+ divider(ui);
+ if section(ui, "Quick Actions", true) {
+ ui.horizontal_wrapped(|ui| {
+ if ui.button("Insert Page").clicked() {
+ let _ = app.run("layout.pages.insert", json!({"count": 1}));
+ }
+ if ui.button("Place…").clicked() {
+ let _ = app.run("app.placeDialog", json!({}));
+ }
+ if ui.button("Export PNG…").clicked() {
+ let _ = app.run("app.exportPng", json!({}));
+ }
+ });
+ }
+}
+
+fn transform_section(app: &mut DesignApp, ui: &mut egui::Ui, i: &SelInfo) {
+ let u = units(app);
+ egui::Grid::new("xf").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
+ caption(ui, "X");
+ if let Some(v) = measure(ui, "px", Some(i.page_rect.x0), u, 70.0) {
+ let _ = app.run("transform.set", json!({"x": v}));
+ }
+ caption(ui, "W");
+ if let Some(v) = measure(ui, "pw", Some(i.page_rect.width()), u, 70.0) {
+ let _ = app.run("transform.set", json!({"width": v}));
+ }
+ ui.end_row();
+ caption(ui, "Y");
+ if let Some(v) = measure(ui, "py", Some(i.page_rect.y0), u, 70.0) {
+ let _ = app.run("transform.set", json!({"y": v}));
+ }
+ caption(ui, "H");
+ if let Some(v) = measure(ui, "ph", Some(i.page_rect.height()), u, 70.0) {
+ let _ = app.run("transform.set", json!({"height": v}));
+ }
+ ui.end_row();
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Rotate");
+ if let Some(v) = number(ui, "rot", Some(0.0), "°", 46.0, 1) {
+ let _ = app.run("transform.rotate", json!({"angle": v}));
+ }
+ if ui.small_button("⇋").on_hover_text("Flip Horizontal").clicked() {
+ let _ = app.run("transform.flip", json!({"axis": "horizontal"}));
+ }
+ if ui.small_button("⇵").on_hover_text("Flip Vertical").clicked() {
+ let _ = app.run("transform.flip", json!({"axis": "vertical"}));
+ }
+ });
+}
+
+fn appearance_section(app: &mut DesignApp, ui: &mut egui::Ui, i: &SelInfo) {
+ ui.horizontal(|ui| {
+ caption(ui, "Fill ");
+ super::swatch_picker(app, ui, "pfill", Some(i.fill.clone()), |app, n| {
+ let _ = app.run("object.fill", json!({"swatch": n}));
+ });
+ ui.label(&i.fill);
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Stroke");
+ super::swatch_picker(app, ui, "pstroke", Some(i.stroke.clone()), |app, n| {
+ let _ = app.run("object.stroke", json!({"swatch": n}));
+ });
+ if let Some(v) = number(ui, "psw", Some(i.stroke_weight), " pt", 50.0, 3) {
+ let _ = app.run("object.stroke", json!({"weight": v}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Opacity");
+ let mut o = (i.opacity * 100.0) as f32;
+ if ui.add(egui::Slider::new(&mut o, 0.0..=100.0).suffix("%").integer()).drag_stopped() {
+ let _ = app.run("object.opacity", json!({"opacity": o / 100.0}));
+ }
+ });
+ ui.horizontal(|ui| {
+ let mut on = i.shadow;
+ if ui.checkbox(&mut on, "Drop shadow").changed() {
+ let _ = app.run("object.dropShadow", json!({"on": on}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Corners");
+ for (label, shape) in [("None", "none"), ("Rounded", "rounded"), ("Bevel", "bevel"), ("Inset", "inset")] {
+ if ui.small_button(label).clicked() {
+ let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": 12.0}));
+ }
+ }
+ });
+}
+
+fn text_frame_section(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let u = units(app);
+ let Some(st) = app.session.active() else { return };
+ let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame));
+ let Some(opts) = fid.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()).map(|t| t.options.clone()) else { return };
+ ui.horizontal(|ui| {
+ caption(ui, "Columns");
+ if let Some(v) = number(ui, "tfc", Some(opts.columns as f64), "", 36.0, 0) {
+ let _ = app.run("object.textFrameOptions", json!({"columns": v.max(1.0) as u64}));
+ }
+ caption(ui, "Gutter");
+ if let Some(v) = measure(ui, "tfg", Some(opts.gutter), u, 56.0) {
+ let _ = app.run("object.textFrameOptions", json!({"gutter": v}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Inset");
+ if let Some(v) = measure(ui, "tfi", Some(opts.inset[0]), u, 56.0) {
+ let _ = app.run("object.textFrameOptions", json!({"inset": v}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Align");
+ for (label, vj) in [("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")] {
+ let on = serde_json::to_value(opts.vertical_justification).ok().and_then(|v| v.as_str().map(|s| s == vj)).unwrap_or(false);
+ if ui.selectable_label(on, label).clicked() {
+ let _ = app.run("object.textFrameOptions", json!({"verticalJustification": vj}));
+ }
+ }
+ });
+ if ui.button("Text Frame Options…").clicked() {
+ app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
+ }
+}
+
+pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let Some(a) = text_attrs(app) else {
+ ui.label("Select text or a text frame.");
+ return;
+ };
+ let c = a["chars"].clone();
+ let fam = c["fontFamily"].as_str().unwrap_or("").to_string();
+ let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
+ ui.label(egui::RichText::new("Character").strong());
+ super::font_family_picker(app, ui, &fam, 220.0);
+ super::font_style_picker(app, ui, &fam, &sty, 220.0);
+ egui::Grid::new("chargrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
+ caption(ui, "Size");
+ if let Some(v) = number(ui, "pcs", c["size"].as_f64(), " pt", 60.0, 2) {
+ let _ = app.run("type.char", json!({"attrs": {"size": v}}));
+ }
+ caption(ui, "Leading");
+ let auto = c["leading"]["kind"].as_str() != Some("points");
+ let lv = if auto { c["size"].as_f64().map(|s| s * a["para"]["autoLeading"].as_f64().unwrap_or(1.2)) } else { c["leading"]["value"].as_f64() };
+ if let Some(v) = number(ui, "pcl", lv, if auto { " (auto)" } else { " pt" }, 70.0, 2) {
+ let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
+ }
+ ui.end_row();
+ caption(ui, "Tracking");
+ if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 60.0, 0) {
+ let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
+ }
+ caption(ui, "Baseline");
+ if let Some(v) = number(ui, "pcb", c["baselineShift"].as_f64(), " pt", 70.0, 2) {
+ let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
+ }
+ ui.end_row();
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Color");
+ super::swatch_picker(app, ui, "tfill", c["fill"].as_str().map(str::to_string), |app, n| {
+ let _ = app.run("type.char", json!({"attrs": {"fill": n}}));
+ });
+ ui.label(c["fill"].as_str().unwrap_or(""));
+ });
+ ui.horizontal(|ui| {
+ let caps = c["capitalization"].as_str() == Some("allCaps");
+ if ui.selectable_label(caps, "TT").on_hover_text("All Caps").clicked() {
+ let _ = app.run("type.char", json!({"attrs": {"capitalization": if caps { "normal" } else { "allCaps" }}}));
+ }
+ let ul = c["underline"].as_bool().unwrap_or(false);
+ if ui.selectable_label(ul, "U").on_hover_text("Underline").clicked() {
+ let _ = app.run("type.char", json!({"attrs": {"underline": !ul}}));
+ }
+ let st = c["strikethrough"].as_bool().unwrap_or(false);
+ if ui.selectable_label(st, "S").on_hover_text("Strikethrough").clicked() {
+ let _ = app.run("type.char", json!({"attrs": {"strikethrough": !st}}));
+ }
+ let sup = c["position"].as_str() == Some("superscript");
+ if ui.selectable_label(sup, "T¹").on_hover_text("Superscript").clicked() {
+ let _ = app.run("type.char", json!({"attrs": {"position": if sup { "normal" } else { "superscript" }}}));
+ }
+ });
+}
+
+pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let Some(a) = text_attrs(app) else {
+ ui.label("Select text or a text frame.");
+ return;
+ };
+ let u = units(app);
+ let p = a["para"].clone();
+ ui.label(egui::RichText::new("Paragraph").strong());
+ let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default();
+ ui.horizontal(|ui| {
+ for (al, icon) in [
+ (Align::Left, "align-left"),
+ (Align::Center, "align-center"),
+ (Align::Right, "align-right"),
+ (Align::LeftJustified, "align-justify"),
+ (Align::FullyJustified, "align-justify-all"),
+ ] {
+ if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"align": al}}));
+ }
+ }
+ });
+ egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
+ for (row, ((la, ka), (lb, kb))) in [
+ (("Left", "leftIndent"), ("Right", "rightIndent")),
+ (("First", "firstLineIndent"), ("Last", "lastLineIndent")),
+ (("Before", "spaceBefore"), ("After", "spaceAfter")),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ caption(ui, la);
+ if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) {
+ let _ = app.run("type.para", json!({"attrs": {ka: v}}));
+ }
+ caption(ui, lb);
+ if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) {
+ let _ = app.run("type.para", json!({"attrs": {kb: v}}));
+ }
+ ui.end_row();
+ }
+ });
+ ui.horizontal(|ui| {
+ let mut h = p["hyphenate"].as_bool().unwrap_or(true);
+ if ui.checkbox(&mut h, "Hyphenate").changed() {
+ let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
+ }
+ let mut grid = p["gridAlign"].as_str() == Some("allLines");
+ if ui.checkbox(&mut grid, "Align to baseline grid").changed() {
+ let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Composer");
+ let single = p["composer"].as_str() == Some("singleLine");
+ if ui.selectable_label(!single, "Paragraph").clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"composer": "paragraph"}}));
+ }
+ if ui.selectable_label(single, "Single-line").clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"composer": "singleLine"}}));
+ }
+ });
+ let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string();
+ let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0);
+ ui.horizontal(|ui| {
+ caption(ui, "Style");
+ super::para_style_picker(app, ui, &ps, ov > 0, 190.0);
+ });
+}
+
+pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let Some(i) = sel_info(app) else {
+ ui.label("Select an object.");
+ return;
+ };
+ ui.horizontal(|ui| {
+ caption(ui, "Weight");
+ if let Some(v) = number(ui, "sw", Some(i.stroke_weight), " pt", 60.0, 3) {
+ let _ = app.run("object.stroke", json!({"weight": v}));
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Align Stroke");
+ for (label, a) in [("Center", "center"), ("Inside", "inside"), ("Outside", "outside")] {
+ let on = serde_json::to_value(i.stroke_align).ok().and_then(|v| v.as_str().map(|s| s == a)).unwrap_or(false);
+ if ui.selectable_label(on, label).clicked() {
+ let _ = app.run("object.stroke", json!({"align": a}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Type");
+ for (label, ty) in [
+ ("Solid", json!({"kind": "solid"})),
+ ("Dashed", json!({"kind": "dashed", "pattern": [12.0, 4.0]})),
+ ("Dotted", json!({"kind": "dotted"})),
+ ] {
+ if ui.small_button(label).clicked() {
+ let _ = app.run("object.stroke", json!({"type": ty}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ caption(ui, "Color");
+ super::swatch_picker(app, ui, "spick", Some(i.stroke.clone()), |app, n| {
+ let _ = app.run("object.stroke", json!({"swatch": n}));
+ });
+ });
+}
+
+pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let info = sel_info(app);
+ ui.horizontal(|ui| {
+ for (icon, mode, tip) in [
+ ("wrap-none", "none", "No Text Wrap"),
+ ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"),
+ ("wrap-contour", "contour", "Wrap Around Object Shape"),
+ ("wrap-jump", "jumpObject", "Jump Object"),
+ ("wrap-next", "jumpToNextColumn", "Jump to Next Column"),
+ ] {
+ let on = info.as_ref().is_some_and(|i| i.wrap == mode);
+ if icons::button(ui, icon, 26.0, on, tip).clicked() {
+ let _ = app.run("object.textWrap", json!({"mode": mode}));
+ }
+ }
+ });
+ let u = units(app);
+ ui.horizontal(|ui| {
+ caption(ui, "Offset");
+ if let Some(v) = measure(ui, "wo", Some(0.0), u, 60.0) {
+ let _ = app.run("object.textWrap", json!({"mode": info.as_ref().map(|i| i.wrap).unwrap_or("boundingBox"), "offset": v}));
+ }
+ });
+}
+
+pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ ui.horizontal(|ui| {
+ for (edge, tip) in [
+ ("left", "Align left edges"),
+ ("hcenter", "Align horizontal centers"),
+ ("right", "Align right edges"),
+ ("top", "Align top edges"),
+ ("vcenter", "Align vertical centers"),
+ ("bottom", "Align bottom edges"),
+ ] {
+ if ui
+ .small_button(match edge {
+ "left" => "⇤",
+ "hcenter" => "↔",
+ "right" => "⇥",
+ "top" => "⤒",
+ "vcenter" => "↕",
+ _ => "⤓",
+ })
+ .on_hover_text(tip)
+ .clicked()
+ {
+ let _ = app.run("object.align", json!({"edge": edge}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ if ui.small_button("Distribute ↔").clicked() {
+ let _ = app.run("object.distribute", json!({"axis": "horizontal"}));
+ }
+ if ui.small_button("Distribute ↕").clicked() {
+ let _ = app.run("object.distribute", json!({"axis": "vertical"}));
+ }
+ });
+}
+
+pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let Some(st) = app.session.active() else { return };
+ let t = Tokens::get(ui.ctx());
+ let assets: Vec<(String, Option<(u32, u32)>, bool)> = st.doc.assets.values().map(|a| (a.name.clone(), a.pixels, a.link.is_some())).collect();
+ if assets.is_empty() {
+ ui.label(egui::RichText::new("No placed graphics.").color(t.text_dim));
+ }
+ for (name, px, linked) in assets {
+ ui.horizontal(|ui| {
+ ui.label(&name);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.label(egui::RichText::new(if linked { "Linked" } else { "Embedded" }).color(t.text_dim).size(11.0));
+ if let Some((w, h)) = px {
+ ui.label(egui::RichText::new(format!("{w}×{h}")).color(t.text_dim).size(11.0));
+ }
+ });
+ });
+ }
+}
+
+pub fn info_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let u = units(app);
+ let t = Tokens::get(ui.ctx());
+ match sel_info(app) {
+ Some(i) => {
+ let f = |v: f64| designcraft_geom::format_measure(v, u);
+ ui.label(format!("X: {} Y: {}", f(i.page_rect.x0), f(i.page_rect.y0)));
+ ui.label(format!("W: {} H: {}", f(i.page_rect.width()), f(i.page_rect.height())));
+ ui.label(format!("Fill: {} Stroke: {}", i.fill, i.stroke));
+ }
+ None => {
+ if let Some(st) = app.session.active() {
+ ui.label(format!("{} pages · {} stories · {} items", st.doc.page_count(), st.doc.stories.len(), st.doc.all_items().len()));
+ }
+ }
+ }
+ ui.label(egui::RichText::new(format!("Render {:.1} ms", app.perf.render_ms)).color(t.text_dim));
+}
diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs
new file mode 100644
index 00000000..fac997c9
--- /dev/null
+++ b/crates/ui-egui/src/panels/styles.rs
@@ -0,0 +1,64 @@
+//! Paragraph Styles and Character Styles panels.
+
+use egui::{Sense, vec2};
+use serde_json::json;
+
+use crate::theme::Tokens;
+use crate::{DesignApp, icons};
+
+fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) {
+ let t = Tokens::get(ui.ctx());
+ let Some(st) = app.session.active() else { return };
+ let names: Vec = if para {
+ st.doc.styles.paragraph.iter().map(|s| s.name.clone()).collect()
+ } else {
+ st.doc.styles.character.iter().map(|s| s.name.clone()).collect()
+ };
+ let attrs = super::text_attrs(app);
+ let current = attrs.as_ref().and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string));
+ let overrides = attrs.as_ref().map(|a| a[if para { "paraOverrides" } else { "charOverrides" }].as_u64().unwrap_or(0)).unwrap_or(0);
+ ui.label(
+ egui::RichText::new(match ¤t {
+ Some(c) => format!("{c}{}", if overrides > 0 { "+" } else { "" }),
+ None => "No text selected".into(),
+ })
+ .size(11.0)
+ .color(t.text_dim),
+ );
+ for n in &names {
+ if para && n == designcraft_doc::NO_PARA_STYLE {
+ continue;
+ }
+ let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
+ if current.as_deref() == Some(n.as_str()) {
+ ui.painter().rect_filled(row, 0.0, t.row_selected);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(row, 0.0, t.hover);
+ }
+ ui.painter().text(row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text);
+ if resp.clicked() {
+ let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
+ let clear = ui.input(|i| i.modifiers.alt);
+ let _ = app.run(cmd, json!({"name": n, "clearOverrides": clear}));
+ }
+ }
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new("⌥-click clears overrides").size(10.5).color(t.text_disabled));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if icons::button(ui, "plus", 20.0, false, "Create New Style from Selection").clicked() {
+ let cmd = if para { "style.paragraph.create" } else { "style.character.create" };
+ let base = if para { "Paragraph Style 1" } else { "Character Style 1" };
+ let _ = app.run(cmd, json!({"name": base, "fromSelection": true}));
+ }
+ });
+ });
+}
+
+pub fn paragraph(app: &mut DesignApp, ui: &mut egui::Ui) {
+ list(app, ui, true);
+}
+
+pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) {
+ list(app, ui, false);
+}
diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs
new file mode 100644
index 00000000..4b5c9e30
--- /dev/null
+++ b/crates/ui-egui/src/panels/swatches.rs
@@ -0,0 +1,97 @@
+//! Swatches and Color panels.
+
+use designcraft_color::{ColorType, SwatchValue};
+use egui::{Sense, vec2};
+use serde_json::json;
+
+use crate::theme::Tokens;
+use crate::{DesignApp, icons};
+
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let Some(st) = app.session.active() else { return };
+ let doc = st.doc.clone();
+ let text = st.selection.text.is_some();
+ let fill_cur = super::sel_info(app).map(|i| i.fill);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(if text { "Applies to text" } else { "Applies to fill" }).size(11.0).color(t.text_dim));
+ });
+ for sw in &doc.swatches {
+ let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
+ if fill_cur.as_deref() == Some(sw.name.as_str()) {
+ ui.painter().rect_filled(row, 0.0, t.row_selected);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(row, 0.0, t.hover);
+ }
+ let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0);
+ let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
+ crate::widgets::paint_chip(ui.painter(), chip, c, g);
+ ui.painter().text(row.min + vec2(28.0, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
+ // Type indicators: spot dot / process square, colour mode.
+ let kind = match &sw.value {
+ SwatchValue::Color { color, color_type } => {
+ let m = match color {
+ designcraft_color::Color::Cmyk { .. } => "CMYK",
+ designcraft_color::Color::Rgb { .. } => "RGB",
+ designcraft_color::Color::Gray { .. } => "Gray",
+ };
+ format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
+ }
+ SwatchValue::Gradient { .. } => "Gradient".into(),
+ SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
+ _ => String::new(),
+ };
+ if sw.locked {
+ icons::paint(
+ ui.painter(),
+ egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)),
+ "lock",
+ t.text_dim,
+ );
+ }
+ ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim);
+ if resp.clicked() {
+ let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) };
+ let _ = r;
+ }
+ if resp.secondary_clicked() {
+ let _ = app.run("object.stroke", json!({"swatch": sw.name}));
+ }
+ }
+ ui.add_space(6.0);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new("Right-click applies to stroke").size(10.5).color(t.text_disabled));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if icons::button(ui, "plus", 20.0, false, "New Swatch").clicked() {
+ let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}}));
+ }
+ });
+ });
+}
+
+pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
+ let Some(i) = super::sel_info(app) else {
+ ui.label("Select an object to edit its fill.");
+ return;
+ };
+ let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
+ let Some(c) = doc.resolve_color(&i.fill, 1.0) else {
+ ui.label(format!("Fill: {}", i.fill));
+ return;
+ };
+ let [mut cc, mut m, mut y, mut k] = c.to_cmyk();
+ let mut changed = false;
+ for (label, v) in [("C", &mut cc), ("M", &mut m), ("Y", &mut y), ("K", &mut k)] {
+ ui.horizontal(|ui| {
+ ui.label(label);
+ let mut p = *v * 100.0;
+ if ui.add(egui::Slider::new(&mut p, 0.0..=100.0).suffix("%").integer()).drag_stopped() {
+ *v = p / 100.0;
+ changed = true;
+ }
+ });
+ }
+ if changed && let Ok(r) = app.run("swatch.create", json!({"color": {"c": cc * 100.0, "m": m * 100.0, "y": y * 100.0, "k": k * 100.0}})) {
+ let _ = app.run("object.fill", json!({"swatch": r["name"]}));
+ }
+}
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
new file mode 100644
index 00000000..a819b273
--- /dev/null
+++ b/crates/ui-egui/src/theme.rs
@@ -0,0 +1,243 @@
+//! Design tokens (InDesign's four interface brightness levels), fonts and egui style.
+
+use std::sync::Arc;
+
+use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Brightness {
+ #[default]
+ Dark,
+ MediumDark,
+ MediumLight,
+ Light,
+}
+
+impl Brightness {
+ pub const ALL: [Brightness; 4] = [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light];
+ pub fn label(self) -> &'static str {
+ match self {
+ Brightness::Dark => "Dark",
+ Brightness::MediumDark => "Medium Dark",
+ Brightness::MediumLight => "Medium Light",
+ Brightness::Light => "Light",
+ }
+ }
+ pub fn id(self) -> &'static str {
+ match self {
+ Brightness::Dark => "dark",
+ Brightness::MediumDark => "mediumDark",
+ Brightness::MediumLight => "mediumLight",
+ Brightness::Light => "light",
+ }
+ }
+ pub fn parse(s: &str) -> Option