diff --git a/.gitignore b/.gitignore
index 928d6b49..a2220288 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ plans/
/log/
.DS_Store
*.profraw
+.cache/
+.vscode/
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 46b789a8..cfcdbf06 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
+
+## Arabic interface catalog
+
+| Path | Contributor | Source | Licence | Review |
+|---|---|---|---|---|
+| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
diff --git a/CLAUDE.md b/CLAUDE.md
index b2384311..f61412ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,7 @@ code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
+- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
diff --git a/Cargo.lock b/Cargo.lock
index 555ed9af..8901fd6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -253,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -972,6 +972,40 @@ dependencies = [
"writeable",
]
+[[package]]
+name = "craft-keymap"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "craft-menu"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "craft-keymap",
+ "craft-quit",
+ "egui",
+ "muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
+[[package]]
+name = "craft-quit"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "dispatch2",
+ "egui",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1034,6 +1068,15 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -1526,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1593,9 +1636,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"arboard",
+ "craft-menu",
"craft-update",
"craft-window",
"eframe",
@@ -1621,7 +1665,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1635,7 +1679,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1646,12 +1690,13 @@ dependencies = [
"postcard",
"serde",
"serde_json",
+ "sha2",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-collab-net"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1674,7 +1719,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1683,7 +1728,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1695,7 +1740,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1727,7 +1772,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1743,7 +1788,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1751,7 +1796,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1759,7 +1804,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1775,7 +1820,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1784,7 +1829,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1801,7 +1846,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"serde",
@@ -1814,7 +1859,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1832,7 +1877,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1856,7 +1901,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1872,7 +1917,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"core-text",
"figcraft-doc",
@@ -1888,7 +1933,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1916,7 +1961,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1930,7 +1975,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"eframe",
@@ -2865,6 +2910,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keyboard-types"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3077,6 +3132,24 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "muda"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "keyboard-types",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "png",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "naga"
version = "30.0.1"
@@ -3575,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4249,7 +4322,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4306,7 +4379,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4869,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5992,7 +6065,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6422,7 +6495,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.2"
+version = "0.12.1"
dependencies = [
"figcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index fd2fbb81..2bf5da74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.2"
+version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -44,6 +44,9 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
+# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
+craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -102,6 +105,9 @@ webbrowser = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
dbg_macro = "warn"
# Style lints from newer toolchains that fight index-heavy pixel code.
needless_range_loop = "allow"
@@ -130,7 +136,7 @@ lto = "thin"
codegen-units = 4
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
-# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
+# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
diff --git a/ROADMAP.md b/ROADMAP.md
index 74a617f3..129c21de 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
-| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
+| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
-| Arabic | ar | 0% | none |
+| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
@@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people.
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
-French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
+French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -127,11 +127,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
-4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
+4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
+- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
+- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
+- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +351,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
+| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
+| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
+| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs
index 1e440a32..1e58f7c2 100644
--- a/apps/figcraft-server/src/codes_tests.rs
+++ b/apps/figcraft-server/src/codes_tests.rs
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
+/// The links file keeps hashes only, never the codes or the token they wrap.
+fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
+ let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
+ for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
+ assert!(!links.contains(secret), "{links}");
+ }
+ assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
+ assert_eq!(links.matches("\"code\"").count(), 2);
+}
+
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
- let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
- for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
- assert!(!links.contains(secret), "{links}");
- }
- assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
- assert_eq!(links.matches("\"code\"").count(), 2);
+ only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs
index f4703f20..5dd45435 100644
--- a/apps/figcraft-server/src/library_tests.rs
+++ b/apps/figcraft-server/src/library_tests.rs
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
+/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
+fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(edit));
+ assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
+ let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
+ let mut rng = Rng::new(5);
+ for round in 0..12 {
+ let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
+ for _ in 0..1 + rng.below(8) {
+ let i = rng.below(junk.len());
+ junk[i] = rng.next_u64() as u8;
+ }
+ raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
+ assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
+ }
+ let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
+ assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
+ raw.send(&ClientMsg::Library(LibraryReq::Usage));
+ let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
+ let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
+ assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
+ assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
+ assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+}
+
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
- // Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
- let mut raw = Raw::connect(addr);
- raw.send(&open(&edit));
- assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
- let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
- let mut rng = Rng::new(5);
- for round in 0..12 {
- let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
- for _ in 0..1 + rng.below(8) {
- let i = rng.below(junk.len());
- junk[i] = rng.next_u64() as u8;
- }
- raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
- assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
- }
- let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
- assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
- raw.send(&ClientMsg::Library(LibraryReq::Usage));
- let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
- let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
- assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
- assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
- let mut raw = Raw::connect(addr);
- raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
- assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+ junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs
index af9b8bbb..95fd8948 100644
--- a/apps/figcraft-server/src/store/backend.rs
+++ b/apps/figcraft-server/src/store/backend.rs
@@ -1,5 +1,8 @@
use super::journal::flush_log;
-use super::*;
+use super::{
+ Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
+ LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
+};
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result {
diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs
index 53ae9c87..565916ed 100644
--- a/apps/figcraft-server/src/store/files.rs
+++ b/apps/figcraft-server/src/store/files.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
+ MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
+ Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
+};
impl Store {
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
@@ -222,7 +226,7 @@ impl Store {
pub fn break_log(&mut self, file: &str) {
let path = self.dir(file).join(LOG);
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
- && let Ok(f) = File::open(path)
+ && let Ok(f) = std::fs::File::open(path)
{
log.file = f;
}
diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs
index e3983543..5f8904fc 100644
--- a/apps/figcraft-server/src/store/journal.rs
+++ b/apps/figcraft-server/src/store/journal.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
+ now, write_json,
+};
impl Store {
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs
index adb592db..55ca50ad 100644
--- a/apps/figcraft-server/src/store/library.rs
+++ b/apps/figcraft-server/src/store/library.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
+ MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
+ write_json,
+};
impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs
index ad30f458..6c279219 100644
--- a/apps/figcraft-server/src/store/links.rs
+++ b/apps/figcraft-server/src/store/links.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
+ Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
+};
impl Store {
/// The files kept.
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index bd8c09c8..2b5a3bd1 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
+# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
+# same guarded path as the close button); winit's default Quit ends the process through
+# `terminate:`, losing unsaved changes without a word (issue #857).
+craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs
index ada44c0f..c6344303 100644
--- a/apps/figcraft/src/control_server.rs
+++ b/apps/figcraft/src/control_server.rs
@@ -6,6 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
+use figcraft_engine::events::{self, EventLog};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
@@ -58,7 +59,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec) {
target.extend(queue.drain(..count));
}
-pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
+pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -71,7 +72,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
- spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
+ spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -84,13 +85,20 @@ fn accepted(incoming: impl Iterator- >, mut wait: imp
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
-fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) {
+fn spawn_connection(
+ stream: TcpStream,
+ queue: Queue,
+ ctx: CtxSlot,
+ events: Arc,
+ workers: &mut Vec>,
+ timeout: Duration,
+) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
- let _ = serve(stream, queue, ctx, timeout);
+ let _ = serve_with_events(stream, queue, ctx, events, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
@@ -100,7 +108,13 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
-fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
+/// Serve a connection with a private event log (the tests; the app shares its session's log).
+#[cfg(test)]
+fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
+ serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
+}
+
+fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
@@ -126,6 +140,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
+ // A poll waits on the shared log, not on a frame: it needs no UI thread and no
+ // request slot, and it works while the window is minimised.
+ if method == "events.poll" {
+ let reply = match event_poll(&events, ¶ms) {
+ Ok(result) => json!({"id": id, "ok": true, "result": result}),
+ Err(error) => json!({"id": id, "ok": false, "error": error}),
+ };
+ writeln!(out, "{reply}")?;
+ continue;
+ }
let (tx, rx) = std::sync::mpsc::channel();
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -166,6 +190,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
Ok(())
}
+/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
+fn event_poll(log: &EventLog, params: &Value) -> Result {
+ let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
+ let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
+ let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
+ let topics = events::topics(params.get("topics"))?;
+ let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
+ serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
+}
+
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs
index f29a7773..2d1b405d 100644
--- a/apps/figcraft/src/control_server_tests.rs
+++ b/apps/figcraft/src/control_server_tests.rs
@@ -198,12 +198,26 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
- spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
+ spawn_connection(
+ server,
+ pending.clone(),
+ ctx.clone(),
+ Arc::new(figcraft_engine::events::EventLog::default()),
+ &mut workers,
+ Duration::from_secs(5),
+ );
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
- spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
+ spawn_connection(
+ server,
+ pending.clone(),
+ ctx.clone(),
+ Arc::new(figcraft_engine::events::EventLog::default()),
+ &mut workers,
+ Duration::from_secs(5),
+ );
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
@@ -215,7 +229,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
- spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
+ spawn_connection(server, pending, ctx, Arc::new(figcraft_engine::events::EventLog::default()), &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
@@ -249,6 +263,39 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
worker.join().unwrap().unwrap();
}
+#[test]
+fn events_poll_is_answered_without_the_ui_queue() {
+ let (mut client, server) = pair();
+ let pending = queue();
+ let q = pending.clone();
+ let log = Arc::new(figcraft_engine::events::EventLog::default());
+ log.push("comment.added", "Doc", None, json!({"commentId": 7}));
+ let l = log.clone();
+ let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
+ client.write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":0,\"timeoutMs\":0}}\n").unwrap();
+ let mut reader = BufReader::new(client);
+ let r = reply(&mut reader);
+ assert_eq!(r["id"], 1);
+ assert_eq!(r["result"]["events"].as_array().unwrap().len(), 1);
+ assert_eq!(r["result"]["events"][0]["kind"], "comment.added");
+ assert_eq!(r["result"]["events"][0]["data"]["commentId"], 7);
+ assert_eq!(r["result"]["cursor"], 1);
+ assert!(pending.lock().unwrap().is_empty());
+ reader.get_ref().shutdown(Shutdown::Write).unwrap();
+ worker.join().unwrap().unwrap();
+}
+
+#[test]
+fn a_comment_added_in_the_session_is_pollable() {
+ let mut session = figcraft_engine::Session::new();
+ let log = session.events.clone();
+ let id = session.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix the padding"})).unwrap()["id"].clone();
+ let r = event_poll(&log, &json!({"after": 0, "timeoutMs": 0})).unwrap();
+ assert_eq!(r["events"].as_array().unwrap().len(), 1);
+ assert_eq!(r["events"][0]["kind"], "comment.added");
+ assert_eq!(r["events"][0]["data"]["commentId"], id);
+}
+
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
@@ -342,3 +389,40 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
worker.join().unwrap().unwrap();
}
}
+
+#[test]
+fn events_poll_rejects_an_unknown_topic_without_the_ui_queue() {
+ let (client, server) = pair();
+ let pending = queue();
+ let q = pending.clone();
+ let log = Arc::new(figcraft_engine::events::EventLog::default());
+ let l = log.clone();
+ let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
+ let mut reader = BufReader::new(client);
+ reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"topics\":[\"nope\"],\"timeoutMs\":0}}\n").unwrap();
+ let r = reply(&mut reader);
+ assert_eq!(r["id"], 1);
+ assert_eq!(r["ok"], false);
+ assert!(r["error"].as_str().is_some_and(|e| e.contains("unknown topic")), "{r}");
+ assert!(pending.lock().unwrap().is_empty());
+ reader.get_ref().shutdown(Shutdown::Write).unwrap();
+ worker.join().unwrap().unwrap();
+}
+
+#[test]
+fn events_poll_reports_reset_for_a_cursor_ahead_of_the_log() {
+ let (client, server) = pair();
+ let log = Arc::new(figcraft_engine::events::EventLog::default());
+ log.push("comment.added", "Doc", None, json!({"commentId": 1}));
+ let q = queue();
+ let l = log.clone();
+ let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
+ let mut reader = BufReader::new(client);
+ reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":2,\"timeoutMs\":0}}\n").unwrap();
+ let r = reply(&mut reader);
+ assert_eq!(r["result"]["reset"], true, "{r}");
+ assert!(r["result"]["events"].as_array().unwrap().is_empty(), "{r}");
+ assert_eq!(r["result"]["cursor"], 1);
+ reader.get_ref().shutdown(Shutdown::Write).unwrap();
+ worker.join().unwrap().unwrap();
+}
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index a3cd21bf..77f15ee6 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -234,7 +234,7 @@ fn main() -> eframe::Result {
}
let queue: Arc>> = Arc::new(Mutex::new(Vec::new()));
if let Some(p) = port {
- control_server::start(p, queue.clone(), ctx_slot.clone());
+ control_server::start(p, queue.clone(), ctx_slot.clone(), fig.session.events.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
@@ -271,6 +271,15 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
+ // winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
+ // app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
+ // sends the root window a close request instead, and the unsaved-changes question
+ // asks just as it does for the close button. Failure keeps winit's menu: the app
+ // runs, but ⌘Q ends it without asking.
+ #[cfg(target_os = "macos")]
+ if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
+ log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
+ }
Ok(Box::new(App {
queue,
ctx_slot,
diff --git a/clippy.toml b/clippy.toml
index 36cc87a4..305f94bd 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
+cognitive-complexity-threshold = 30
diff --git a/contributors/people.toml b/contributors/people.toml
index ec2847f0..89366cb6 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -89,3 +89,6 @@ sync_github_name = true
[people.mateusvillain]
sync_github_name = true
+
+[people.culiulemon]
+sync_github_name = true
diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs
index 4ebbec15..84801c4d 100644
--- a/crates/collab-net/src/driver.rs
+++ b/crates/collab-net/src/driver.rs
@@ -2,9 +2,10 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
-//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
-//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
-//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
+//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
+//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
+//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
+//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
@@ -36,13 +37,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
- last_sent: Instant,
+ /// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
+ /// else is sent, since presence gets no answer and only what the host sends counts as heard.
+ last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
- Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
+ Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -106,7 +109,7 @@ impl Driver
{
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
- if !self.write(&hello, now) {
+ if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -173,7 +176,7 @@ impl Driver {
return;
}
if let Some(p) = self.throttle.offer(p, now)
- && !self.write(&ClientMsg::Presence(p), now)
+ && !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -186,7 +189,7 @@ impl Driver {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
- if !self.write(&msg, now) {
+ if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -204,7 +207,7 @@ impl Driver {
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
- [Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
+ [Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
}
@@ -223,7 +226,7 @@ impl Driver {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
- let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
+ let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
@@ -233,21 +236,23 @@ impl Driver {
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
- let ok = match self.throttle.take_due(now) {
- Some(p) => self.write(&ClientMsg::Presence(p), now),
- None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
- None => true,
- };
+ let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
+ let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
- /// Send one message; false when it couldn't go.
- fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
+ /// Send a ping; false when it couldn't go.
+ fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
- c.last_sent = now;
+ c.last_ping = now;
}
+ self.write(&ClientMsg::Ping)
+ }
+
+ /// Send one message; false when it couldn't go.
+ fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs
index 5e395b98..a94ff064 100644
--- a/crates/collab-net/src/driver_tests.rs
+++ b/crates/collab-net/src/driver_tests.rs
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
- // Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
+ // A ping every keep-alive interval from the connection's start, presence or not (the host
+ // answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
- assert_eq!(ping_at, due + Duration::from_secs(15));
+ assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
+/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
+/// pings still go out, and the host's pongs keep the connection (before, each presence put the
+/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
+#[test]
+fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
+ let t0 = Instant::now();
+ let (mut d, inbox) = start(Script::default(), t0);
+ d.opened(t0);
+ d.message(&welcome(), t0);
+ inbox.drain();
+ let mut answered = 0;
+ for i in 1..=900u32 {
+ let now = t0 + ms(100 * u64::from(i));
+ d.presence(cursor(f64::from(i)), now);
+ while let Some(due) = d.deadline().filter(|t| *t <= now) {
+ d.tick(due);
+ }
+ // The host answers every ping (and nothing else) at once.
+ let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
+ answered = d.sock.sent.len();
+ if pinged {
+ d.message(&encode(&ServerMsg::Pong), now);
+ }
+ }
+ assert!(!d.done());
+ assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
+ assert!(statuses(&inbox.drain()).is_empty());
+ let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
+ assert!(pings >= 5, "a ping every 15 s ({pings})");
+}
+
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs
index 2e1d5046..91e72cf6 100644
--- a/crates/collab-net/src/guest.rs
+++ b/crates/collab-net/src/guest.rs
@@ -1,7 +1,8 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
-//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
+//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
+//! host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
@@ -280,13 +281,15 @@ fn session(
}
}
};
- let mut last_sent = Instant::now();
+ // A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
+ // reader counts only what the host sends.
+ let mut last_ping = Instant::now();
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
- let outcome = if send(&mut writer, &hello, &mut last_sent) {
+ let outcome = if send(&mut writer, &hello) {
loop {
- let ping_at = last_sent + cfg.limits.keepalive;
+ let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
@@ -312,16 +315,17 @@ fn session(
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
- if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
+ if !now_send.iter().all(|m| send(&mut writer, m)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
- let ok = match out {
- Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
- None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
- None => true,
- };
+ let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
+ let ok = ok
+ && (now < last_ping + cfg.limits.keepalive || {
+ last_ping = now;
+ send(&mut writer, &ClientMsg::Ping)
+ });
if !ok {
break Outcome::Retry("the connection dropped".into());
}
@@ -335,8 +339,7 @@ fn session(
}
/// Send one message; false when the connection is gone.
-fn send(ws: &mut WebSocket, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
- *last_sent = Instant::now();
+fn send(ws: &mut WebSocket, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs
index 04c5363d..e594b0f1 100644
--- a/crates/collab-net/src/hub/backend.rs
+++ b/crates/collab-net/src/hub/backend.rs
@@ -1,5 +1,5 @@
use super::limits::Admission;
-use super::*;
+use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
/// An admin request's reply, and what the hub must do about connections it affects.
#[derive(Clone, Debug, PartialEq)]
diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs
index 109dfed9..895218c4 100644
--- a/crates/collab-net/src/hub/conn.rs
+++ b/crates/collab-net/src/hub/conn.rs
@@ -1,7 +1,11 @@
use super::hosting::{Open, spawn};
use super::limits::{PerAddress, ip_key};
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
-use super::*;
+use super::{
+ Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
+ NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
+ WebSocket, Write, config, io, message, shutdown, sync_channel,
+};
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs
index 8588e56a..df8fd975 100644
--- a/crates/collab-net/src/hub/hosting.rs
+++ b/crates/collab-net/src/hub/hosting.rs
@@ -2,7 +2,10 @@ use super::backend::Backend;
use super::conn::{Shared, accept};
use super::limits::{PerAddress, loopback};
use super::queue::{Blocks, HubIn, InFlight};
-use super::*;
+use super::{
+ Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
+ Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
+};
/// The person hosting an app session: in the file's session without a socket.
pub(crate) struct Local {
diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs
index 333ff165..66369790 100644
--- a/crates/collab-net/src/hub/limits.rs
+++ b/crates/collab-net/src/hub/limits.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
/// What a link lets a hello do.
#[derive(Clone, Debug, PartialEq, Eq)]
diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs
index b6bce39e..1c6a5dfc 100644
--- a/crates/collab-net/src/hub/queue.rs
+++ b/crates/collab-net/src/hub/queue.rs
@@ -1,5 +1,7 @@
use super::limits::ip_blocks;
-use super::*;
+use super::{
+ Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
+};
/// A message for one connection's writer: bytes (with what they count against, released when
/// they are sent or dropped), or the close.
diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs
index 0321b36c..301c4e78 100644
--- a/crates/collab-net/src/limits.rs
+++ b/crates/collab-net/src/limits.rs
@@ -30,7 +30,7 @@ pub struct NetLimits {
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
- /// A quiet client pings this often (keeps it under `idle_timeout`).
+ /// A client pings this often, whatever else it sends (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml
index ed85201d..189c1a02 100644
--- a/crates/collab/Cargo.toml
+++ b/crates/collab/Cargo.toml
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
+sha2 = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs
index 7977f83a..dedae208 100644
--- a/crates/collab/src/change.rs
+++ b/crates/collab/src/change.rs
@@ -245,6 +245,10 @@ pub enum Entry {
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
+ /// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
+ /// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
+ /// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
+ /// size are bounded as the entry applies (`image_of` in `docprops.rs`).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
@@ -309,6 +313,13 @@ impl DocProp {
}
}
+impl Change {
+ /// Whether this puts or removes an image entry.
+ pub(crate) fn is_image(&self) -> bool {
+ matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
+ }
+}
+
impl Entry {
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs
index 24a604ff..f53040fb 100644
--- a/crates/collab/src/docprops.rs
+++ b/crates/collab/src/docprops.rs
@@ -8,6 +8,7 @@ use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Family, Json};
@@ -164,13 +165,68 @@ struct ImageValue {
bytes: String,
}
+/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
+/// image this app realistically opens reaches it — an entry claiming a bigger side is
+/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
+/// couldn't probe (`image_data`).
+pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
+
+/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
+fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
+/// already holds an image can't be given other bytes. A key always names the same bytes
+/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
+/// same grey picture), so nobody can swap the picture under a key someone else added.
+fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result {
+ let img = image_of(key, value)?;
+ if held.is_some_and(|old| old.bytes != img.bytes) {
+ return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
+ }
+ Ok(img)
+}
+
+/// The JSON value an image entry carries: its size and its encoded bytes in base64.
fn image_json(img: &ImageData) -> Result {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
-fn image_of(value: &Json) -> Result {
+/// The image a change carries, validated as a collaborator's claim (an invalid entry
+/// rejects its batch, leaving every replica's store clean): a key that can name an archive
+/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/`), dimensions within
+/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
+/// the encoded length is capped before decoding so hostile text can't force a large
+/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
+/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
+/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
+/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
+/// claim no content address, so only the name and size rules apply to them.
+fn image_of(key: &str, value: &Json) -> Result {
+ image_within(key, value, ImageData::MAX_BYTES)
+}
+
+/// [`image_of`] with `max_bytes` for the decoded size.
+pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result {
+ if !ImageData::is_valid_key(key) {
+ return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
+ }
let v: ImageValue = value.read("image")?;
+ if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
+ return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
+ }
+ let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
+ if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
+ return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
+ }
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
+ if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
+ return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
+ }
+ if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
+ return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
+ }
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
@@ -286,7 +342,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
- let img = image_of(value)?;
+ let img = image_for(doc.images.get(k), k, value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
@@ -302,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
+ // An image a layer or style shows stays: removing it would let other bytes go
+ // under its key, swapping the picture that layer shows ([`image_for`]).
+ if figcraft_doc::used_images(doc).contains(k) {
+ return Err(Error::Invalid(format!("image: {k} is still in use")));
+ }
doc.images.remove(k);
}
Entry::Ballot(k) => {
diff --git a/crates/collab/src/history.rs b/crates/collab/src/history.rs
index a45e88a4..50f09c27 100644
--- a/crates/collab/src/history.rs
+++ b/crates/collab/src/history.rs
@@ -311,17 +311,7 @@ impl Step {
apply_all(&mut work, &mut out, deletes);
// Changes that apply one by one can still fail the batch's checks together (rare): keep
// only those that pass them in order.
- if view.clone().apply(&out, Mode::Strict).is_err() {
- let mut w = view.clone();
- out.retain(|c| {
- let mut t = w.clone();
- let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok();
- if ok {
- w = t;
- }
- ok
- });
- }
+ view.clone().apply_kept(&mut out, |_| true)?;
Ok(out)
}
}
diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs
new file mode 100644
index 00000000..9588a68c
--- /dev/null
+++ b/crates/collab/src/image_tests.rs
@@ -0,0 +1,258 @@
+//! Image entries are checked as they apply: bytes carried under a content-hash key must be
+//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
+//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
+//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
+//! content-addressed image store or balloon memory (#1086).
+
+use base64::Engine as _;
+use figcraft_doc::Document;
+use serde_json::json;
+use sha2::{Digest as _, Sha256};
+
+use crate::change::{Change, Entry, Json};
+use crate::docprops::apply as apply_doc;
+use crate::docprops::{MAX_IMAGE_SIDE, image_within};
+
+/// The side bound the implementation enforces.
+const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
+
+/// sha256 hex, computed here independently of the implementation under test.
+pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// An image entry putting `bytes` (`width` × `height`) under `key`.
+pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
+ Change::Put {
+ entry: Entry::Image(key.into()),
+ value: Json(json!({
+ "width": width,
+ "height": height,
+ "bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
+ })),
+ }
+}
+
+/// Bytes a collaborator hashed into the key, and different bytes they send under it.
+pub(crate) fn forged() -> (String, &'static [u8]) {
+ (sha256_hex(b"the image the key actually names"), b"someone else's bytes")
+}
+
+#[test]
+fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
+ let (key, bytes) = forged();
+ let mut doc = Document::new("t");
+ let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
+ Ok(_) => panic!("bytes that don't hash to their key must be refused"),
+ Err(e) => e,
+ };
+ assert!(e.to_string().contains("image"), "not an image refusal: {e}");
+ assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
+}
+
+/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
+/// form, so bytes matching under the uppercase form would file a second copy under a name
+/// no honest lookup reaches. Refused (#1086).
+#[test]
+fn an_uppercase_content_hash_shaped_key_is_refused() {
+ let bytes = b"honestly keyed, uppercase claim".as_slice();
+ let mut doc = Document::new("t");
+ let upper = sha256_hex(bytes).to_uppercase();
+ let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
+ Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
+ Err(e) => e,
+ };
+ assert!(e.to_string().contains("image"), "not an image refusal: {e}");
+ assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
+}
+
+#[test]
+fn an_image_that_hashes_to_its_key_applies() {
+ let bytes = b"a real image, honestly keyed".as_slice();
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
+ let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
+ assert_eq!((img.width, img.height), (2, 1));
+ assert_eq!(img.bytes.as_slice(), bytes);
+}
+
+#[test]
+fn absurd_dimensions_are_refused() {
+ let bytes = b"honest bytes, dishonest size".as_slice();
+ let key = sha256_hex(bytes);
+ for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&key, w, h, bytes))
+ .err()
+ .unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
+ assert!(doc.images.is_empty());
+ }
+}
+
+/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
+/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
+/// the test light.
+#[test]
+fn oversized_image_bytes_are_refused() {
+ let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
+ let at = vec![0u8; 1024];
+ image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
+ let past = vec![0u8; 1025];
+ assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
+ let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
+ let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
+ assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
+}
+
+#[test]
+fn a_zero_sized_image_still_applies() {
+ // The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
+ // so a shared document can legitimately carry one; the bytes still have to be honest.
+ let bytes = b"undecodable bytes kept from a file load".as_slice();
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
+ assert!(doc.images.contains_key(&sha256_hex(bytes)));
+}
+
+#[test]
+fn a_key_that_is_not_a_content_hash_shape_still_applies() {
+ // Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
+ // chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
+ // imageRef itself. They are not content-addressed references, so no hash is claimed; the
+ // dimension and size bounds still hold.
+ for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
+ assert!(doc.images.contains_key(key));
+ let mut big = Document::new("t");
+ assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
+ }
+}
+
+/// A key becomes an archive member name when the file is saved (`images/`), so a key that
+/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
+/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
+#[test]
+fn a_key_that_cannot_name_an_archive_member_is_refused() {
+ for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
+ let mut doc = Document::new("t");
+ assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
+ assert!(doc.images.is_empty(), "key {key:?} reached the image store");
+ }
+}
+
+/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
+/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
+#[test]
+fn an_image_as_large_as_an_import_takes_still_applies() {
+ let bytes = vec![0u8; 40 << 20];
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
+ assert_eq!(doc.images.len(), 1);
+}
+
+/// An image key is content-derived by every producer, so its bytes never change: a collaborator
+/// can't swap the picture under a key another one added.
+#[test]
+fn an_image_cannot_be_replaced_under_its_key() {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
+ assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
+ assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
+ apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
+}
+
+/// A snapshot written before these checks may hold an image they refuse: the session still
+/// opens, without that image, instead of not opening at all.
+#[test]
+fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
+ let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
+ let (key, bytes) = forged();
+ snapshot.push(put(&key, 2, 1, bytes));
+ let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
+ assert!(!r.document().images.contains_key(&key));
+}
+
+/// A server's log written before these checks may hold an image they refuse: a restart replays
+/// the edits after it instead of dropping them with the rest of the log.
+#[test]
+fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
+ let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
+ let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
+ let (key, bytes) = forged();
+ let good = b"an honest image after it".as_slice();
+ let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
+ for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
+ {
+ r.replay(j).expect("the log replays");
+ }
+ assert_eq!(r.seq(), 2, "both batches count");
+ assert!(!r.document().images.contains_key(&key), "the refused image is left out");
+ assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
+}
+
+/// A server's log written before these checks may give an image other bytes under its key (the
+/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
+/// every edit after it, instead of stopping there and dropping the rest of the log.
+#[test]
+fn a_restart_replays_past_a_logged_image_replacement() {
+ let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
+ let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
+ let good = b"an honest image after it".as_slice();
+ let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
+ let journal = [
+ crate::Journal::Issued { client: 1 },
+ accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
+ accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
+ accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
+ ];
+ for j in journal {
+ r.replay(j).expect("the log replays");
+ }
+ assert_eq!(r.seq(), 3, "every batch counts");
+ assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
+ assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
+}
+
+/// A document whose rectangle shows `img1` (the first bytes), and that rectangle.
+fn in_use() -> (Document, figcraft_doc::NodeId) {
+ let mut doc = Document::new("t");
+ let page = doc.first_page().unwrap();
+ let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
+ doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
+ apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
+ (doc, id)
+}
+
+/// An image a layer or style still shows can't be removed: removing it and putting other bytes
+/// under its key would swap the picture that layer shows, which giving the key other bytes
+/// directly can't do (#1086).
+#[test]
+fn an_image_in_use_cannot_be_removed() {
+ let (mut doc, _) = in_use();
+ let remove = Change::Remove { entry: Entry::Image("img1".into()) };
+ assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it");
+ assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err());
+ assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
+}
+
+/// A batch that first stops using an image, then removes it (the order a diff makes), applies.
+#[test]
+fn an_image_no_longer_in_use_can_be_removed() {
+ let (doc, id) = in_use();
+ let mut r = crate::Replica::new(doc);
+ let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }];
+ r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image");
+ assert!(r.document().images.is_empty());
+}
+
+/// Only image entries are left out of saved history: a snapshot whose other changes don't apply
+/// is still an error, not a document missing them.
+#[test]
+fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() {
+ let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
+ // A property of a layer that isn't there: an error when applied (unlike deleting it).
+ let missing = figcraft_doc::NodeId(u64::MAX);
+ snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) });
+ assert!(crate::Replica::from_snapshot(&snapshot).is_err());
+}
diff --git a/crates/collab/src/journal_tests.rs b/crates/collab/src/journal_tests.rs
index 6e41ee76..a8a7c4c2 100644
--- a/crates/collab/src/journal_tests.rs
+++ b/crates/collab/src/journal_tests.rs
@@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() {
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}
+
+/// Restores `state` with one more batch from another client in its log (and in its snapshot,
+/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
+fn resume_past(s: &Server, a: &mut Client, batch: Vec, in_snapshot: bool) -> Server {
+ let mut state = s.state().unwrap();
+ if in_snapshot {
+ state.snapshot.retain(|c| {
+ !batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
+ });
+ state.snapshot.extend(batch.iter().cloned());
+ }
+ state.seq += 1;
+ state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
+ let mut r = Server::restore(state, "", Limits::default()).unwrap();
+ a.disconnect();
+ let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
+ let (_, out) = r.join(h).unwrap();
+ deliver(a, &out);
+ r
+}
+
+/// A saved log may hold an image a newer server refuses: a client resuming from before it still
+/// catches up, without that image (#1086).
+#[test]
+fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let (key, bytes) = crate::image_tests::forged();
+ let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
+ assert!(!a.view().document().images.contains_key(&key));
+ assert_eq!(a.view(), r.replica());
+}
+
+/// A saved log may give an image other bytes under a key the resuming client already holds (an
+/// older server allowed it): the client still catches up, to what the server holds (#1086).
+#[test]
+fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let m = edit(&mut a, |d| {
+ d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
+ });
+ send(&mut s, a.id(), m, &mut [&mut a]);
+ let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
+ assert_eq!(a.view(), r.replica());
+}
+
+/// `a` adds a rectangle showing image `img1`; returns the rectangle.
+fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId {
+ let page = s.document().first_page().unwrap();
+ let mut id = None;
+ let m = edit(a, |d| {
+ let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
+ d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
+ id = Some(r);
+ });
+ send(s, a.id(), m, &mut [a]);
+ id.unwrap()
+}
+
+/// Undoing an image's insertion still works with images in use protected from removal (#1086).
+#[test]
+fn undoing_an_image_layer_still_works() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let id = add_image_layer(&mut s, &mut a);
+ for m in a.undo(&NoRecompute).unwrap() {
+ send(&mut s, a.id(), m, &mut [&mut a]);
+ }
+ assert!(s.document().node(id).is_none(), "the layer is gone");
+ assert_eq!(a.view(), s.replica());
+}
+
+/// A server's log written before the image checks may remove an image a layer still shows: a
+/// restart replays past it, keeping the image and every edit after it (#1086).
+#[test]
+fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() {
+ let mut s = Server::new(base(), "", Limits::default());
+ s.journaling(true);
+ let mut a = join_as(&mut s, Access::Edit);
+ add_image_layer(&mut s, &mut a);
+ let saved = s.state().unwrap();
+ let page = s.document().first_page().unwrap();
+ let mut journal = s.take_journal();
+ let base_seq = saved.seq;
+ let next = |n: u64| base_seq + n;
+ journal.push(Journal::Accepted(message::Accepted {
+ seq: next(1),
+ author: a.id(),
+ batch_id: 90,
+ changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }],
+ }));
+ let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) };
+ journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] }));
+ let mut r = Server::restore(saved, "", Limits::default()).unwrap();
+ for j in journal {
+ r.replay(j).expect("the log replays");
+ }
+ assert_eq!(r.seq(), next(2));
+ assert!(r.document().images.contains_key("img1"), "the image a layer shows stays");
+ assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept");
+}
+
+/// What a resuming client is told to catch up with.
+fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp {
+ a.disconnect();
+ let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
+ let (id, out) = r.join(h).unwrap();
+ out.into_iter()
+ .find_map(|o| match o {
+ Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up),
+ _ => None,
+ })
+ .expect("a welcome")
+}
+
+/// A restart that left an image out of the saved history (an older server accepted it) catches
+/// every resuming client up with the snapshot, even one at the same sequence number: a client
+/// that kept the image from before would otherwise go on showing what the session no longer
+/// has (#1086).
+#[test]
+fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let mut state = s.state().unwrap();
+ let (key, bytes) = crate::image_tests::forged();
+ state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes));
+ let mut r = Server::restore(state, "", Limits::default()).unwrap();
+ assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore");
+
+ // Left out on replay.
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let state = s.state().unwrap();
+ let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap();
+ let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)];
+ r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap();
+ assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay");
+}
+
+/// A restart that left nothing out keeps resuming clients on the cheaper delta.
+#[test]
+fn a_resume_after_a_clean_restart_catches_up_with_the_log() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap();
+ assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_)));
+}
diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs
index fa271f35..f256ef15 100644
--- a/crates/collab/src/lib.rs
+++ b/crates/collab/src/lib.rs
@@ -136,6 +136,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
+mod image_tests;
+#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod merge_list_tests;
diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs
index e14c5fec..0bf0391a 100644
--- a/crates/collab/src/replica.rs
+++ b/crates/collab/src/replica.rs
@@ -72,12 +72,47 @@ impl Replica {
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result {
+ Self::from_snapshot_leaving_out(changes).map(|(r, _)| r)
+ }
+
+ /// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a
+ /// snapshot saved before the image checks may hold one they refuse, and opens without it.
+ pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
- r.apply(changes, Mode::Strict)?;
+ let mut changes = changes.to_vec();
+ let left_out = r.apply_kept(&mut changes, Change::is_image)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
- Ok(r)
+ Ok((r, left_out))
+ }
+
+ /// Apply `changes` strictly. When they don't apply together — history written under older
+ /// rules, or changes that apply one by one but fail the batch's checks together — apply those
+ /// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out`
+ /// allows it (otherwise that change's error). Returns how many were left out.
+ pub(crate) fn apply_kept(&mut self, changes: &mut Vec, may_leave_out: impl Fn(&Change) -> bool) -> Result {
+ let mut all = self.clone();
+ if all.apply(changes, Mode::Strict).is_ok() {
+ *self = all;
+ return Ok(0);
+ }
+ let given = changes.len();
+ let mut kept = Vec::with_capacity(given);
+ for c in changes.drain(..) {
+ let mut t = self.clone();
+ match t.apply(std::slice::from_ref(&c), Mode::Strict) {
+ Ok(_) => {
+ *self = t;
+ kept.push(c);
+ }
+ Err(e) if !may_leave_out(&c) => return Err(e),
+ Err(_) => {}
+ }
+ }
+ let left_out = given - kept.len();
+ *changes = kept;
+ Ok(left_out)
}
/// The whole state as changes that build it from nothing (pages and their trees in order,
diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs
index 52f112ad..9a04e54b 100644
--- a/crates/collab/src/server.rs
+++ b/crates/collab/src/server.rs
@@ -77,6 +77,10 @@ pub struct Server {
signals: BTreeSet,
/// Who spotlights now (a newcomer is told).
spotlight: Option,
+ /// Saved history this server restored or replayed left image entries out (or may give a
+ /// resuming client other bytes under a key it holds): every resuming client catches up with
+ /// the snapshot, not the log.
+ resync: bool,
}
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
@@ -127,6 +131,7 @@ impl Server {
journal: None,
signals: BTreeSet::new(),
spotlight: None,
+ resync: false,
}
}
@@ -148,7 +153,7 @@ impl Server {
/// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped
/// (clients then catch up with a snapshot).
pub fn restore(state: SessionState, token: impl Into, limits: Limits) -> Result {
- let replica = Replica::from_snapshot(&state.snapshot)?;
+ let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?;
let mut server = Server::new(replica.document().clone(), token, limits);
server.state = replica;
server.session = state.session;
@@ -158,6 +163,12 @@ impl Server {
server.deleted = state.deleted;
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
+ // History saved before the image checks may hold image entries they refuse. The snapshot
+ // opens without them, and a saved log putting images may give a resuming client other
+ // bytes under a key it holds, which this state can't check: either way resuming clients
+ // catch up with the snapshot.
+ let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image);
+ server.resync = left_out > 0 || puts_images;
if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
for a in state.log {
server.remember(a);
@@ -198,10 +209,16 @@ impl Server {
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
- Journal::Accepted(acc) => {
+ Journal::Accepted(mut acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
+ // A log written before the image checks may hold an image entry they refuse: the
+ // batch replays without it, so the edits after it aren't dropped with the rest of
+ // the log, and resuming clients catch up with the snapshot.
+ if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 {
+ self.resync = true;
+ }
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
@@ -284,7 +301,9 @@ impl Server {
// The log holds every batch after `floor`.
let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1));
let catch_up = match resume {
- Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
+ Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => {
+ CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect())
+ }
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let (id, secret) = match resume {
diff --git a/crates/doc/src/client_id_tests.rs b/crates/doc/src/client_id_tests.rs
index 520895fb..94b676c2 100644
--- a/crates/doc/src/client_id_tests.rs
+++ b/crates/doc/src/client_id_tests.rs
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
assert!(d.validate().is_empty());
}
+/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
+/// that isn't a page, and repairs into a file with a page.
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
- NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
+ NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
"P",
0.0,
0.0,
diff --git a/crates/doc/src/guides_tests.rs b/crates/doc/src/guides_tests.rs
new file mode 100644
index 00000000..82926e5d
--- /dev/null
+++ b/crates/doc/src/guides_tests.rs
@@ -0,0 +1,61 @@
+//! Ruler guides survive a save and reopen (#1476).
+
+use crate::{Document, Guide, Node, NodeKind};
+
+/// A guide: vertical (at an x offset) or horizontal (at a y offset).
+fn guide(vertical: bool, offset: f64) -> Guide {
+ Guide { vertical, offset }
+}
+
+/// A page's guides are written once and read back onto the page.
+#[test]
+fn page_guides_round_trip_through_json() {
+ let mut d = Document::new("a");
+ let page = d.first_page().unwrap();
+ d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
+ let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
+ assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
+ let back: Node = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.guides(), &[guide(true, 120.0)]);
+ assert_eq!(back, *d.node(page).unwrap());
+}
+
+/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
+#[test]
+fn frame_guides_round_trip_and_none_are_left_out() {
+ let mut d = Document::new("a");
+ let page = d.first_page().unwrap();
+ let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
+ for id in [page, frame] {
+ let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
+ assert!(json.get("guides").is_none(), "{json}");
+ }
+ d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
+ let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
+ assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
+ let back: Node = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, *d.node(frame).unwrap());
+}
+
+/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
+#[test]
+fn a_page_written_with_two_guides_lists_opens_with_both() {
+ // How a page was saved after its file had been reopened: the page kind's list (guides added
+ // since), then the layer's (the ones the file had).
+ let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
+ "width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
+ let n: Node = serde_json::from_str(json).unwrap();
+ assert!(matches!(n.kind, NodeKind::Page { .. }));
+ assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
+ let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
+ assert_eq!(again.guides(), n.guides());
+}
+
+/// A `guides` value that isn't a list of guides is still refused.
+#[test]
+fn a_bad_guides_list_is_still_an_error() {
+ for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
+ let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
+ assert!(serde_json::from_str::(&json).is_err(), "{bad}");
+ }
+}
diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs
index 79b97194..8a18151d 100644
--- a/crates/doc/src/handoff_tests.rs
+++ b/crates/doc/src/handoff_tests.rs
@@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() {
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
+
+/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per
+/// page (#1078: a file with many pages made repair quadratic).
+#[test]
+fn repair_relists_many_unlisted_pages_once_each() {
+ let mut doc = Document::new("d");
+ for i in 0..5_000 {
+ doc.add_page(&format!("P{i}"));
+ }
+ let all: Vec<_> = doc.pages.clone();
+ // Half the pages fall off the list.
+ doc.pages.retain(|p| p.0 % 2 == 0);
+ doc.repair();
+ let mut listed = doc.pages.clone();
+ listed.sort_unstable();
+ let mut want = all;
+ want.sort_unstable();
+ assert_eq!(listed, want, "every page is listed again, once");
+ assert!(doc.validate().is_empty());
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index caf0c861..6f6af7a3 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -161,6 +161,26 @@ pub struct ImageData {
pub height: u32,
}
+impl ImageData {
+ /// Largest encoded image a document takes from outside: an image member of a `.fig` or
+ /// `.sketch` file, an image file a REST import reads, an image entry from a live session.
+ pub const MAX_BYTES: u64 = 256 * 1024 * 1024;
+
+ /// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex
+ /// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it.
+ pub fn claims_content_hash(key: &str) -> bool {
+ key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
+ }
+
+ /// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
+ /// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
+ /// becomes an archive member name (`images/`) when the file is saved, so only these are
+ /// read back from a file or taken from a collaborator: never `/`, `.` or other path text.
+ pub fn is_valid_key(key: &str) -> bool {
+ (1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
+ }
+}
+
/// A whole design file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -407,13 +427,8 @@ impl Document {
/// Add a page at the end.
pub fn add_page(&mut self, name: &str) -> NodeId {
let id = self.alloc_id();
- let node = Node::new(
- id,
- NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
- name,
- 0.0,
- 0.0,
- );
+ let node =
+ Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
self.nodes.insert(id, Arc::new(node));
self.dirty.touch(id);
self.pages.push(id);
@@ -1143,8 +1158,10 @@ impl Document {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
+ // Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
+ let mut listed: std::collections::BTreeSet = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
- if n.is_page() && !self.pages.contains(k) && Some(*k) != store {
+ if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
}
}
@@ -1258,6 +1275,8 @@ mod diff_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
+mod guides_tests;
+#[cfg(test)]
mod handoff_tests;
#[cfg(test)]
mod ids_tests;
diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs
index 35994bb0..fe53ec29 100644
--- a/crates/doc/src/library.rs
+++ b/crates/doc/src/library.rs
@@ -260,7 +260,7 @@ impl Document {
let id = self.alloc_id();
let node = Node::new(
id,
- NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
+ NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
STORE_NAME,
0.0,
0.0,
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 2a284b05..bcf8f09d 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -1,7 +1,10 @@
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
use crate::NodeId;
-use crate::props::*;
+use crate::props::{
+ AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
+ StrokeProps,
+};
use crate::proto::{Interaction, Overflow, ScrollPosition};
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
use crate::text::TextData;
@@ -63,6 +66,9 @@ pub enum PropDef {
}
/// What a node is, with per-type data.
+// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
+// Page's guides moved to the node (#1476).
+#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NodeKind {
@@ -73,8 +79,6 @@ pub enum NodeKind {
flows: Vec,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
- #[serde(default)]
- guides: Vec,
},
Frame,
Group,
@@ -469,8 +473,8 @@ pub struct Node {
pub mask_type: MaskType,
#[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec,
- /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
- #[serde(default, skip_serializing_if = "is_default")]
+ /// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
+ #[serde(flatten, with = "guides_key")]
pub guides: Vec,
#[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec,
@@ -575,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
*v == identity()
}
+/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
+/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
+/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
+/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
+mod guides_key {
+ use super::Guide;
+ use serde::de::{IgnoredAny, MapAccess, Visitor};
+ use serde::ser::SerializeMap;
+ use serde::{Deserialize, Deserializer, Serializer};
+ use std::fmt;
+
+ const KEY: &str = "guides";
+
+ /// Write `guides` as the layer's `guides` key, or nothing when there are none.
+ pub fn serialize(guides: &[Guide], s: S) -> Result {
+ let mut map = s.serialize_map(None)?;
+ if !guides.is_empty() {
+ map.serialize_entry(KEY, guides)?;
+ }
+ map.end()
+ }
+
+ /// Read every `guides` list among the layer's fields, joined in file order.
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> {
+ d.deserialize_map(Lists)
+ }
+
+ /// Reads the `guides` lists of a layer's fields and skips the other fields.
+ struct Lists;
+
+ impl<'de> Visitor<'de> for Lists {
+ type Value = Vec;
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("a layer's fields")
+ }
+ /// Every `guides` value joined, in order; a malformed one is an error.
+ fn visit_map>(self, mut map: A) -> Result, A::Error> {
+ let mut guides = Vec::new();
+ while let Some(key) = map.next_key::()? {
+ if key.0 {
+ guides.extend(map.next_value::>()?);
+ } else {
+ map.next_value::()?;
+ }
+ }
+ Ok(guides)
+ }
+ }
+
+ /// Whether a field name is `guides` (without copying the name).
+ struct Key(bool);
+
+ impl<'de> Deserialize<'de> for Key {
+ /// Compare a field name with `guides` as it is read.
+ fn deserialize>(d: D) -> Result {
+ struct Name;
+ impl Visitor<'_> for Name {
+ type Value = Key;
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("a field name")
+ }
+ /// `guides` or another field.
+ fn visit_str(self, s: &str) -> Result {
+ Ok(Key(s == KEY))
+ }
+ }
+ d.deserialize_identifier(Name)
+ }
+ }
+}
+
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self {
@@ -709,17 +784,12 @@ impl Node {
/// The ruler guides on this page or frame, in its own coordinates.
pub fn guides(&self) -> &[Guide] {
- match &self.kind {
- NodeKind::Page { guides, .. } => guides,
- _ => &self.guides,
- }
+ &self.guides
}
+ /// The ruler guides on this page or frame, to edit.
pub fn guides_mut(&mut self) -> &mut Vec {
- match &mut self.kind {
- NodeKind::Page { guides, .. } => guides,
- _ => &mut self.guides,
- }
+ &mut self.guides
}
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs
index 3932efff..3192c3f1 100644
--- a/crates/engine/src/assets/components.rs
+++ b/crates/engine/src/assets/components.rs
@@ -33,7 +33,9 @@
use std::collections::{BTreeMap, BTreeSet};
-use figcraft_doc::*;
+use figcraft_doc::{
+ ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData,
+};
use figcraft_geom::Affine;
use serde_json::Value;
diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs
index bf742fed..41c1a424 100644
--- a/crates/engine/src/assets/components/diff.rs
+++ b/crates/engine/src/assets/components/diff.rs
@@ -1,5 +1,5 @@
use super::instances::PROP_BINDING;
-use super::*;
+use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set};
/// The property groups whose value layer `n` takes from the same variables as its main layer
/// `src` (they differ only where the two resolve in different variable modes).
diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs
index b320b7c4..0424beff 100644
--- a/crates/engine/src/assets/components/guard.rs
+++ b/crates/engine/src/assets/components/guard.rs
@@ -1,7 +1,10 @@
use super::diff::geometry_derived;
use super::rebase::rebase_instance;
use super::variants::instance_issues;
-use super::*;
+use super::{
+ ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance,
+ owning_instance, structure_locked,
+};
/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
/// main's default content until now) stop mirroring the main, and instances among them stand
diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs
index 9fd4e2c2..ac7ff4ec 100644
--- a/crates/engine/src/assets/components/instances.rs
+++ b/crates/engine/src/assets/components/instances.rs
@@ -1,6 +1,9 @@
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
-use super::*;
+use super::{
+ Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot,
+ slot_edited,
+};
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result {
diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs
index 8c61f0b8..8ae01bc1 100644
--- a/crates/engine/src/assets/components/patch.rs
+++ b/crates/engine/src/assets/components/patch.rs
@@ -2,7 +2,12 @@ use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaul
use super::guard::{own_slot_content, relink_copy};
use super::instances::{copy_group, field_group, sync_instance};
use super::sync::insert_linked;
-use super::*;
+use super::{
+ BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component,
+ owning_instance, root_instance, scope, slot_edited, slot_scope,
+};
+
+type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>);
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
/// whose properties or main were switched, or whose overrides were reset.
@@ -14,93 +19,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
}
}
let changed = changed.as_slice();
- // 0. A slot whose layers were added, removed or reordered holds the instance's own content
- // from now on; copies of instance layers placed elsewhere stop mirroring their main.
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- if n.children == b.children || owning_instance(doc, id).is_none() {
- continue;
- }
- if let Some(slot) = slot_scope(doc, id)
- && !slot_edited(doc, slot)
- {
- own_slot_content(doc, slot);
- }
- }
- for &id in changed {
- if !before.contains(id) {
- relink_copy(doc, id);
- }
- }
+ adopt_slot_content(doc, before, changed);
let mut resync: BTreeSet = BTreeSet::new();
- // 1. Overrides for layers edited inside instances.
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- let Some(inst) = owning_instance(doc, id) else { continue };
- let top_root = inst == id && !inside_instance(doc, id);
- if top_root
- && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
- && (c1 != c2 || p1 != p2)
- {
- // Switching the main or a property value of an instance isn't an override.
- resync.insert(id);
- }
- let raw = diff_groups(b, n);
- let mut groups = raw.clone();
- // An instance's own place, resizing, export settings, interactions and variable modes are
- // its own, not overrides.
- if top_root {
- groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
- }
- if groups.iter().any(|g| matches!(*g, "size" | "position")) {
- groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
- }
- // A value equal to the main's isn't an override (setting it back clears the override).
- let mut cleared = Vec::new();
- if let Some(src) = n.source.and_then(|s| doc.node(s)) {
- let mut differs = diff_groups(src, n);
- if inst == id {
- differs.retain(|g| *g != "name");
- if n.name != default_instance_name(doc, src.id) {
- differs.push("name");
- }
- }
- // A value from the same variable as the main's (resolved in this layer's modes) isn't an
- // override either.
- let from_variables = variable_groups(n, src);
- groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
- // Binding another variable (or none) where the main binds one is an override.
- for (k, _) in b.bound.iter().chain(n.bound.iter()) {
- let Some(g) = binding_group(k) else { continue };
- let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
- if rebound && !groups.contains(&g) {
- groups.push(g);
- }
- }
- cleared =
- raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
- if top_root {
- cleared.retain(|g| !matches!(*g, "component" | "props"));
- }
- }
- let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
- let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
- if (!groups.is_empty() || !cleared.is_empty())
- && let Some(m) = doc.node_mut(id)
- {
- for g in groups {
- m.overrides.insert(g.to_string());
- }
- for g in cleared {
- m.overrides.remove(g);
- }
- }
- if (lost || nested_switch)
- && let Some(r) = root_instance(doc, id)
- {
- resync.insert(r);
- }
- }
+ record_overrides(doc, before, changed, &mut resync);
// 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
// visibility, …) are carried to the copies directly; anything else rebuilds the instances.
let mut comps: BTreeSet = BTreeSet::new();
@@ -172,6 +93,105 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
rebuild_instances(doc, &comps);
}
+/// Slot content changes: layers added, removed or reordered in a slot become the instance's own.
+fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) {
+ // 0. A slot whose layers were added, removed or reordered holds the instance's own content
+ // from now on; copies of instance layers placed elsewhere stop mirroring their main.
+ for &id in changed {
+ let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
+ if n.children == b.children || owning_instance(doc, id).is_none() {
+ continue;
+ }
+ if let Some(slot) = slot_scope(doc, id)
+ && !slot_edited(doc, slot)
+ {
+ own_slot_content(doc, slot);
+ }
+ }
+ for &id in changed {
+ if !before.contains(id) {
+ relink_copy(doc, id);
+ }
+ }
+}
+
+/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`.
+fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet) {
+ for &id in changed {
+ let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
+ let Some(inst) = owning_instance(doc, id) else { continue };
+ let top_root = inst == id && !inside_instance(doc, id);
+ if top_root
+ && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
+ && (c1 != c2 || p1 != p2)
+ {
+ // Switching the main or a property value of an instance isn't an override.
+ resync.insert(id);
+ }
+ let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root);
+ let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
+ let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
+ if (!groups.is_empty() || !cleared.is_empty())
+ && let Some(m) = doc.node_mut(id)
+ {
+ for g in groups {
+ m.overrides.insert(g.to_string());
+ }
+ for g in cleared {
+ m.overrides.remove(g);
+ }
+ }
+ if (lost || nested_switch)
+ && let Some(r) = root_instance(doc, id)
+ {
+ resync.insert(r);
+ }
+ }
+}
+
+/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones
+/// that are now overrides and the overrides that were set back to the main's value.
+fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups {
+ let raw = diff_groups(b, n);
+ let mut groups = raw.clone();
+ // An instance's own place, resizing, export settings, interactions and variable modes are
+ // its own, not overrides.
+ if top_root {
+ groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
+ }
+ if groups.iter().any(|g| matches!(*g, "size" | "position")) {
+ groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
+ }
+ // A value equal to the main's isn't an override (setting it back clears the override).
+ let mut cleared = Vec::new();
+ if let Some(src) = n.source.and_then(|s| doc.node(s)) {
+ let mut differs = diff_groups(src, n);
+ if inst == id {
+ differs.retain(|g| *g != "name");
+ if n.name != default_instance_name(doc, src.id) {
+ differs.push("name");
+ }
+ }
+ // A value from the same variable as the main's (resolved in this layer's modes) isn't an
+ // override either.
+ let from_variables = variable_groups(n, src);
+ groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
+ // Binding another variable (or none) where the main binds one is an override.
+ for (k, _) in b.bound.iter().chain(n.bound.iter()) {
+ let Some(g) = binding_group(k) else { continue };
+ let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
+ if rebound && !groups.contains(&g) {
+ groups.push(g);
+ }
+ }
+ cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
+ if top_root {
+ cleared.retain(|g| !matches!(*g, "component" | "props"));
+ }
+ }
+ (raw, groups, cleared)
+}
+
#[cfg(test)]
thread_local! {
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs
index 748a99d3..ea9cb6f3 100644
--- a/crates/engine/src/assets/components/rebase.rs
+++ b/crates/engine/src/assets/components/rebase.rs
@@ -1,6 +1,6 @@
use super::diff::{diff_groups, variable_groups};
use super::instances::{field_group, path_map};
-use super::*;
+use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content};
/// Bounded source and parent lookups while a rebase reads an unchanged document.
pub(super) struct RebaseSources<'a> {
diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs
index c502322d..e81bf437 100644
--- a/crates/engine/src/assets/components/sync.rs
+++ b/crates/engine/src/assets/components/sync.rs
@@ -1,6 +1,9 @@
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
-use super::*;
+use super::{
+ Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props,
+ holds_content, inside_instance, is_slot, owning_component, variant_set,
+};
/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested
/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides.
@@ -16,31 +19,10 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
// A missing main, or an instance inside its own main (would recurse forever).
return;
}
- let mut eff_props = default_props(&comp);
- for (k, v) in root_props {
- if eff_props.contains_key(k) {
- eff_props.insert(k.clone(), v.clone());
- }
- }
+ let eff_props = effective_props(&comp, root_props);
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
- let mut old_ids = old_layers(doc, inst);
- old_ids.extend(spare.keys().copied());
- old_ids.sort();
- old_ids.dedup();
- let mut old: BTreeMap> = spare;
- let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
- let mut by_src: BTreeMap> = BTreeMap::new();
- for i in &old_ids {
- let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
- if let Some(s) = n.source {
- if let Some(p) = n.parent {
- by_source.entry((p, s)).or_insert(*i);
- }
- by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
- }
- old.insert(*i, n);
- }
+ let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
let mut by_path: Option> = None;
let mut main_paths: Option> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
@@ -124,23 +106,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
// Component properties: the main layer's bindings use this instance's values; layers of
// nested instances use the nested instance's values (one level per source hop).
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
- let mut chain: Vec<&Node> = vec![orig];
- while chain.len() < enc.len() {
- let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
- chain.push(next);
- }
- for (j, level) in chain.iter().enumerate().rev() {
- let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
- for (field, prop) in &level.prop_refs {
- let g = field_group(field);
- if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
- continue;
- }
- if let Some(val) = props.get(prop) {
- apply_prop(doc, &mut n, field, val);
- }
- }
- }
+ apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
if let NodeKind::Instance { component: c, props } = &n.kind {
inst_props.insert(nid, props.clone());
let mut e = enc;
@@ -167,6 +133,155 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
p.children.retain(|c| *c != id);
}
}
+ let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
+ if let Some(r) = doc.node_mut(inst) {
+ *r = fresh;
+ }
+ // A resized instance: children follow their constraints (auto layout re-flows later).
+ let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
+ if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
+ figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
+ remeasure = map.values().copied().collect();
+ }
+ follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
+ remeasure_text(doc, remeasure);
+ rebuild_nested(doc, nested, &old, &used, depth);
+}
+
+/// Geometry overrides of children win over constraints.
+fn follow_geometry_overrides(
+ doc: &mut Document,
+ src: &[NodeId],
+ map: &BTreeMap,
+ old: &BTreeMap>,
+ remeasure: &mut Vec,
+) {
+ for s in src {
+ let Some(nid) = map.get(s).copied() else { continue };
+ let Some(o) = old.get(&nid) else { continue };
+ // A frame that kept the size its own resizing gave it: its layers (copied at their main
+ // layers' places) follow their constraints from its main layer's size.
+ if o.overrides.contains("resizing")
+ && !o.overrides.contains("size")
+ && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
+ && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
+ {
+ figcraft_layout::apply_constraints(doc, nid, mw, mh);
+ }
+ if !o.overrides.contains("size") && !o.overrides.contains("position") {
+ continue;
+ }
+ remeasure.push(nid);
+ let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
+ if o.overrides.contains("size") {
+ // Through set_size so vector networks scale with the box.
+ figcraft_layout::set_size(doc, nid, o.width, o.height);
+ if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
+ figcraft_layout::apply_constraints(doc, nid, ow, oh);
+ }
+ }
+ if let Some(n) = doc.node_mut(nid) {
+ carry_overrides(o, n, Carry::Geometry);
+ }
+ }
+}
+
+/// Nested instances showing another main than the main's copy (swapped): rebuild from it,
+/// with the old layers below them this rebuild didn't reuse.
+fn rebuild_nested(doc: &mut Document, nested: Vec, old: &BTreeMap>, used: &BTreeSet, depth: usize) {
+ let unused: BTreeMap> =
+ old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
+ for n in nested {
+ let mut below: BTreeMap> = unused
+ .iter()
+ .filter(|(_, x)| {
+ let mut cur = x.parent;
+ for _ in 0..=MAX_DEPTH * 4 {
+ match cur {
+ Some(p) if p == n => return true,
+ Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
+ None => return false,
+ }
+ }
+ false
+ })
+ .map(|(i, x)| (*i, x.clone()))
+ .collect();
+ take_placing_overrides(doc, n, old, &mut below);
+ sync_instance_depth(doc, n, depth + 1, below);
+ }
+}
+
+fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap>) {
+ let mut chain: Vec<&Node> = vec![orig];
+ while chain.len() < enc.len() {
+ let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
+ chain.push(next);
+ }
+ for (j, level) in chain.iter().enumerate().rev() {
+ let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
+ for (field, prop) in &level.prop_refs {
+ let g = field_group(field);
+ if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
+ continue;
+ }
+ if let Some(val) = props.get(prop) {
+ apply_prop(doc, n, field, val);
+ }
+ }
+ }
+}
+
+/// Text whose characters or style differ from the main re-measures.
+fn remeasure_text(doc: &mut Document, remeasure: Vec) {
+ for nid in remeasure {
+ if let Some(n) = doc.node(nid)
+ && let Some((w, h)) = figcraft_text::auto_size(n)
+ && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
+ {
+ figcraft_layout::set_size(doc, nid, w, h);
+ }
+ }
+}
+
+/// The component's property defaults with the instance's values for the ones it has.
+fn effective_props(comp: &Node, root_props: &BTreeMap) -> BTreeMap {
+ let mut eff_props = default_props(comp);
+ for (k, v) in root_props {
+ if eff_props.contains_key(k) {
+ eff_props.insert(k.clone(), v.clone());
+ }
+ }
+ eff_props
+}
+
+type OldIndex = (BTreeMap>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap>);
+
+/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None`
+/// when several layers mirror it).
+fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap>) -> OldIndex {
+ let mut old_ids = old_layers(doc, inst);
+ old_ids.extend(spare.keys().copied());
+ old_ids.sort();
+ old_ids.dedup();
+ let mut old = spare;
+ let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
+ let mut by_src: BTreeMap> = BTreeMap::new();
+ for i in &old_ids {
+ let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
+ if let Some(s) = n.source {
+ if let Some(p) = n.parent {
+ by_source.entry((p, s)).or_insert(*i);
+ }
+ by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
+ }
+ old.insert(*i, n);
+ }
+ (old, by_source, by_src)
+}
+
+/// The rebuilt root of instance `inst` of `comp`.
+fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap) -> Node {
// Root: follows the main except its own placement, name, size and overrides.
let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
let mut fresh = comp.clone();
@@ -175,7 +290,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
fresh.children = order;
fresh.transform = root.transform;
fresh.kind = root.kind.clone();
- carry_prop_bindings(&root, &mut fresh);
+ carry_prop_bindings(root, &mut fresh);
// A nested instance keeps mirroring its layer of the outer main.
let nested_copy = inside_instance(doc, inst);
fresh.source = if nested_copy { root.source } else { Some(component) };
@@ -212,81 +327,12 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
// Switched to another variant: its own overrides stay where both variants agreed.
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
- let differs = diff_groups(prev, &comp);
+ let differs = diff_groups(prev, comp);
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
}
- carry_overrides(&root, &mut fresh, Carry::Root);
- if let Some(r) = doc.node_mut(inst) {
- *r = fresh;
- }
- // A resized instance: children follow their constraints (auto layout re-flows later).
- let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
- if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
- figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
- remeasure = map.values().copied().collect();
- }
- // Geometry overrides of children win over constraints.
- for s in &src {
- let Some(nid) = map.get(s).copied() else { continue };
- let Some(o) = old.get(&nid) else { continue };
- // A frame that kept the size its own resizing gave it: its layers (copied at their main
- // layers' places) follow their constraints from its main layer's size.
- if o.overrides.contains("resizing")
- && !o.overrides.contains("size")
- && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
- && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
- {
- figcraft_layout::apply_constraints(doc, nid, mw, mh);
- }
- if !o.overrides.contains("size") && !o.overrides.contains("position") {
- continue;
- }
- remeasure.push(nid);
- let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
- if o.overrides.contains("size") {
- // Through set_size so vector networks scale with the box.
- figcraft_layout::set_size(doc, nid, o.width, o.height);
- if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
- figcraft_layout::apply_constraints(doc, nid, ow, oh);
- }
- }
- if let Some(n) = doc.node_mut(nid) {
- carry_overrides(o, n, Carry::Geometry);
- }
- }
- // Text whose characters or style differ from the main re-measures.
- for nid in remeasure {
- if let Some(n) = doc.node(nid)
- && let Some((w, h)) = figcraft_text::auto_size(n)
- && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
- {
- figcraft_layout::set_size(doc, nid, w, h);
- }
- }
- // Nested instances showing another main than the main's copy (swapped): rebuild from it,
- // with the old layers below them this rebuild didn't reuse.
- let unused: BTreeMap> =
- old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
- for n in nested {
- let mut below: BTreeMap> = unused
- .iter()
- .filter(|(_, x)| {
- let mut cur = x.parent;
- for _ in 0..=MAX_DEPTH * 4 {
- match cur {
- Some(p) if p == n => return true,
- Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
- None => return false,
- }
- }
- false
- })
- .map(|(i, x)| (*i, x.clone()))
- .collect();
- take_placing_overrides(doc, n, &old, &mut below);
- sync_instance_depth(doc, n, depth + 1, below);
- }
+ carry_overrides(root, &mut fresh, Carry::Root);
+ fresh
}
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs
index 30254651..82aabc63 100644
--- a/crates/engine/src/assets/components/tests.rs
+++ b/crates/engine/src/assets/components/tests.rs
@@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() {
healthy(&s);
}
+/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property.
+fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) {
+ // Unbind and delete.
+ ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
+ assert!(node(s, copy_of(s, i, dot)).visible);
+ ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
+ assert!(node(s, title).prop_refs.is_empty());
+ assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
+ let desc = ex(s, "component.properties", json!({"id": i}));
+ assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
+ // A variant property on a lone component makes it a component set.
+ let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
+ assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
+ assert_eq!(node(s, c).name, "Theme=Light");
+}
+
#[test]
fn component_properties_apply_to_instances() {
let mut s = Session::new();
@@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() {
assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err());
assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err());
assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err());
- // Unbind and delete.
- ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
- assert!(node(&s, copy_of(&s, i, dot)).visible);
- ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
- assert!(node(&s, title).prop_refs.is_empty());
- assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
- let desc = ex(&mut s, "component.properties", json!({"id": i}));
- assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
- // A variant property on a lone component makes it a component set.
- let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
- assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. }));
- assert_eq!(node(&s, c).name, "Theme=Light");
+ unbind_delete_and_vary(&mut s, c, i, title, dot);
healthy(&s);
}
diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs
index 40ac23ea..bc0d25b7 100644
--- a/crates/engine/src/assets/components/variants.rs
+++ b/crates/engine/src/assets/components/variants.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance};
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs
index 56b0fc07..24699ef2 100644
--- a/crates/engine/src/assets/library.rs
+++ b/crates/engine/src/assets/library.rs
@@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use figcraft_doc::proto::Action;
-use figcraft_doc::*;
+use figcraft_doc::{
+ Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
+ LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
+ VariableValue, Variables, node_images, paint_images, used_images,
+};
use serde_json::Value;
use crate::components::inside_instance;
diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs
index 27c9e616..c8ef2e49 100644
--- a/crates/engine/src/assets/library/tests.rs
+++ b/crates/engine/src/assets/library/tests.rs
@@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String {
r["id"].as_str().unwrap().to_string()
}
+/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest.
+fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) {
+ let m = &lib.manifest;
+ // Saved and read back: the author's file and a reader of the library see the same manifest.
+ let bytes = figcraft_format::save_bytes(doc, None).unwrap();
+ let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
+ assert!(issues.is_empty(), "{issues:?}");
+ assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
+ let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
+ assert_eq!(read.manifest, *m);
+ assert_eq!(read.doc.node_count(), lib.doc.node_count());
+ // Older files and files never published have no library; readers that don't know libraries
+ // are unaffected (the entries are extra archive members).
+ let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
+ assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
+ assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
+}
+
+/// `publish_manifest_roundtrip`: a republish bumps only what changed.
+fn republish_bumps_touched(s: &mut Session, k: &Kit) {
+ // Nothing changed: refused. A change bumps only what it touches (and what uses it).
+ let e = s.execute("library.publish", &json!({})).unwrap_err();
+ assert!(e.to_string().contains("no changes"), "{e}");
+ ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
+ let r = ex(s, "library.publish", json!({}));
+ assert_eq!(r["version"], 2);
+ let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
+ assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
+ assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
+ assert_eq!(m.description, "Our kit", "the description carries over");
+ // Excluding an asset; a used component still comes along (not offered).
+ let r = ex(s, "library.publish", json!({"exclude": ["Button"]}));
+ assert_eq!(r["components"], 1);
+ let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
+ assert!(m.component(k.button).is_some_and(|a| !a.published));
+ // Unknown assets are an error, not ignored.
+ assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
+ assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
+ // Undo takes the publish back.
+ s.undo().unwrap();
+ assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
+}
+
#[test]
fn publish_manifest_roundtrip() {
let mut s = Session::new();
@@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() {
assert!(lib.doc.node(k.label).is_some());
assert!(lib.doc.validate().is_empty());
assert_eq!(lib.doc.styles.len(), 1);
- // Saved and read back: the author's file and a reader of the library see the same manifest.
- let bytes = figcraft_format::save_bytes(doc, None).unwrap();
- let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
- assert!(issues.is_empty(), "{issues:?}");
- assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
- let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
- assert_eq!(read.manifest, *m);
- assert_eq!(read.doc.node_count(), lib.doc.node_count());
- // Older files and files never published have no library; readers that don't know libraries
- // are unaffected (the entries are extra archive members).
- let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
- assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
- assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
+ saved_and_read_back(doc, &lib);
- // Nothing changed: refused. A change bumps only what it touches (and what uses it).
- let e = s.execute("library.publish", &json!({})).unwrap_err();
- assert!(e.to_string().contains("no changes"), "{e}");
- ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
- let r = ex(&mut s, "library.publish", json!({}));
- assert_eq!(r["version"], 2);
- let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
- assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
- assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
- assert_eq!(m.description, "Our kit", "the description carries over");
- // Excluding an asset; a used component still comes along (not offered).
- let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
- assert_eq!(r["components"], 1);
- let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
- assert!(m.component(k.button).is_some_and(|a| !a.published));
- // Unknown assets are an error, not ignored.
- assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
- assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
- // Undo takes the publish back.
- s.undo().unwrap();
- assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
+ republish_bumps_touched(&mut s, &k);
+}
+
+/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back.
+fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
+ let unchanged = s.doc().unwrap().clone();
+ let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
+ assert!(e.to_string().contains("can't be edited"), "{e}");
+ assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
+ for (cmd, args) in
+ [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
+ {
+ assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
+ assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
+ }
+ let style = s.doc().unwrap().styles[0].id.clone();
+ assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
+ assert!(s.execute("style.delete", &json!({"style": style})).is_err());
+ let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
+ assert!(e.to_string().contains("library"), "{e}");
+ assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
+ assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
+ assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
+ assert_eq!(fill(s, remote_button), "0000FF");
+}
+
+/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance.
+fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId {
+ // The library changes the Button (fill via its style, and its label) and publishes again.
+ ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
+ ex(lib, "text.set", json!({"id": k.label, "characters": "Go"}));
+ ex(lib, "library.publish", json!({}));
+ ex(lib, "file.save", json!({}));
+
+ let u = ex(s, "library.updates", json!({}));
+ assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
+ let label_ids: Vec = s.doc().unwrap().subtree(b);
+ ex(s, "library.acceptUpdates", json!({}));
+ assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main");
+ assert_eq!(chars(s, b_label), "Buy", "its override is kept");
+ assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
+ assert_eq!(fill(s, remote_button), "00FF00");
+ let style = s.doc().unwrap().styles[0].clone();
+ assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
+ let doc = s.doc().unwrap();
+ assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2));
+ // The Card's nested Button follows too.
+ let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
+ let nested = node(s, card_inst).children[0];
+ assert_eq!(fill(s, nested), "00FF00");
+ assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
+ healthy(s);
+ // A structural change: the Button gains a layer (the Card's nested Button too).
+ ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
+ ex(lib, "library.publish", json!({}));
+ ex(lib, "file.save", json!({}));
+ ex(s, "library.acceptUpdates", json!({"ids": [lid]}));
+ assert_eq!(node(s, b).children.len(), 2);
+ assert_eq!(node(s, nested).children.len(), 2);
+ assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update");
+ assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3));
+ healthy(s);
+ // Overrides still work after the update; resetting shows the library's text.
+ ex(s, "component.resetInstance", json!({"ids": [b]}));
+ assert_eq!(chars(s, node(s, b).children[0]), "Go");
+ card_inst
}
#[test]
@@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() {
NodeKind::Instance { component, .. } => component,
_ => panic!("not an instance"),
};
- let unchanged = s.doc().unwrap().clone();
- let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
- assert!(e.to_string().contains("can't be edited"), "{e}");
- assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
- for (cmd, args) in
- [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
- {
- assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
- assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
- }
- let style = s.doc().unwrap().styles[0].id.clone();
- assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
- assert!(s.execute("style.delete", &json!({"style": style})).is_err());
- let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
- assert!(e.to_string().contains("library"), "{e}");
- assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
- assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
- assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
- assert_eq!(fill(&s, remote_button), "0000FF");
+ assert_remote_read_only(&mut s, b, remote_button);
- // The library changes the Button (fill via its style, and its label) and publishes again.
- ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
- ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
- ex(&mut lib, "library.publish", json!({}));
- ex(&mut lib, "file.save", json!({}));
-
- let u = ex(&mut s, "library.updates", json!({}));
- assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
- let label_ids: Vec = s.doc().unwrap().subtree(b);
- ex(&mut s, "library.acceptUpdates", json!({}));
- assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
- assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
- assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
- assert_eq!(fill(&s, remote_button), "00FF00");
- let style = s.doc().unwrap().styles[0].clone();
- assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
- let doc = s.doc().unwrap();
- assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
- // The Card's nested Button follows too.
- let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
- let nested = node(&s, card_inst).children[0];
- assert_eq!(fill(&s, nested), "00FF00");
- assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
- healthy(&s);
- // A structural change: the Button gains a layer (the Card's nested Button too).
- ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
- ex(&mut lib, "library.publish", json!({}));
- ex(&mut lib, "file.save", json!({}));
- ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
- assert_eq!(node(&s, b).children.len(), 2);
- assert_eq!(node(&s, nested).children.len(), 2);
- assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
- assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
- healthy(&s);
- // Overrides still work after the update; resetting shows the library's text.
- ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
- assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
+ let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]);
// Saved and reopened, the document keeps its links, copies and records.
let user_path = path_str(&d.join("app.figcraft"));
diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs
index 1220d24c..1dfc1691 100644
--- a/crates/engine/src/assets/library_swap.rs
+++ b/crates/engine/src/assets/library_swap.rs
@@ -6,7 +6,7 @@
use std::collections::{BTreeMap, BTreeSet};
-use figcraft_doc::*;
+use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue};
use serde_json::Value;
use crate::cmd::component::{main_of, swap_instance};
@@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt
}
}
+/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`).
+fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap {
+ let mut keys = BTreeMap::new();
+ for r in recs {
+ let Some(st) = doc.style(&r.local) else { continue };
+ let kind = st.kind.type_name();
+ match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
+ Some(x) => {
+ keys.insert(r.local.clone(), x.id.clone());
+ }
+ None => out.unmatched.push(format!("style {}", st.name)),
+ }
+ }
+ keys
+}
+
+/// (local component root, the library's component id) by name; the rest go to `out.unmatched`.
+fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> {
+ let mut pairs = Vec::new();
+ for r in records {
+ let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
+ match m.components.iter().find(|a| a.published && a.name == name) {
+ Some(a) => pairs.push((r.root, a.id)),
+ None => out.unmatched.push(format!("component {name}")),
+ }
+ }
+ pairs
+}
+
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result {
let m = &to.manifest;
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
})
.collect();
let records: Vec = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
- let mut pairs = Vec::new();
- for r in &records {
- let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
- match m.components.iter().find(|a| a.published && a.name == name) {
- Some(a) => pairs.push((r.root, a.id)),
- None => out.unmatched.push(format!("component {name}")),
- }
- }
+ let pairs = match_components(doc, &records, m, &mut out);
let keys: Vec = pairs.iter().map(|(_, k)| *k).collect();
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
// A's mains (each variant) and roots → B's.
@@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
// Styles the document's own layers link to: by name and type.
let linked: BTreeSet = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
let style_recs: Vec = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
- let mut style_keys: BTreeMap = BTreeMap::new();
- for r in &style_recs {
- let Some(st) = doc.style(&r.local) else { continue };
- let kind = st.kind.type_name();
- match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
- Some(x) => {
- style_keys.insert(r.local.clone(), x.id.clone());
- }
- None => out.unmatched.push(format!("style {}", st.name)),
- }
- }
+ let style_keys = match_styles(doc, &style_recs, m, &mut out);
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
let styles: BTreeMap = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
out.styles = styles.len();
diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs
index 90a627b0..5c05c3b9 100644
--- a/crates/engine/src/assets/playground.rs
+++ b/crates/engine/src/assets/playground.rs
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::CodeLang;
-use crate::params::*;
+use crate::params::{get, id_of, num, string, targets};
use crate::{EngineError, Result, Session, bad, spec};
/// The component a playground for `target` shows: an instance's component, a component or a
diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs
index b2e5c182..7fb30fd0 100644
--- a/crates/engine/src/assets/style_vars.rs
+++ b/crates/engine/src/assets/style_vars.rs
@@ -8,7 +8,10 @@ use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::PaintKind;
-use figcraft_doc::*;
+use figcraft_doc::{
+ CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId,
+ VariableType, VariableValue, Variables,
+};
use crate::cmd::lists::{BoundList, list_binding, stop_binding};
diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs
index 7dccd706..886d2d85 100644
--- a/crates/engine/src/automation/inspect.rs
+++ b/crates/engine/src/automation/inspect.rs
@@ -1,6 +1,6 @@
//! Document inspection as JSON, so agents can verify their work without screenshots.
-use figcraft_doc::*;
+use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration};
use serde_json::{Value, json};
use crate::Session;
@@ -8,7 +8,6 @@ use crate::Session;
/// A node and (to `depth`) its children, with the properties a designer would check.
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
- let c = doc.frame_relative(id).as_coeffs();
let mut v = json!({
"id": n.id,
"name": n.name,
@@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
});
let Some(o) = v.as_object_mut() else { return v };
if !n.is_page() {
- o.insert("x".into(), json!(round(c[4])));
- o.insert("y".into(), json!(round(c[5])));
- o.insert("width".into(), json!(round(n.width)));
- o.insert("height".into(), json!(round(n.height)));
- let rot = figcraft_geom::rotation_degrees(n.transform);
- if rot.abs() > 1e-6 {
- o.insert("rotation".into(), json!(round(rot)));
- }
- if let Some(b) = doc.world_bounds(id) {
- o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
- }
- if !n.visible {
- o.insert("visible".into(), json!(false));
- }
- if n.locked {
- o.insert("locked".into(), json!(true));
- }
- if n.opacity < 1.0 {
- o.insert("opacity".into(), json!(n.opacity));
- }
- if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
- o.insert("blend".into(), json!(n.blend));
- }
- if !n.fills.is_empty() {
- o.insert("fills".into(), paints(&n.fills));
- }
- if !n.strokes.is_empty() {
- o.insert("strokes".into(), paints(&n.strokes));
- o.insert("strokeWeight".into(), json!(n.stroke.weight));
- o.insert("strokeAlign".into(), json!(n.stroke.align));
- if n.stroke.sides.is_some() {
- o.insert("strokeSides".into(), json!(n.stroke.sides));
- }
- if !n.stroke.dashes.is_empty() {
- o.insert("dashes".into(), json!(n.stroke.dashes));
- }
- if n.stroke.join != StrokeJoin::Miter {
- o.insert("strokeJoin".into(), json!(n.stroke.join));
- } else if n.stroke.miter_limit != 4.0 {
- o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
- }
- if let Some(p) = n.stroke.profile() {
- let points = crate::cmd::props::width_points_json(Some(p));
- o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
- }
- if let Some(b) = &n.stroke.brush {
- o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
- }
- }
- if !n.effects.is_empty() {
- o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
- }
- if n.corners.radii.iter().any(|r| *r > 0.0) {
- o.insert(
- "cornerRadius".into(),
- match n.corners.uniform() {
- Some(r) => json!(r),
- None => json!(n.corners.radii),
- },
- );
- if n.corners.smoothing > 0.0 {
- o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
- }
- }
- if let Some(l) = &n.layout {
- o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
- }
- let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
- if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
- o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
- }
- let cl = &n.child_layout;
- if cl.absolute {
- o.insert("absolute".into(), json!(true));
- }
- for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
- if let Some(v) = v {
- o.insert(k.into(), json!(v));
- }
- }
- if let Some(cell) = parent_al
- .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
- .flatten()
- .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
- {
- o.insert(
- "gridCell".into(),
- json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
- "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
- );
- }
- if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
- o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
- }
- if n.kind.is_frame_like() {
- o.insert("clip".into(), json!(n.clip));
- }
- if !n.dev_status.is_none() {
- o.insert("devStatus".into(), json!(n.dev_status.id()));
- }
- if n.dev_changed {
- o.insert("devChanged".into(), json!(true));
- }
- if !n.dev_note.is_empty() {
- o.insert("devNote".into(), json!(n.dev_note));
- }
- if let NodeKind::Section { contents_hidden } = n.kind {
- o.insert("sectionContentsHidden".into(), json!(contents_hidden));
- }
- if n.is_mask {
- o.insert("isMask".into(), json!(true));
- o.insert("maskType".into(), json!(n.mask_type.id()));
- }
- if !n.grids.is_empty() {
- o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
- }
- if !n.exports.is_empty() {
- o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
- }
- if let Some(b) = &n.board {
- o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
- }
- if !n.annotations.is_empty() {
- o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
- }
- if !n.code_links.is_empty() {
- o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
- }
- if !n.measurements.is_empty() {
- o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
- }
- let dev = crate::dev_resources::list_json(doc, n.id);
- if dev.as_array().is_some_and(|a| !a.is_empty()) {
- o.insert("devResources".into(), dev);
- }
- if !n.interactions.is_empty() {
- o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
- }
- if !n.styles.is_empty() {
- o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
- }
- if !n.bound.is_empty() {
- o.insert("boundVariables".into(), json!(n.bound));
- }
- if !n.overrides.is_empty() {
- o.insert("overrides".into(), json!(n.overrides));
- }
- if let Some(slot) = crate::components::slot_name(doc, n.id) {
- o.insert("slot".into(), json!(slot));
- }
- // An operand combining by an operation of its own.
- if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
- o.insert("operandOperation".into(), json!(op));
- }
+ base_props(doc, n, id, o);
+ paint_props(n, o);
+ layout_props(doc, n, o);
+ dev_props(doc, n, o);
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
}
match &n.kind {
- NodeKind::Text(t) => {
- o.insert("characters".into(), json!(t.characters));
- o.insert("fontFamily".into(), json!(t.style.font_family));
- o.insert("fontStyle".into(), json!(t.style.font_style));
- o.insert("fontSize".into(), json!(t.style.font_size));
- if !t.style.variations.is_empty() {
- o.insert("fontVariations".into(), json!(t.style.variations));
- }
- o.insert("lineHeight".into(), json!(t.style.line_height.label()));
- o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
- o.insert("textAlign".into(), json!(t.align_h));
- o.insert("textAutoResize".into(), json!(t.auto_resize));
- if t.max_lines > 0 {
- o.insert("maxLines".into(), json!(t.max_lines));
- }
- if let Some(tp) = &t.path {
- o.insert("textPath".into(), json!(tp));
- }
- if t.style.decoration != TextDecoration::None {
- o.insert("textDecoration".into(), json!(t.style.decoration));
- }
- if let Some(l) = t.style.decoration_look.as_deref() {
- o.insert("textDecorationStyle".into(), json!(l.style));
- o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
- o.insert("textDecorationOffset".into(), json!(l.offset.label()));
- o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
- o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
- }
- // Lists: one style for the layer, or each paragraph's type, nesting level and start.
- if t.paragraphs.is_empty() {
- if t.list != figcraft_doc::ListStyle::None {
- o.insert("listStyle".into(), json!(t.list));
- }
- } else {
- o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
- o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
- if t.paragraphs.iter().any(|p| p.start_offset > 0) {
- o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
- }
- }
- if t.list_spacing != 0.0 {
- o.insert("listSpacing".into(), json!(t.list_spacing));
- }
- for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
- if on {
- o.insert(k.into(), json!(true));
- }
- }
- if t.leading_trim != figcraft_doc::LeadingTrim::None {
- o.insert("leadingTrim".into(), json!(t.leading_trim));
- }
- if !t.runs.is_empty() {
- o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
- }
- // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
- let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
- if !linked.is_empty() {
- o.insert("textStyleRanges".into(), Value::Array(linked));
- }
+ NodeKind::Text(t) => text_props(t, o),
+ _ => kind_props(doc, n, o),
+ }
+ if !n.children.is_empty() {
+ if depth > 0 {
+ o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
+ } else {
+ o.insert("childCount".into(), json!(n.children.len()));
}
+ }
+ v
+}
+
+fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map) {
+ let c = doc.frame_relative(id).as_coeffs();
+ o.insert("x".into(), json!(round(c[4])));
+ o.insert("y".into(), json!(round(c[5])));
+ o.insert("width".into(), json!(round(n.width)));
+ o.insert("height".into(), json!(round(n.height)));
+ let rot = figcraft_geom::rotation_degrees(n.transform);
+ if rot.abs() > 1e-6 {
+ o.insert("rotation".into(), json!(round(rot)));
+ }
+ if let Some(b) = doc.world_bounds(id) {
+ o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
+ }
+ if !n.visible {
+ o.insert("visible".into(), json!(false));
+ }
+ if n.locked {
+ o.insert("locked".into(), json!(true));
+ }
+ if n.opacity < 1.0 {
+ o.insert("opacity".into(), json!(n.opacity));
+ }
+ if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
+ o.insert("blend".into(), json!(n.blend));
+ }
+}
+
+fn paint_props(n: &Node, o: &mut serde_json::Map) {
+ if !n.fills.is_empty() {
+ o.insert("fills".into(), paints(&n.fills));
+ }
+ if !n.strokes.is_empty() {
+ o.insert("strokes".into(), paints(&n.strokes));
+ o.insert("strokeWeight".into(), json!(n.stroke.weight));
+ o.insert("strokeAlign".into(), json!(n.stroke.align));
+ if n.stroke.sides.is_some() {
+ o.insert("strokeSides".into(), json!(n.stroke.sides));
+ }
+ if !n.stroke.dashes.is_empty() {
+ o.insert("dashes".into(), json!(n.stroke.dashes));
+ }
+ if n.stroke.join != StrokeJoin::Miter {
+ o.insert("strokeJoin".into(), json!(n.stroke.join));
+ } else if n.stroke.miter_limit != 4.0 {
+ o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
+ }
+ if let Some(p) = n.stroke.profile() {
+ let points = crate::cmd::props::width_points_json(Some(p));
+ o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
+ }
+ if let Some(b) = &n.stroke.brush {
+ o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
+ }
+ }
+ if !n.effects.is_empty() {
+ o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
+ }
+ if n.corners.radii.iter().any(|r| *r > 0.0) {
+ o.insert(
+ "cornerRadius".into(),
+ match n.corners.uniform() {
+ Some(r) => json!(r),
+ None => json!(n.corners.radii),
+ },
+ );
+ if n.corners.smoothing > 0.0 {
+ o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
+ }
+ }
+}
+
+fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map) {
+ if let Some(l) = &n.layout {
+ o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
+ }
+ let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
+ if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
+ o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
+ }
+ let cl = &n.child_layout;
+ if cl.absolute {
+ o.insert("absolute".into(), json!(true));
+ }
+ for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
+ if let Some(v) = v {
+ o.insert(k.into(), json!(v));
+ }
+ }
+ if let Some(cell) = parent_al
+ .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
+ .flatten()
+ .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
+ {
+ o.insert(
+ "gridCell".into(),
+ json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
+ "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
+ );
+ }
+ if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
+ o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
+ }
+ if n.kind.is_frame_like() {
+ o.insert("clip".into(), json!(n.clip));
+ }
+}
+
+fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map) {
+ if !n.dev_status.is_none() {
+ o.insert("devStatus".into(), json!(n.dev_status.id()));
+ }
+ if n.dev_changed {
+ o.insert("devChanged".into(), json!(true));
+ }
+ if !n.dev_note.is_empty() {
+ o.insert("devNote".into(), json!(n.dev_note));
+ }
+ if let NodeKind::Section { contents_hidden } = n.kind {
+ o.insert("sectionContentsHidden".into(), json!(contents_hidden));
+ }
+ if n.is_mask {
+ o.insert("isMask".into(), json!(true));
+ o.insert("maskType".into(), json!(n.mask_type.id()));
+ }
+ if !n.grids.is_empty() {
+ o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
+ }
+ if !n.exports.is_empty() {
+ o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
+ }
+ if let Some(b) = &n.board {
+ o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
+ }
+ if !n.annotations.is_empty() {
+ o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
+ }
+ if !n.code_links.is_empty() {
+ o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
+ }
+ if !n.measurements.is_empty() {
+ o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
+ }
+ let dev = crate::dev_resources::list_json(doc, n.id);
+ if dev.as_array().is_some_and(|a| !a.is_empty()) {
+ o.insert("devResources".into(), dev);
+ }
+ if !n.interactions.is_empty() {
+ o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
+ }
+ if !n.styles.is_empty() {
+ o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
+ }
+ if !n.bound.is_empty() {
+ o.insert("boundVariables".into(), json!(n.bound));
+ }
+ if !n.overrides.is_empty() {
+ o.insert("overrides".into(), json!(n.overrides));
+ }
+ if let Some(slot) = crate::components::slot_name(doc, n.id) {
+ o.insert("slot".into(), json!(slot));
+ }
+ // An operand combining by an operation of its own.
+ if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
+ o.insert("operandOperation".into(), json!(op));
+ }
+}
+
+fn text_props(t: &TextData, o: &mut serde_json::Map) {
+ o.insert("characters".into(), json!(t.characters));
+ o.insert("fontFamily".into(), json!(t.style.font_family));
+ o.insert("fontStyle".into(), json!(t.style.font_style));
+ o.insert("fontSize".into(), json!(t.style.font_size));
+ if !t.style.variations.is_empty() {
+ o.insert("fontVariations".into(), json!(t.style.variations));
+ }
+ o.insert("lineHeight".into(), json!(t.style.line_height.label()));
+ o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
+ o.insert("textAlign".into(), json!(t.align_h));
+ o.insert("textAutoResize".into(), json!(t.auto_resize));
+ if t.max_lines > 0 {
+ o.insert("maxLines".into(), json!(t.max_lines));
+ }
+ if let Some(tp) = &t.path {
+ o.insert("textPath".into(), json!(tp));
+ }
+ if t.style.decoration != TextDecoration::None {
+ o.insert("textDecoration".into(), json!(t.style.decoration));
+ }
+ if let Some(l) = t.style.decoration_look.as_deref() {
+ o.insert("textDecorationStyle".into(), json!(l.style));
+ o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
+ o.insert("textDecorationOffset".into(), json!(l.offset.label()));
+ o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
+ o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
+ }
+ // Lists: one style for the layer, or each paragraph's type, nesting level and start.
+ if t.paragraphs.is_empty() {
+ if t.list != figcraft_doc::ListStyle::None {
+ o.insert("listStyle".into(), json!(t.list));
+ }
+ } else {
+ o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
+ o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
+ if t.paragraphs.iter().any(|p| p.start_offset > 0) {
+ o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
+ }
+ }
+ if t.list_spacing != 0.0 {
+ o.insert("listSpacing".into(), json!(t.list_spacing));
+ }
+ for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
+ if on {
+ o.insert(k.into(), json!(true));
+ }
+ }
+ if t.leading_trim != figcraft_doc::LeadingTrim::None {
+ o.insert("leadingTrim".into(), json!(t.leading_trim));
+ }
+ if !t.runs.is_empty() {
+ o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
+ }
+ // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
+ let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
+ if !linked.is_empty() {
+ o.insert("textStyleRanges".into(), Value::Array(linked));
+ }
+}
+
+fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map) {
+ match &n.kind {
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
}
@@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
}
_ => {}
}
- if !n.children.is_empty() {
- if depth > 0 {
- o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
- } else {
- o.insert("childCount".into(), json!(n.children.len()));
- }
- }
- v
}
fn round(v: f64) -> f64 {
diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs
index 753550e5..14760331 100644
--- a/crates/engine/src/boards/board/connectors.rs
+++ b/crates/engine/src/boards/board/connectors.rs
@@ -1,7 +1,10 @@
use super::derived::{ends_now, place_route, route};
use super::edit::set_label;
use super::items::add;
-use super::*;
+use super::{
+ BoardItem, Connector, Document, End, LINE, LINE_WEIGHT, NodeId, NodeKind, Paint, Point, Result, Session, Side, StrokeCap, StrokeJoin, Value,
+ VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string,
+};
/// One connector end from the params: a layer on this page, or a point.
fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result {
diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs
index ac73df1e..9899632f 100644
--- a/crates/engine/src/boards/board/derived.rs
+++ b/crates/engine/src/boards/board/derived.rs
@@ -1,6 +1,9 @@
use super::edit::set_font_size;
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
-use super::*;
+use super::{
+ Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork,
+ is_board,
+};
// ---------------------------------------------------------------------------------------------
// Derived data (the normalize pass).
diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs
index 1770852c..9456256e 100644
--- a/crates/engine/src/boards/board/edit.rs
+++ b/crates/engine/src/boards/board/edit.rs
@@ -1,7 +1,11 @@
use super::connectors::connector_of;
use super::derived::ends_now;
use super::items::{add, add_text, shape_padding, text_of};
-use super::*;
+use super::{
+ Affine, AutoLayout, BTreeMap, BoardItem, BoardShape, Color, Connector, DocError, Document, End, INK, NodeId, NodeKind, Paint, Result, Routing,
+ Session, Sizing, StrokeCap, TextAlignH, TextAutoResize, TextData, Value, VectorNetwork, bad, cap_of, color_param, json, line_shade, num,
+ req_string, req_targets, require, routing_of, shape_of, string,
+};
/// The label of connector `id`, if it has one.
pub fn label_of(doc: &Document, id: NodeId) -> Option {
diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs
index 6d96d34b..b76fc898 100644
--- a/crates/engine/src/boards/board/items.rs
+++ b/crates/engine/src/boards/board/items.rs
@@ -1,4 +1,10 @@
-use super::*;
+use super::{
+ AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
+ HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
+ STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
+ author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
+ textedit,
+};
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
pub(super) fn add(
diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs
index 2dc904c9..cb3fc005 100644
--- a/crates/engine/src/boards/board/tools.rs
+++ b/crates/engine/src/boards/board/tools.rs
@@ -2,7 +2,10 @@ use super::connectors::{end_json, other_end};
use super::derived::ends_now;
use super::edit::label_of;
use super::items::edit_text;
-use super::*;
+use super::{
+ BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
+ is_board, json,
+};
// ---------------------------------------------------------------------------------------------
// The board tools.
diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs
index d5e8b3cf..9e054630 100644
--- a/crates/engine/src/canvas/drop.rs
+++ b/crates/engine/src/canvas/drop.rs
@@ -1,7 +1,7 @@
//! Where something dropped at a page point lands: the frame under the point (or the page) and, in
//! an auto layout frame, the child slot by pointer position plus the insertion line to draw.
-use figcraft_doc::*;
+use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind};
use figcraft_geom::Point;
use serde::Serialize;
diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs
index 99d59b45..74a4a9d1 100644
--- a/crates/engine/src/canvas/shapebuilder.rs
+++ b/crates/engine/src/canvas/shapebuilder.rs
@@ -21,14 +21,14 @@
use std::collections::BTreeSet;
use std::sync::Arc;
-use figcraft_doc::*;
+use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule};
use figcraft_geom::kurbo::PathEl;
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::cmd::edit::{roots, z_sorted};
-use crate::params::*;
+use crate::params::{f64_of, get, string, targets};
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Result, Session, bad, spec};
diff --git a/crates/engine/src/canvas/textpreview.rs b/crates/engine/src/canvas/textpreview.rs
index da101d5e..268b6238 100644
--- a/crates/engine/src/canvas/textpreview.rs
+++ b/crates/engine/src/canvas/textpreview.rs
@@ -1,9 +1,9 @@
-//! Live text previews: what a text command would do, shown on the canvas without an undo step —
-//! while the pointer is over a font family or style, a text style in the style browser or a
-//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes
-//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so
-//! committing (running the command for real) applies to the document itself as one undo step, and
-//! Escape just ends the preview.
+//! Live previews: what a command would do, shown on the canvas without an undo step — while the
+//! pointer is over a font family or style, a text style in the style browser, a font's named
+//! instance or a blend mode, or while a variable font axis slider is dragged. The document under
+//! the preview is kept and comes back when the preview ends ([`end`], `text.endPreview`). Every
+//! other command ends it first, so committing (running the command for real) applies to the
+//! document itself as one undo step, and Escape just ends the preview.
use std::sync::Arc;
@@ -14,8 +14,19 @@ use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
/// The commands that show or end a preview (they don't end the one showing).
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
-/// What can be previewed: text styles, font styles and axis values.
-pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"];
+/// What can be previewed: text styles, font styles and axis values, layers' properties and the
+/// blend of a paint on a layer, a selection or a style.
+pub const PREVIEWABLE: [&str; 9] = [
+ "style.apply",
+ "library.applyStyle",
+ "text.setVariations",
+ "text.style",
+ "node.set",
+ "fill.set",
+ "stroke.set",
+ "selection.setPaint",
+ "style.update",
+];
/// Show `command params` as a live preview (in place of an earlier one).
pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
@@ -39,7 +50,14 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
ds.text_edit = text_edit;
match result {
Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => {
- normalize(ds.doc_mut(), &base);
+ // A panic while normalising shows no preview (#1085).
+ if let Err(e) = guard::run(command, || {
+ normalize(ds.doc_mut(), &base);
+ Ok(())
+ }) {
+ ds.doc = base;
+ return Err(e);
+ }
ds.text_preview = Some(base);
ds.revision += 1;
Ok(())
diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs
index fce14f7c..959dce9c 100644
--- a/crates/engine/src/canvas/tools.rs
+++ b/crates/engine/src/canvas/tools.rs
@@ -6,7 +6,7 @@ use std::collections::BTreeMap;
use figcraft_color::{ImageScaleMode, PaintKind};
use figcraft_doc::geometry::select_target;
-use figcraft_doc::*;
+use figcraft_doc::{Document, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAutoResize, TextData, TextPath, VectorNetwork};
use figcraft_geom::{Affine, Point, Rect, Vec2};
use serde::Serialize;
use serde_json::json;
@@ -422,6 +422,9 @@ pub(crate) enum Gesture {
/// A Shift-press on this selected layer: it leaves the selection on release, unless the
/// press became a drag.
deselect: Option,
+ /// A press on one layer of a multi-selection that keeps the selection to drag it all: a
+ /// release without a drag selects just this layer.
+ keep: Option,
},
Resize {
start: Point,
diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs
index 8f21aaca..a8291f0f 100644
--- a/crates/engine/src/canvas/tools/click.rs
+++ b/crates/engine/src/canvas/tools/click.rs
@@ -1,5 +1,8 @@
use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills};
-use super::*;
+use super::{
+ Affine, Document, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, Sizing, TextAutoResize, TextData, TextPath,
+ ToolId, frame_at, json, select_target, textedit, within_slop,
+};
impl Session {
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs
index 0d406673..97dfc7a5 100644
--- a/crates/engine/src/canvas/tools/drag.rs
+++ b/crates/engine/src/canvas/tools/drag.rs
@@ -2,7 +2,11 @@ use super::handles::{
SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame,
shape_hint,
};
-use super::*;
+use super::{
+ Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
+ PointerEvent, Rect, ResizeStart, Result, ScaleHint, Session, Sizing, Targets, TextAutoResize, ToolId, Vec2, move_world, roots, select_target,
+ textedit, within_slop,
+};
impl Session {
pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> {
@@ -90,6 +94,10 @@ impl Session {
let behind_sel = |t: Option| {
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
};
+ // What the press hit (a label or a painted layer): a click that doesn't become a drag
+ // selects just it. A press that reaches nothing above the selection uses the selection as
+ // its drag target instead; that fallback is not a clicked layer.
+ let clicked = target;
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
@@ -98,13 +106,18 @@ impl Session {
};
match target {
Some(target) => {
- // Clicking inside the current selection's box keeps it (drag them all).
+ // Clicking inside the current selection's box keeps it (drag them all); a click
+ // that doesn't become a drag narrows it to the clicked layer.
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
// Shift on a selected layer takes it out on release if the press doesn't become a
// drag; a drag moves the selection with it. Where no drag can start, it goes now.
let deselect = (ev.shift && sel.contains(&target)).then_some(target);
let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>();
- let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {
+ // A release without a drag narrows the selection to the layer the press hit. A
+ // selected frame's name label sits outside the selection bounds, so it counts too.
+ let keep = clicked
+ .filter(|c| (label.is_some() || in_sel_box) && !ev.cmd && (sel.contains(c) || sel.iter().any(|s| doc.is_ancestor(*s, *c))));
+ let new_sel = if keep.is_some() {
sel.clone()
} else if ev.shift && deselect.is_none() {
let mut s2 = sel.clone();
@@ -116,10 +129,13 @@ impl Session {
vec![target]
};
self.set_selection(new_sel.clone());
- // Dev Mode only selects (Shift-click still deselects): nothing moves.
+ // Dev Mode only selects (Shift-click still deselects): nothing moves. No move
+ // gesture starts, so a click selects the layer it hit now instead of on release.
if !edits {
if deselect.is_some() {
self.set_selection(without_target());
+ } else if let Some(k) = keep.filter(|_| sel.len() > 1) {
+ self.set_selection(vec![k]);
}
return Ok(());
}
@@ -128,6 +144,9 @@ impl Session {
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
if deselect.is_some() {
self.set_selection(without_target());
+ } else if let Some(k) = keep {
+ // No drag can start here, so the click narrows the selection now.
+ self.set_selection(vec![k]);
}
return Ok(());
}
@@ -144,7 +163,7 @@ impl Session {
}
return Err(e);
}
- self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect };
+ self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect, keep };
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
}
None => {
diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs
index b594ae27..4e0a69b9 100644
--- a/crates/engine/src/canvas/tools/draw.rs
+++ b/crates/engine/src/canvas/tools/draw.rs
@@ -1,6 +1,8 @@
use super::handles::SNAP_PX;
use super::pen::take_enclosed;
-use super::*;
+use super::{
+ Affine, Cursor, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, StrokeCap, Targets, ToolId, Vec2, frame_at, textedit,
+};
impl Session {
pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> {
@@ -109,7 +111,7 @@ impl Session {
self.tool.overlay.drop_cell = None;
let drop_target = self.tool.overlay.drop_target.take();
match g {
- Gesture::Move { ids, moved, duplicate, start, deselect, .. } => {
+ Gesture::Move { ids, moved, duplicate, start, deselect, keep, .. } => {
if moved {
// A free move lands in the frame under the pointer (or the page) where it was
// dropped; layers in an auto layout frame already sit in their slot.
@@ -154,6 +156,13 @@ impl Session {
if let Some(d) = deselect {
let sel = self.selection().into_iter().filter(|x| *x != d).collect();
self.set_selection(sel);
+ } else if let Some(k) = keep {
+ // A click (not a drag) on one layer of a multi-selection selects just it.
+ let doc = self.doc()?.clone();
+ let sel = self.selection();
+ if sel.len() > 1 && (sel.contains(&k) || sel.iter().any(|s| doc.is_ancestor(*s, k))) {
+ self.set_selection(vec![k]);
+ }
}
if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) {
self.widget_click(start, ev.tol() * 2.0)?;
diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs
index fd765f66..7ccaca40 100644
--- a/crates/engine/src/canvas/tools/handles.rs
+++ b/crates/engine/src/canvas/tools/handles.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Affine, BTreeMap, Cursor, Document, Handle, NodeId, NodeKind, Point, ShapeHandle, ShapeHint, roots};
/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost
/// one drawn, if it can be selected (still there, on the page, visible and unlocked).
diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs
index 591f1479..a369463c 100644
--- a/crates/engine/src/canvas/tools/keys.rs
+++ b/crates/engine/src/canvas/tools/keys.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
impl Session {
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs
index 319c11b7..4857e83c 100644
--- a/crates/engine/src/canvas/tools/pen.rs
+++ b/crates/engine/src/canvas/tools/pen.rs
@@ -1,5 +1,8 @@
use super::handles::{PENCIL_STROKE, selected_end_at};
-use super::*;
+use super::{
+ Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2,
+ VectorNetwork, at_45_degrees, frame_at, json, pen_handles,
+};
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no
diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs
index 985e3326..550c4a66 100644
--- a/crates/engine/src/canvas/tools/pointer.rs
+++ b/crates/engine/src/canvas/tools/pointer.rs
@@ -1,5 +1,8 @@
use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at};
-use super::*;
+use super::{
+ Cursor, Document, Gesture, NodeId, NodeKind, Overlay, PenCursor, Point, PointerEvent, PointerKind, Rect, Result, Session, ToolId, at_45_degrees,
+ frame_at, json, select_target,
+};
impl Session {
/// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode
diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs
index 03b22d22..fc263b4f 100644
--- a/crates/engine/src/canvas/vectoredit.rs
+++ b/crates/engine/src/canvas/vectoredit.rs
@@ -24,14 +24,14 @@ mod width_sides_tests;
#[cfg(test)]
mod width_tests;
-use figcraft_doc::*;
+use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::{Affine, Point, Rect, Vec2};
use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::cmd::props::enum_of;
-use crate::params::*;
+use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets};
use crate::{EngineError, Result, Session, bad, spec};
pub(crate) use pointer::VGesture;
diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs
index 935f033a..8832e05d 100644
--- a/crates/engine/src/canvas/vectoredit/ops.rs
+++ b/crates/engine/src/canvas/vectoredit/ops.rs
@@ -2,7 +2,7 @@
//! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in
//! `vectoredit.rs` re-fit the node afterwards.
-use figcraft_doc::*;
+use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use figcraft_geom::{Affine, Point, Vec2};
diff --git a/crates/engine/src/canvas/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs
index d977ce87..ebd8e2e3 100644
--- a/crates/engine/src/canvas/vectoredit/pointer.rs
+++ b/crates/engine/src/canvas/vectoredit/pointer.rs
@@ -5,7 +5,7 @@
use std::sync::Arc;
-use figcraft_doc::*;
+use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex};
use figcraft_geom::{Affine, Point, Rect, Vec2};
use super::point_snap;
@@ -510,20 +510,25 @@ impl Session {
Ok(())
}
+ /// Another edited layer under the press (or, with the paint bucket, whose area it is in).
+ fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option {
+ match hit_any(doc, ve, ev.p(), tol) {
+ Some((id, _)) => Some(id),
+ None if ve.tool == VTool::Paint => {
+ let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
+ (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
+ }
+ None => None,
+ }
+ }
+
/// Returns false when the click left edit mode (the caller then runs the normal tool).
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result {
let doc = self.doc()?.clone();
let tol = 6.0 / ev.zoom.max(1e-6);
// A press on another edited layer's point, handle or segment (or, with the paint bucket,
// area) makes it the active layer; a pen drawing a path keeps to its own layer.
- let other = match hit_any(&doc, ve, ev.p(), tol) {
- Some((id, _)) => Some(id),
- None if ve.tool == VTool::Paint => {
- let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
- (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
- }
- None => None,
- };
+ let other = Self::pressed_layer(&doc, ve, ev, tol);
let switched;
let ve = match other {
Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => {
diff --git a/crates/engine/src/canvas/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs
index 5efb5343..0f87a0bd 100644
--- a/crates/engine/src/canvas/vectoredit/transform.rs
+++ b/crates/engine/src/canvas/vectoredit/transform.rs
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position};
use crate::cmd::CommandSpec;
-use crate::params::*;
+use crate::params::{boolean, f64_of, get, num, num_or, req_num};
use crate::tools::{Handle, handle_at_corners};
use crate::{Result, Session, bad, spec};
diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs
index ba51b043..13c7be0c 100644
--- a/crates/engine/src/canvas/vectoredit/width.rs
+++ b/crates/engine/src/canvas/vectoredit/width.rs
@@ -10,7 +10,7 @@
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
//! fractions of each subpath's length).
-use figcraft_doc::*;
+use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide};
use figcraft_geom::{Point, Vec2};
use figcraft_pathops::width::{Polyline, nearest, polylines};
use serde::Serialize;
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
use super::{editing, set_state};
use crate::cmd::CommandSpec;
-use crate::params::*;
+use crate::params::{get, num, req_index, req_num, string};
use crate::tools::{Gesture, PointerEvent};
use crate::{Result, Session, bad, spec};
diff --git a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
index bf2dbeb0..937245ae 100644
--- a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use super::*;
use crate::EngineError;
use crate::tools::{PointerEvent, PointerKind};
+use figcraft_doc::{ExportFormat, WidthSide};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
diff --git a/crates/engine/src/canvas/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs
index 54b4b0e2..8c89d4c6 100644
--- a/crates/engine/src/canvas/vectoredit/width_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/width_tests.rs
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
use super::*;
use crate::EngineError;
use crate::tools::{PointerEvent, PointerKind};
+use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs
index 8f8e40d2..19572c95 100644
--- a/crates/engine/src/cmd/arrange.rs
+++ b/crates/engine/src/cmd/arrange.rs
@@ -1,12 +1,12 @@
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
-use figcraft_doc::*;
+use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle};
use figcraft_geom::{Affine, Point, Rect};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
-use crate::params::*;
+use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string};
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec {
@@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result
\n", open("p")));
}
_ => {
diff --git a/crates/engine/src/dev/codegen/swift.rs b/crates/engine/src/dev/codegen/swift.rs
index a9f8eaaa..9d0dfcf8 100644
--- a/crates/engine/src/dev/codegen/swift.rs
+++ b/crates/engine/src/dev/codegen/swift.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
// ------------------------------------------------------------------ SwiftUI
diff --git a/crates/engine/src/dev/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs
index c227fbdb..2e513cf6 100644
--- a/crates/engine/src/dev/component_codegen.rs
+++ b/crates/engine/src/dev/component_codegen.rs
@@ -7,13 +7,13 @@
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
-use figcraft_doc::*;
+use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
-use crate::params::*;
+use crate::params::{id_of, string, targets};
use crate::{bad, spec};
/// What a component property becomes in code.
diff --git a/crates/engine/src/dev/dev_resources.rs b/crates/engine/src/dev/dev_resources.rs
index 3da391a5..fc4e5b1a 100644
--- a/crates/engine/src/dev/dev_resources.rs
+++ b/crates/engine/src/dev/dev_resources.rs
@@ -7,12 +7,12 @@
//! capped) and never fetched or opened by the engine. A link added without a name is named after
//! its URL (no page title is looked up).
-use figcraft_doc::*;
+use figcraft_doc::{DevResource, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::components::{inside_instance, variant_set};
-use crate::params::*;
+use crate::params::{get, req_index, targets};
use crate::{Result, Session, bad, spec};
/// Most dev resources on one layer.
diff --git a/crates/engine/src/dev/devcompare.rs b/crates/engine/src/dev/devcompare.rs
index ef40a2fb..6804f1aa 100644
--- a/crates/engine/src/dev/devcompare.rs
+++ b/crates/engine/src/dev/devcompare.rs
@@ -7,7 +7,7 @@
use std::collections::BTreeSet;
-use figcraft_doc::*;
+use figcraft_doc::{DevStatus, Document, MAX_SNAPSHOT_NODES, Node, NodeId, NodeKind};
use serde::Serialize;
use crate::devinspect::{Prop, Section, properties};
diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs
index 78aa5553..dbf3ac91 100644
--- a/crates/engine/src/dev/devinspect.rs
+++ b/crates/engine/src/dev/devinspect.rs
@@ -3,12 +3,15 @@
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
use figcraft_color::{Color, GradientKind, Hsb, Hsl, PaintKind};
-use figcraft_doc::*;
+use figcraft_doc::{
+ AutoLayout, ContentAlign, CrossAlign, DevStatus, Document, EffectKind, ExportFormat, ExportSetting, LayoutMode, MainAlign, Node, NodeId,
+ NodeKind, NoiseMode, StrokeAlign, StyleId, TextAlignH, TextData, TextDecoration, Variable,
+};
use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
-use crate::params::*;
+use crate::params::{get, id, id_of, string, targets};
use crate::spec;
/// One group of properties ("Layout", "Typography"…).
diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs
index 38d1a9fb..47c33aa2 100644
--- a/crates/engine/src/devmode_behaviour_tests.rs
+++ b/crates/engine/src/devmode_behaviour_tests.rs
@@ -38,6 +38,19 @@ fn status(s: &mut Session, ids: &[NodeId], st: &str) {
ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}}));
}
+#[test]
+fn a_dev_mode_click_on_a_selected_layer_selects_just_it() {
+ let mut s = Session::new();
+ let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})));
+ let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 80, "y": 0, "width": 40, "height": 40})));
+ s.tool.dev_mode = true;
+ ex(&mut s, "select.set", json!({"ids": [a, b]}));
+ // Dev Mode starts no move gesture, so the click narrows the selection at the press itself.
+ s.pointer(&ev(PointerKind::Down, 20.0, 20.0)).unwrap();
+ s.pointer(&ev(PointerKind::Up, 20.0, 20.0)).unwrap();
+ assert_eq!(s.selection(), vec![a], "a click on one of several selected layers selects it");
+}
+
#[test]
fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
let mut s = Session::new();
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
index 4f9509a7..a9cad9c9 100644
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -1,5 +1,5 @@
use super::view_state::ViewState;
-use super::*;
+use super::{Arc, BTreeSet, Deserialize, Document, NodeId, Serialize, cmd, components, file_access, guides, library, rotation_origin, vectoredit};
/// One undo step.
#[derive(Clone, Debug)]
diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs
index 7f2ab1d4..7f26258f 100644
--- a/crates/engine/src/error.rs
+++ b/crates/engine/src/error.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::NodeId;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs
new file mode 100644
index 00000000..fbc1b9b6
--- /dev/null
+++ b/crates/engine/src/events.rs
@@ -0,0 +1,152 @@
+//! In-memory events for agents watching a live document.
+//!
+//! Comments are the feedback queue: `comment.add`, `reply`, `resolve` and the other mutating
+//! `comment.*` commands append an event. The control channel's `events.poll` long-polls this log,
+//! and the MCP server exposes it as `wait_for_changes`. Nothing here touches the file system, the
+//! document format or the undo history.
+
+use std::collections::VecDeque;
+use std::sync::{Condvar, Mutex, PoisonError};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use serde::Serialize;
+use serde_json::Value;
+
+/// Most events kept; the oldest are dropped.
+pub const MAX_EVENTS: usize = 1024;
+
+/// One change an agent can react to.
+#[derive(Clone, Debug, Serialize)]
+pub struct Event {
+ pub seq: u64,
+ pub time: f64,
+ pub kind: String,
+ pub document: String,
+ pub path: Option,
+ pub data: Value,
+}
+
+/// What `events.poll` returns.
+#[derive(Clone, Debug, Serialize)]
+pub struct PollResult {
+ pub events: Vec,
+ pub cursor: u64,
+ pub reset: bool,
+ pub dropped: u64,
+}
+
+#[derive(Default)]
+struct Log {
+ next: u64,
+ events: VecDeque,
+}
+
+/// A bounded, thread-safe log. The UI thread appends; control-channel workers wait.
+#[derive(Default)]
+pub struct EventLog {
+ log: Mutex,
+ wake: Condvar,
+}
+
+impl EventLog {
+ /// Append one event and wake every waiter.
+ pub fn push(&self, kind: &str, document: &str, path: Option<&str>, data: Value) {
+ let mut log = self.log.lock().unwrap_or_else(PoisonError::into_inner);
+ let seq = log.next.saturating_add(1);
+ log.next = seq;
+ log.events.push_back(Event {
+ seq,
+ time: now(),
+ kind: kind.to_string(),
+ document: document.to_string(),
+ path: path.map(str::to_string),
+ data,
+ });
+ while log.events.len() > MAX_EVENTS {
+ log.events.pop_front();
+ }
+ drop(log);
+ self.wake.notify_all();
+ }
+
+ /// The newest sequence number (0 before the first event).
+ pub fn latest(&self) -> u64 {
+ self.log.lock().unwrap_or_else(PoisonError::into_inner).next
+ }
+
+ /// Events after `after` that match `topics`, or wait up to `timeout` for one. `limit` is clamped
+ /// to 1..=1000. A zero timeout returns at once.
+ ///
+ /// `reset` means `after` is ahead of the log (the app restarted, or the cursor is stale): the
+ /// caller should re-read the comments instead of trusting the cursor. `dropped` counts events
+ /// that were lost because `after` is older than the retained window (0 for a fresh cursor).
+ pub fn poll(&self, after: u64, topics: &[String], limit: usize, timeout: Duration) -> PollResult {
+ let limit = limit.clamp(1, 1000);
+ let deadline = Instant::now() + timeout;
+ let mut log = self.log.lock().unwrap_or_else(PoisonError::into_inner);
+ loop {
+ let latest = log.next;
+ let reset = after > latest;
+ let first = log.events.front().map_or(latest.saturating_add(1), |e| e.seq);
+ let dropped = if after > 0 && after < first.saturating_sub(1) { first.saturating_sub(1) - after } else { 0 };
+ let events: Vec = log.events.iter().filter(|e| e.seq > after && keeps(topics, &e.kind)).take(limit).cloned().collect();
+ if !events.is_empty() || reset || timeout.is_zero() {
+ let cursor = events.last().map_or(if reset { latest } else { after }, |e| e.seq);
+ return PollResult { events, cursor, reset, dropped };
+ }
+ let now = Instant::now();
+ if now >= deadline {
+ return PollResult { events: Vec::new(), cursor: after.min(latest), reset, dropped };
+ }
+ let (guard, _) = self.wake.wait_timeout(log, deadline - now).unwrap_or_else(|e| e.into_inner());
+ log = guard;
+ }
+ }
+}
+
+fn now() -> f64 {
+ SystemTime::now().duration_since(UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64())
+}
+
+/// Whether an event kind matches the requested topics. An empty list (or `all`) keeps everything.
+fn keeps(topics: &[String], kind: &str) -> bool {
+ topics.is_empty()
+ || topics.iter().any(|t| match t.as_str() {
+ "all" => true,
+ "comments" => kind.starts_with("comment."),
+ "document" => kind.starts_with("document."),
+ _ => false,
+ })
+}
+
+/// `events.poll`'s `topics`: default `["comments"]`, `[]` means `all`, and unknown names are errors.
+pub fn topics(v: Option<&Value>) -> Result, String> {
+ let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(vec!["comments".into()]) };
+ let Some(list) = v.as_array() else { return Err("`topics` must be an array of strings".into()) };
+ let mut out = Vec::with_capacity(list.len());
+ for t in list {
+ let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) };
+ match s {
+ "comments" | "document" | "all" => out.push(s.to_string()),
+ other => return Err(format!("unknown topic `{other}` (comments, document, all)")),
+ }
+ }
+ if out.is_empty() {
+ out.push("all".into());
+ }
+ Ok(out)
+}
+
+/// The event a successful mutating `comment.*` command records, if it records one.
+pub fn comment_event_kind(command: &str) -> Option<&'static str> {
+ Some(match command {
+ "comment.add" => "comment.added",
+ "comment.reply" => "comment.replied",
+ "comment.edit" => "comment.edited",
+ "comment.react" => "comment.reacted",
+ "comment.resolve" => "comment.resolved",
+ "comment.move" => "comment.moved",
+ "comment.delete" => "comment.deleted",
+ _ => return None,
+ })
+}
diff --git a/crates/engine/src/events_tests.rs b/crates/engine/src/events_tests.rs
new file mode 100644
index 00000000..36b118a7
--- /dev/null
+++ b/crates/engine/src/events_tests.rs
@@ -0,0 +1,238 @@
+//! The live event log: sequence, topics, cursors, waiting, the retained window, and the events
+//! comment commands record.
+
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use serde_json::json;
+
+use crate::events::{self, EventLog, MAX_EVENTS};
+use crate::{EngineError, Session};
+
+fn comments() -> Vec {
+ vec!["comments".into()]
+}
+
+#[test]
+fn events_keep_sequence_and_filter_topics() {
+ let log = EventLog::default();
+ assert_eq!(log.latest(), 0);
+ log.push("comment.added", "Doc", None, json!({"commentId": 1}));
+ log.push("document.changed", "Doc", None, json!({}));
+ log.push("comment.resolved", "Doc", None, json!({"commentId": 1}));
+ assert_eq!(log.latest(), 3);
+
+ let r = log.poll(0, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 2);
+ assert_eq!(r.cursor, 3);
+ assert!(!r.reset && r.dropped == 0);
+ assert_eq!(r.events[0].kind, "comment.added");
+
+ let r = log.poll(2, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].seq, 3);
+
+ let r = log.poll(3, &comments(), 10, Duration::ZERO);
+ assert!(r.events.is_empty() && r.cursor == 3 && !r.reset);
+
+ // A cursor ahead of the log means the app restarted.
+ let r = log.poll(4, &comments(), 10, Duration::ZERO);
+ assert!(r.reset && r.events.is_empty() && r.cursor == 3);
+
+ let r = log.poll(0, &["document".into()], 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].kind, "document.changed");
+}
+
+#[test]
+fn a_poll_wakes_for_a_push() {
+ let log = Arc::new(EventLog::default());
+ let writer = log.clone();
+ let t = std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(30));
+ writer.push("comment.added", "Doc", None, json!({"commentId": 7}));
+ });
+ let start = Instant::now();
+ let r = log.poll(0, &comments(), 10, Duration::from_secs(2));
+ t.join().unwrap();
+ assert_eq!(r.events.len(), 1);
+ assert!(start.elapsed() < Duration::from_secs(2));
+}
+
+#[test]
+fn the_retained_window_reports_what_it_missed() {
+ let log = EventLog::default();
+ for i in 0..MAX_EVENTS + 5 {
+ log.push("comment.added", "Doc", None, json!({"commentId": i}));
+ }
+ let r = log.poll(0, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 10);
+ let r = log.poll(1, &comments(), 1000, Duration::ZERO);
+ assert_eq!(r.events.len(), 1000);
+ assert_eq!(r.dropped, 4);
+ assert_eq!(r.events[0].seq, 6);
+}
+
+#[test]
+fn comment_commands_record_events() {
+ let mut s = Session::new();
+ let add = s.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix", "author": "You"})).unwrap();
+ let id = add["id"].as_u64().unwrap();
+ let r = s.events.poll(0, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].kind, "comment.added");
+ assert_eq!(r.events[0].data["commentId"], id);
+ assert_eq!(r.events[0].document, "Untitled");
+
+ s.execute("comment.reply", &json!({"id": id, "text": "Agreed", "author": "Sam"})).unwrap();
+ s.execute("comment.resolve", &json!({"id": id})).unwrap();
+ let r = s.events.poll(1, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 2);
+ assert_eq!(r.events[0].kind, "comment.replied");
+ assert_eq!(r.events[0].data["reply"], 0, "the reply's index, from the command's result");
+ assert_eq!(r.events[1].kind, "comment.resolved");
+ assert_eq!(r.events[1].data["commentId"], id);
+
+ // Queries and read state are not feedback changes.
+ let before = s.events.latest();
+ s.execute("comment.markRead", &json!({"id": id})).unwrap();
+ s.execute("comment.list", &json!({})).unwrap();
+ s.execute("comment.prompt", &json!({})).unwrap();
+ assert_eq!(s.events.latest(), before);
+
+ s.execute("comment.delete", &json!({"id": id})).unwrap();
+ let r = s.events.poll(before, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].kind, "comment.deleted");
+}
+
+#[test]
+fn a_failed_batch_reports_no_comment_events() {
+ let mut s = Session::new();
+ let before = s.events.latest();
+ let r = s.execute(
+ "command.batch",
+ &json!({"commands": [
+ {"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Rolled back"}},
+ {"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}}
+ ]}),
+ );
+ assert!(r.is_err());
+ assert_eq!(s.events.latest(), before);
+ assert!(s.doc().unwrap().comments.is_empty());
+
+ let r = s.execute(
+ "command.batch",
+ &json!({"commands": [
+ {"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}},
+ {"command": "node.create", "params": {"type": "rectangle", "width": 10, "height": 10}}
+ ]}),
+ );
+ assert!(r.is_ok());
+ let r = s.events.poll(before, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].kind, "comment.added");
+}
+
+#[test]
+fn every_comment_mutation_records_its_kind() {
+ let mut s = Session::new();
+ let add = s.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix", "author": "You"})).unwrap();
+ let id = add["id"].as_u64().unwrap();
+ s.execute("comment.reply", &json!({"id": id, "text": "On it", "author": "Sam"})).unwrap();
+ s.execute("comment.edit", &json!({"id": id, "text": "Fix the padding", "author": "You"})).unwrap();
+ s.execute("comment.react", &json!({"id": id, "emoji": "👍", "author": "Sam"})).unwrap();
+ s.execute("comment.move", &json!({"id": id, "x": 30, "y": 40})).unwrap();
+ s.execute("comment.resolve", &json!({"id": id})).unwrap();
+ s.execute("comment.delete", &json!({"id": id})).unwrap();
+ let r = s.events.poll(0, &comments(), 20, Duration::ZERO);
+ let kinds: Vec<&str> = r.events.iter().map(|e| e.kind.as_str()).collect();
+ assert_eq!(
+ kinds,
+ ["comment.added", "comment.replied", "comment.edited", "comment.reacted", "comment.moved", "comment.resolved", "comment.deleted"]
+ );
+ assert!(r.events.iter().all(|e| e.data["commentId"] == id), "{:?}", r.events);
+ assert_eq!(r.events[1].data["reply"], 0, "the reply index");
+}
+
+#[test]
+fn nested_groups_commit_events_only_when_the_outer_group_succeeds() {
+ let mut s = Session::new();
+ let r: crate::Result<()> = s.grouped("outer", |s| {
+ s.grouped("inner", |s| s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"})).map(|_| ()))?;
+ Err(EngineError::Other("roll back the outer group".into()))
+ });
+ assert!(r.is_err());
+ assert_eq!(s.events.latest(), 0);
+ assert!(s.doc().unwrap().comments.is_empty());
+
+ let r = s.grouped("outer", |s| {
+ s.grouped("inner", |s| s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "One"})).map(|_| ()))?;
+ s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Two"})).map(|_| ())
+ });
+ assert!(r.is_ok());
+ assert_eq!(s.doc().unwrap().comments.len(), 2);
+ let r = s.events.poll(0, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 2);
+ assert!(r.events.iter().all(|e| e.kind == "comment.added"));
+}
+
+#[test]
+fn ordinary_edits_do_not_emit_comment_events() {
+ let mut s = Session::new();
+ let id = s.execute("node.create", &json!({"type": "rectangle", "width": 10, "height": 10})).unwrap()["id"].clone();
+ s.execute("node.set", &json!({"ids": [id], "props": {"name": "Renamed"}})).unwrap();
+ let r = s.events.poll(0, &comments(), 10, Duration::ZERO);
+ assert!(r.events.is_empty());
+ assert_eq!(s.events.latest(), 0);
+}
+
+#[test]
+fn topics_parse_and_reject_unknown_names() {
+ assert_eq!(events::topics(None).unwrap(), vec!["comments".to_string()]);
+ assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]);
+ assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
+ assert!(events::topics(Some(&json!(["nope"]))).is_err());
+ assert!(events::topics(Some(&json!("comments"))).is_err());
+}
+
+#[test]
+fn every_waiter_is_woken_by_a_push() {
+ let log = Arc::new(EventLog::default());
+ let readers: Vec<_> = (0..2)
+ .map(|_| {
+ let l = log.clone();
+ std::thread::spawn(move || l.poll(0, &comments(), 10, Duration::from_secs(2)))
+ })
+ .collect();
+ std::thread::sleep(Duration::from_millis(50));
+ log.push("comment.added", "Doc", None, json!({"commentId": 9}));
+ for reader in readers {
+ let r = reader.join().unwrap();
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].data["commentId"], 9);
+ }
+}
+
+#[test]
+fn restoring_after_an_internal_error_keeps_the_outer_group_context() {
+ let mut s = Session::new();
+ s.grouped("outer", |s| {
+ s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Before"})).map(|_| ())?;
+ let depth = s.group_depth;
+ let pending = s.pending_events.len();
+ assert_eq!((depth, pending), (1, 1));
+ // A failing inner command appends its own event; the restore must keep the group's
+ // earlier event and remove only what the failed command added.
+ s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "During"})).map(|_| ())?;
+ assert_eq!(s.pending_events.len(), 2);
+ s.restore_after_internal_error(depth, pending);
+ assert_eq!((s.group_depth, s.pending_events.len()), (depth, pending));
+ Ok(())
+ })
+ .unwrap();
+ // The outer group's success still commits its buffered event.
+ let r = s.events.poll(0, &comments(), 10, Duration::ZERO);
+ assert_eq!(r.events.len(), 1);
+ assert_eq!(r.events[0].kind, "comment.added");
+}
diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs
index 3da9522f..9a4143ad 100644
--- a/crates/engine/src/frame_label_tests.rs
+++ b/crates/engine/src/frame_label_tests.rs
@@ -62,6 +62,21 @@ fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() {
assert!(s.selection().is_empty());
}
+#[test]
+fn a_click_on_a_selected_frames_label_selects_only_it() {
+ let (mut s, f, q) = setup();
+ let other = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 40, "height": 40}))["id"]).unwrap();
+ ex(&mut s, "select.set", json!({"ids": [f, other]}));
+ // A point on the label's left edge, clear of the selection box's resize handles, lies outside
+ // the selection's bounds (the strip starts left of the frame's left edge there).
+ let p = q[0] + (q[3] - q[0]) * 0.2 + (q[1] - q[0]) * 0.02;
+ assert!(s.doc().unwrap().bounds_of(&[f, other]).is_some_and(|b| !b.contains(p)), "the label point is outside the selection bounds");
+ assert_eq!(label_at(s.doc().unwrap(), s.page().unwrap(), &s.tool.labels, p), Some(f), "the point is on the label");
+ s.pointer(&ev(PointerKind::Down, p)).unwrap();
+ s.pointer(&ev(PointerKind::Up, p)).unwrap();
+ assert_eq!(s.selection(), vec![f], "the label outside the bounds still narrows the selection to its frame");
+}
+
#[test]
fn labels_of_frames_that_cant_be_picked_do_nothing() {
let (mut s, f, q) = setup();
diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs
index f624ecfb..bdf57c9d 100644
--- a/crates/engine/src/keymap_tests.rs
+++ b/crates/engine/src/keymap_tests.rs
@@ -95,6 +95,11 @@ fn key_names_normalise() {
#[test]
fn lookups_follow_the_platform_and_context() {
+ lookups_on_every_platform();
+ lookups_that_differ_by_platform();
+}
+
+fn lookups_on_every_platform() {
let none = Mods::default();
let shift = mods(true, false, false, false);
for p in PLATFORMS {
@@ -119,6 +124,9 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(p, Context::Text, "V", none), None);
assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None);
}
+}
+
+fn lookups_that_differ_by_platform() {
let mac = Platform::Mac;
let other = Platform::Other;
// Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere.
@@ -133,6 +141,10 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter"));
+ macos_tells_ctrl_from_cmd(mac, other);
+}
+
+fn macos_tells_ctrl_from_cmd(mac: Platform, other: Platform) {
// macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies.
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids"));
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group"));
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ea135ca1..0db81fd1 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -17,6 +17,7 @@ pub mod catalog;
pub mod cmd;
mod content;
mod dev;
+pub mod events;
mod extensions;
pub mod fonts;
pub mod guard;
@@ -143,6 +144,10 @@ pub struct Session {
pub live: live::Lives,
/// Commands the UI runs without waiting on a server ([`Session::start_background`]).
pub background: background::Background,
+ /// Live agent events (comment changes), shared with the control channel's `events.poll`.
+ pub events: Arc,
+ /// Events recorded inside a group: (kind, document, path, data).
+ pending_events: Vec<(String, String, Option, Value)>,
}
impl Default for Session {
@@ -254,6 +259,8 @@ mod duplicate_spacing_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
+mod events_tests;
+#[cfg(test)]
mod export_behaviour_tests;
#[cfg(test)]
mod export_bench_tests;
@@ -346,6 +353,8 @@ mod nested_override_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
+mod normalize_guard_tests;
+#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
@@ -424,6 +433,8 @@ mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
#[cfg(test)]
+mod scaled_instance_tests;
+#[cfg(test)]
mod section_contents_tests;
#[cfg(test)]
mod section_tests;
diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs
index b4a4171a..f61bab29 100644
--- a/crates/engine/src/library_variables_tests.rs
+++ b/crates/engine/src/library_variables_tests.rs
@@ -122,6 +122,65 @@ fn publishing_collections_hides_and_versions_variables() {
assert!(empty.execute("library.publish", &json!({})).is_err());
}
+/// `bind_alias_mode_read_only_and_updates`: every edit of a copy's variable or collection is refused.
+fn assert_copies_read_only(s: &mut Session, local: &str) {
+ let coll = copy_of(s, "primary").collection;
+ for (cmd, p) in [
+ ("variable.set", json!({"variable": local, "value": "#ff0000"})),
+ ("variable.update", json!({"variable": local, "name": "mine"})),
+ ("variable.delete", json!({"variable": local})),
+ ("variable.duplicate", json!({"variable": local})),
+ ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
+ ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
+ ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
+ ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
+ ("variable.deleteCollection", json!({"collection": coll})),
+ ] {
+ let e = s.execute(cmd, &p).unwrap_err();
+ assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
+ }
+}
+
+/// `bind_alias_mode_read_only_and_updates`: saved and reopened, the file renders without the library.
+fn reopened_without_library(s: &mut Session, d: &std::path::Path, lib_path: &str, lid: &str, outer: NodeId) {
+ // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
+ let user_path = path_str(&d.join("app.figcraft"));
+ ex(s, "file.saveAs", json!({"path": user_path}));
+ std::fs::remove_file(lib_path).unwrap();
+ let mut t = Session::empty();
+ ex(&mut t, "file.open", json!({"path": user_path}));
+ assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
+ assert_eq!(fill(&t, outer), "FF0000");
+ assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+}
+
+/// `bind_alias_mode_read_only_and_updates`: the library changes its variables; the copies, bindings and alias follow.
+fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inner, outer]: [NodeId; 2]) {
+ let modes = Default::default();
+ // The library changes its variables and publishes again.
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
+ ex(lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
+ ex(lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
+ republish(lib);
+ let u = ex(s, "library.updates", json!({}));
+ assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
+ assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
+ let r = ex(s, "library.acceptUpdates", json!({}));
+ assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
+ // Same copies (same ids), new values: the bound layers and the alias follow.
+ assert_eq!(copy_of(s, "primary").id, local);
+ assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into()));
+ assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
+ assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
+ assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
+ // Undo takes the update back.
+ ex(s, "edit.undo", json!({}));
+ assert_eq!(fill(s, outer), "0000FF");
+ ex(s, "edit.redo", json!({}));
+ healthy(s);
+}
+
#[test]
fn bind_alias_mode_read_only_and_updates() {
let d = dir("flow");
@@ -180,57 +239,14 @@ fn bind_alias_mode_read_only_and_updates() {
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only");
// The copies are read-only.
- let coll = copy_of(&s, "primary").collection;
- for (cmd, p) in [
- ("variable.set", json!({"variable": local, "value": "#ff0000"})),
- ("variable.update", json!({"variable": local, "name": "mine"})),
- ("variable.delete", json!({"variable": local})),
- ("variable.duplicate", json!({"variable": local})),
- ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
- ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
- ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
- ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
- ("variable.deleteCollection", json!({"collection": coll})),
- ] {
- let e = s.execute(cmd, &p).unwrap_err();
- assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
- }
+ assert_copies_read_only(&mut s, &local);
// Local edits still work.
ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3}));
healthy(&s);
- // The library changes its variables and publishes again.
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
- ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
- ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
- republish(&mut lib);
- let u = ex(&mut s, "library.updates", json!({}));
- assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
- assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
- let r = ex(&mut s, "library.acceptUpdates", json!({}));
- assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
- // Same copies (same ids), new values: the bound layers and the alias follow.
- assert_eq!(copy_of(&s, "primary").id, local);
- assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into()));
- assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
- assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
- assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
- // Undo takes the update back.
- ex(&mut s, "edit.undo", json!({}));
- assert_eq!(fill(&s, outer), "0000FF");
- ex(&mut s, "edit.redo", json!({}));
- healthy(&s);
+ library_update_follows(&mut s, &mut lib, &local, [inner, outer]);
- // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
- let user_path = path_str(&d.join("app.figcraft"));
- ex(&mut s, "file.saveAs", json!({"path": user_path}));
- std::fs::remove_file(&lib_path).unwrap();
- let mut t = Session::empty();
- ex(&mut t, "file.open", json!({"path": user_path}));
- assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
- assert_eq!(fill(&t, outer), "FF0000");
- assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+ reopened_without_library(&mut s, &d, &lib_path, &lid, outer);
// Removing the library keeps the copies in use and drops the rest.
ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null}));
diff --git a/crates/engine/src/live_docs_tests.rs b/crates/engine/src/live_docs_tests.rs
index 7380cc9b..df805266 100644
--- a/crates/engine/src/live_docs_tests.rs
+++ b/crates/engine/src/live_docs_tests.rs
@@ -62,7 +62,6 @@ fn each_document_has_its_own_session() {
let ua = uid(&s);
// A new document isn't in the first one's session.
run(&mut s, "file.new", json!({}));
- let ub = uid(&s);
let off = run(&mut s, "collab.peers", json!({}));
for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] {
assert_eq!(off[key], Value::Null, "{key}: {off}");
@@ -78,8 +77,12 @@ fn each_document_has_its_own_session() {
assert!(s.live_mut().is_none() && s.active_read_only().is_none());
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here");
assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped");
+ hosting_a_second_session(&mut s, &script, &a, ua);
+}
+
+fn hosting_a_second_session(s: &mut Session, script: &Arc>, a: &Value, ua: u64) {
// Hosting it too starts a second session; the first goes on.
- let b = run(&mut s, "collab.host", json!({}));
+ let b = run(s, "collab.host", json!({}));
assert_ne!(a["session"], b["session"]);
assert_ne!(a["link"], b["link"]);
assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2));
@@ -87,33 +90,38 @@ fn each_document_has_its_own_session() {
let sc = script.lock().unwrap();
assert_eq!((sc.hosted.len(), sc.left), (2, 0));
}
+ people_come_and_go(s, script, a, &b, ua);
+}
+
+fn people_come_and_go(s: &mut Session, script: &Arc>, a: &Value, b: &Value, ua: u64) {
+ let ub = uid(s);
// People come and go in their own session.
- let (a_id, b_id) = (id_of(&a), id_of(&b));
- feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
+ let (a_id, b_id) = (id_of(a), id_of(b));
+ feed(s, script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2));
let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::>();
let p = s.live_summary();
assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"]));
assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2)));
// The file in front decides what `collab.peers` and the commands are about.
- run(&mut s, "doc.switch", json!({"index": 0}));
- let p = run(&mut s, "collab.peers", json!({}));
+ run(s, "doc.switch", json!({"index": 0}));
+ let p = run(s, "collab.peers", json!({}));
assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"]));
- assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5);
+ assert_eq!(run(s, "collab.follow", json!({"client": 5}))["follow"], 5);
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session");
- run(&mut s, "doc.switch", json!({"index": 1}));
+ run(s, "doc.switch", json!({"index": 1}));
assert!(s.execute("collab.follow", &json!({"client": 5})).is_err());
assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5)));
// Stopping ends this file's session only.
- assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true);
+ assert_eq!(run(s, "collab.stop", json!({}))["ended"], true);
assert_eq!(script.lock().unwrap().ended, [b_id]);
assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live));
// Someone ending or leaving the other one doesn't bring this one back.
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped");
// Closing a file ends its session.
- run(&mut s, "file.close", json!({"index": 0, "force": true}));
+ run(s, "file.close", json!({"index": 0, "force": true}));
s.live_sync();
assert_eq!(script.lock().unwrap().ended, [b_id, a_id]);
assert_eq!(s.live.iter().count(), 0);
diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs
index f486250f..452eb3e8 100644
--- a/crates/engine/src/live_share_tests.rs
+++ b/crates/engine/src/live_share_tests.rs
@@ -185,6 +185,38 @@ fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
assert_eq!(old.publish_server, DEFAULT_SERVER);
}
+/// The middle of the online-sharing test: re-asking switches codes, and an offline stop stops nothing.
+fn asking_again_and_offline_stop(s: &mut Session, script: &Arc>) {
+ // Asking again only switches which code is the one to give out.
+ let r = ex(s, "collab.share", json!({"access": "view"}));
+ assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
+ assert_eq!(ex(s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
+ assert_eq!(ex(s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
+ assert_eq!(script.lock().unwrap().published.len(), 1);
+ assert!(err(s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
+ assert!(err(s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
+ // Stopping while the server can't be reached stops nothing, and says so.
+ script.lock().unwrap().offline = true;
+ assert!(err(s, "collab.stop", json!({})).contains("couldn't stop sharing"));
+ assert_eq!(ex(s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
+ script.lock().unwrap().offline = false;
+}
+
+/// The sharing-again step of the online-sharing test: new links and codes, nothing published twice.
+fn sharing_again(s: &mut Session, script: &Arc>) {
+ // Sharing again: new links and codes for the same file, nothing published twice.
+ let r = ex(s, "collab.share", json!({}));
+ assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
+ assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
+ {
+ let sc = script.lock().unwrap();
+ assert_eq!(sc.published.len(), 1);
+ assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
+ }
+ assert_eq!(ex(s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
+ assert_eq!(script.lock().unwrap().asked.len(), 2);
+}
+
#[test]
fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (mut s, script) = session();
@@ -212,19 +244,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap());
assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}");
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
- // Asking again only switches which code is the one to give out.
- let r = ex(&mut s, "collab.share", json!({"access": "view"}));
- assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
- assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
- assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
- assert_eq!(script.lock().unwrap().published.len(), 1);
- assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
- assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
- // Stopping while the server can't be reached stops nothing, and says so.
- script.lock().unwrap().offline = true;
- assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing"));
- assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
- script.lock().unwrap().offline = false;
+ asking_again_and_offline_stop(&mut s, &script);
// Stopping: the server is asked, as the file's owner, to revoke the others' links. The file
// stays there and its owner stays in it: nothing is left, nothing is removed.
assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
@@ -248,17 +268,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
);
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
- // Sharing again: new links and codes for the same file, nothing published twice.
- let r = ex(&mut s, "collab.share", json!({}));
- assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
- assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
- {
- let sc = script.lock().unwrap();
- assert_eq!(sc.published.len(), 1);
- assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
- }
- assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
- assert_eq!(script.lock().unwrap().asked.len(), 2);
+ sharing_again(&mut s, &script);
// Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves
// its session like any other (the file stays on the server).
ex(&mut s, "collab.stop", json!({}));
diff --git a/crates/engine/src/normalize_guard_tests.rs b/crates/engine/src/normalize_guard_tests.rs
new file mode 100644
index 00000000..c4ad7c08
--- /dev/null
+++ b/crates/engine/src/normalize_guard_tests.rs
@@ -0,0 +1,72 @@
+//! A panic while normalising what a command changed is caught like one in the command itself
+//! (#1085): the command fails with `EngineError::Internal`, the document is as it was, no undo step
+//! is pushed, and the session keeps working.
+
+use serde_json::json;
+
+use crate::{EngineError, Session, derived};
+
+/// Run `f` with normalisation panicking.
+fn panicking(f: impl FnOnce() -> T) -> T {
+ derived::PANIC.with(|p| p.set(true));
+ let out = f();
+ derived::PANIC.with(|p| p.set(false));
+ out
+}
+
+/// The layer id a creating command returned.
+fn id(v: &serde_json::Value) -> u64 {
+ v["id"].as_u64().unwrap()
+}
+
+/// A command whose edit panics while normalising fails with `EngineError::Internal`, leaves the
+/// document and the undo history as they were, and the same command then runs as usual.
+#[test]
+fn a_panic_while_normalising_rolls_the_command_back() {
+ let mut s = Session::new();
+ let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
+ let before = s.doc().unwrap().clone();
+ let undo = s.undo_depth();
+ let result = panicking(|| s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})));
+ assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before, "the edit is rolled back");
+ assert_eq!(s.undo_depth(), undo, "no undo step for the failed command");
+ // The next command runs as usual.
+ s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})).unwrap();
+ assert_eq!(s.doc().unwrap().node(figcraft_doc::NodeId(r)).unwrap().name, "Renamed");
+ assert_eq!(s.undo_depth().0, undo.0 + 1);
+}
+
+/// The step fails like any other failed step: the batch's group restores what it set aside for
+/// its steps (the history limit, the group depth), which a panic unwinding through it skipped.
+#[test]
+fn a_panic_while_normalising_a_batch_step_rolls_the_batch_back() {
+ let mut s = Session::new();
+ let before = s.doc().unwrap().clone();
+ let undo = s.undo_depth();
+ let limit = s.ds().unwrap().history.limit;
+ let result = panicking(|| {
+ s.execute("command.batch", &json!({"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}}]}))
+ });
+ assert!(result.is_err(), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before);
+ assert_eq!(s.undo_depth(), undo);
+ assert_eq!(s.ds().unwrap().history.limit, limit, "the undo limit is back");
+ assert_eq!(s.group_depth, 0, "the group ended");
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+}
+
+/// A live text preview whose normalising panics fails and shows nothing: the document is as it
+/// was, and the same preview then shows.
+#[test]
+fn a_panic_while_normalising_a_text_preview_shows_none() {
+ let mut s = Session::new();
+ let t = id(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "Preview"})).unwrap());
+ let before = s.doc().unwrap().clone();
+ let result = panicking(|| s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})));
+ assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before);
+ assert!(!crate::textpreview::active(&s), "no preview shows");
+ s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})).unwrap();
+ assert!(crate::textpreview::active(&s));
+}
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index 6bfedcba..74913df6 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, BTreeMap, Deserialize, Document, NodeId, Serialize, Value, cmd, live, online, plugins};
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
@@ -82,8 +82,10 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
- /// A prototype's Open link goes to a website without first telling the viewer that it leaves
- /// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again).
+ /// A prototype's Open link — to a website or to the mail app — that the viewer's own click,
+ /// press, drag or key fired opens without first telling them that it leaves the prototype
+ /// (the notice's "Don't show this again"; `proto.linkNotice` shows it again). A link a delay
+ /// or the pointer passing over a layer fired still shows the notice (#1096).
pub link_notice_skipped: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
@@ -164,6 +166,10 @@ pub struct Clipboard {
pub paint: Option<(bool, figcraft_color::Paint)>,
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
pub stroke: Option,
+ /// The image data the copied paint names, carried so a paste into another file has it.
+ pub paint_images: std::collections::BTreeMap,
+ /// The file the copied paint came from: a pattern's node id only means anything there.
+ pub paint_source_doc: u64,
/// A copied effect row (`effect.copy`).
pub effect: Option,
/// The page-space bounds of the copied selection.
diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs
index df639034..160dfaaf 100644
--- a/crates/engine/src/property_copy_tests.rs
+++ b/crates/engine/src/property_copy_tests.rs
@@ -103,6 +103,99 @@ fn pasting_design_layers_keeps_a_copied_row() {
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
}
+#[test]
+fn a_copied_image_fill_brings_its_image_to_another_file() {
+ let mut s = Session::new();
+ let a = rect(&mut s);
+ ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}}));
+ let data = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 };
+ s.doc_mut().unwrap().images.insert("img-key".into(), data.clone());
+ ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
+ // Another file has no image under that key: the copy brings the data with it.
+ ex(&mut s, "file.new", json!({}));
+ let b = rect(&mut s);
+ assert!(!s.doc().unwrap().images.contains_key("img-key"));
+ ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
+ assert_eq!(s.doc().unwrap().images.get("img-key"), Some(&data));
+ assert!(matches!(node(&s, b).fills[0].kind, figcraft_color::PaintKind::Image(_)));
+}
+
+#[test]
+fn a_copied_image_key_taken_in_another_file_gets_the_copied_data() {
+ let mut s = Session::new();
+ let a = rect(&mut s);
+ let copied = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 };
+ let taken = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![9, 9, 9]), width: 3, height: 3 };
+ ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}}));
+ s.doc_mut().unwrap().images.insert("img-key".into(), copied.clone());
+ ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
+ // The other file already uses that key for different data: the pasted paint takes a content
+ // key for the copied data instead, and the destination's image stays as it is.
+ ex(&mut s, "file.new", json!({}));
+ let b = rect(&mut s);
+ s.doc_mut().unwrap().images.insert("img-key".into(), taken.clone());
+ ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
+ let doc = s.doc().unwrap();
+ let fill = &doc.node(b).unwrap().fills[0];
+ let figcraft_color::PaintKind::Image(img) = &fill.kind else { panic!("the pasted fill is an image") };
+ assert_ne!(img.image, "img-key");
+ assert_eq!(doc.images.get(&img.image), Some(&copied));
+ assert_eq!(doc.images.get("img-key"), Some(&taken), "the destination's image stays");
+}
+
+#[test]
+fn a_copied_image_whose_hash_key_is_also_taken_gets_a_free_key() {
+ let mut s = Session::new();
+ let a = rect(&mut s);
+ let bytes = vec![1, 2, 3];
+ let copied = figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes.clone()), width: 2, height: 2 };
+ let taken = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![9, 9, 9]), width: 3, height: 3 };
+ // Images are usually keyed by their content hash: here the copied key is that hash, and the
+ // other file already uses it for other data, so the replacement key is taken as well.
+ let hash = figcraft_format::image_hash(&bytes);
+ ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": hash}}));
+ s.doc_mut().unwrap().images.insert(hash.clone(), copied.clone());
+ ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
+ ex(&mut s, "file.new", json!({}));
+ let b = rect(&mut s);
+ s.doc_mut().unwrap().images.insert(hash.clone(), taken.clone());
+ ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
+ let doc = s.doc().unwrap();
+ let figcraft_color::PaintKind::Image(img) = &doc.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") };
+ assert_ne!(img.image, hash, "the taken hash is not reused");
+ assert_eq!(doc.images.get(&img.image), Some(&copied), "the pasted paint points at the copied data");
+ assert_eq!(doc.images.get(&hash), Some(&taken), "the destination's image stays");
+ let key = img.image.clone();
+ // An image key names an archive member, and only letters and digits are read back: the
+ // numbered key must bring the copied image through a save and reopen.
+ assert!(key.bytes().all(|b| b.is_ascii_alphanumeric()), "the key is archive-valid: {key}");
+ let dir = std::env::temp_dir().join(format!("figcraft-image-key-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("pasted.figcraft").to_string_lossy().to_string();
+ ex(&mut s, "file.save", json!({"path": path}));
+ let mut t = Session::empty();
+ ex(&mut t, "file.open", json!({"path": path}));
+ let saved = t.doc().unwrap();
+ let figcraft_color::PaintKind::Image(img) = &saved.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") };
+ assert_eq!(img.image, key, "the reloaded paint keeps its key");
+ // A reopened image's size is read from its pixels on demand, so only its bytes are compared.
+ assert_eq!(saved.images.get(&key).map(|i| i.bytes.as_slice()), Some(copied.bytes.as_slice()), "the copied image survives the reload");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn a_copied_pattern_does_not_paste_into_another_file() {
+ let mut s = Session::new();
+ let source = rect(&mut s);
+ let patterned = rect(&mut s);
+ ex(&mut s, "fill.pattern", json!({"ids": [patterned], "source": source}));
+ ex(&mut s, "fill.copy", json!({"id": patterned}));
+ // Its node id means nothing in another file, where it may name an unrelated layer.
+ ex(&mut s, "file.new", json!({}));
+ let b = rect(&mut s);
+ assert!(s.execute("fill.paste", &json!({"ids": [b], "index": 0})).is_err(), "a pattern stays in its file");
+}
+
#[test]
fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() {
let mut s = Session::new();
diff --git a/crates/engine/src/proto_conditional_tests.rs b/crates/engine/src/proto_conditional_tests.rs
index e40aea5d..b5441262 100644
--- a/crates/engine/src/proto_conditional_tests.rs
+++ b/crates/engine/src/proto_conditional_tests.rs
@@ -71,41 +71,51 @@ fn build_if_else_if_else_with_nested_actions() {
assert_eq!(shown(&s, bl[1].condition.as_ref().unwrap()), "open == false");
assert_eq!(shown(&s, bl[2].condition.as_ref().unwrap()), "label == \"Hi\"");
assert!(bl[3].condition.is_none(), "the else stays last");
+ actions_in_the_branches(&mut s, b, btn);
+ reorder_replace_and_remove(&mut s, b, btn);
+}
+
+fn actions_in_the_branches(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Actions inside the branches: a set variable with an expression in the if, a back in the else.
let r = ex(
- &mut s,
+ s,
"proto.action.add",
json!({"id": btn, "index": 0, "path": [1, 0, 0], "action": {"type": "setVariable", "variable": "count", "expression": "count + 1"}}),
);
assert_eq!(r["path"], json!([1, 0, 0]));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
- let list = actions(&s, btn);
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
+ let list = actions(s, btn);
let Some(Action::SetVariable { variable, expr: Some(e), .. }) = action_at(&list, &[1, 0, 0]) else { panic!("{list:?}") };
- assert_eq!(variable, &var(&s, "count"));
- assert_eq!(shown(&s, e), "count + 1");
+ assert_eq!(variable, &var(s, "count"));
+ assert_eq!(shown(s, e), "count + 1");
assert_eq!(action_at(&list, &[1, 3, 0]), Some(&Action::Back));
+}
+
+fn reorder_replace_and_remove(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Reorder inside the else, replace one, change a condition, remove a block.
- assert_eq!(ex(&mut s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
- assert!(matches!(action_at(&actions(&s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
- ex(&mut s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
- assert_eq!(action_at(&actions(&s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
- ex(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
- assert_eq!(shown(&s, blocks(&actions(&s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
- ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
- assert_eq!(blocks(&actions(&s, btn)[1]).len(), 3);
- ex(&mut s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(ex(s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
+ assert!(matches!(action_at(&actions(s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
+ ex(s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
+ assert_eq!(action_at(&actions(s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
+ ex(s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
+ assert_eq!(shown(s, blocks(&actions(s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
+ ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
+ assert_eq!(blocks(&actions(s, btn)[1]).len(), 3);
+ ex(s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
// Every edit is one undo step.
assert!(s.undo().unwrap());
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions.len(), 2);
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions.len(), 2);
// Removing the if leaves the else if as the if; removing that leaves only the else: the
// conditional goes.
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], false);
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], true);
- assert_eq!(actions(&s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(actions(s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
}
#[test]
diff --git a/crates/engine/src/scaled_instance_tests.rs b/crates/engine/src/scaled_instance_tests.rs
new file mode 100644
index 00000000..f79e9534
--- /dev/null
+++ b/crates/engine/src/scaled_instance_tests.rs
@@ -0,0 +1,131 @@
+//! Instances scaled with the Scale tool keep their scaled layers when a main around them or their
+//! own main changes (#922): a 24 px icon with a 1.5 px stroke shown at 14 px has a 0.875 px stroke,
+//! and its points and size are 14/24 of its main's.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::components::tests::{copy_of, ex, healthy, idv, node};
+use figcraft_doc::{NodeId, NodeKind};
+
+/// The scale the icon is shown at: 24 px to 14 px.
+const K: f64 = 14.0 / 24.0;
+
+/// Whether two lengths are equal up to rounding.
+fn near(a: f64, b: f64) -> bool {
+ (a - b).abs() < 1e-6
+}
+
+/// The icon: a 24 px component holding a flattened triangle with a 1.5 px stroke; returns the
+/// component and the triangle.
+fn icon(s: &mut Session) -> (NodeId, NodeId) {
+ let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 24, "height": 24})));
+ let p = idv(&ex(s, "shape.polygon", json!({"x": 4, "y": 4, "width": 16, "height": 16, "parent": f})));
+ ex(s, "vector.flatten", json!({"ids": [p]}));
+ let v = node(s, f).children[0];
+ assert!(matches!(node(s, v).kind, NodeKind::Vector { .. }));
+ ex(s, "node.set", json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 1.5}}));
+ let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
+ (c, v)
+}
+
+/// `copy` shows main layer `main` at 14/24: stroke, size and points.
+fn assert_scaled(s: &Session, copy: NodeId, main: NodeId, why: &str) {
+ let (c, m) = (node(s, copy), node(s, main));
+ assert!(near(c.stroke.weight, m.stroke.weight * K), "{why}: stroke {} (main {})", c.stroke.weight, m.stroke.weight);
+ assert!(near(c.width, m.width * K) && near(c.height, m.height * K), "{why}: size {}×{} (main {}×{})", c.width, c.height, m.width, m.height);
+ let (NodeKind::Vector { network: a }, NodeKind::Vector { network: b }) = (&c.kind, &m.kind) else { panic!("{why}: not vectors") };
+ assert_eq!(a.vertices.len(), b.vertices.len(), "{why}");
+ for (va, vb) in a.vertices.iter().zip(&b.vertices) {
+ assert!(near(va.x, vb.x * K) && near(va.y, vb.y * K), "{why}: point ({}, {}) (main ({}, {}))", va.x, va.y, vb.x, vb.y);
+ }
+}
+
+/// Edits of a main: a value on its root, a value on a layer, a layer added (a rebuild).
+fn main_edits(main: NodeId, layer: NodeId) -> Vec<(&'static str, Value)> {
+ vec![
+ ("effects on the main", json!({"id": main, "props": {"effects": [{"type": "dropShadow"}]}})),
+ ("effects cleared on the main", json!({"id": main, "props": {"effects": []}})),
+ ("fill on the main", json!({"id": main, "props": {"fills": ["#ff0000"]}})),
+ ("fill on a main layer", json!({"id": layer, "props": {"fills": ["#00ff00"]}})),
+ ]
+}
+
+/// An instance scaled on the page keeps its scaled layers through value edits of its main and
+/// a rebuild (a layer added to the main).
+#[test]
+fn a_scaled_instance_keeps_its_scale_when_its_main_changes() {
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
+ ex(&mut s, "transform.scale", json!({"ids": [i], "factor": K}));
+ let iv = copy_of(&s, i, v);
+ assert_scaled(&s, iv, v, "scaled");
+ for (why, p) in main_edits(c, v) {
+ ex(&mut s, "node.set", p);
+ assert_scaled(&s, copy_of(&s, i, v), v, why);
+ }
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
+ healthy(&s);
+}
+
+/// Dragging a Scale tool handle records the same overrides, and undoing the drag takes them back.
+#[test]
+fn an_instance_scaled_with_the_tool_keeps_its_scale_and_undo_takes_it_back() {
+ use crate::tools::{PointerEvent, PointerKind};
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
+ ex(&mut s, "select.set", json!({"ids": [i]}));
+ ex(&mut s, "tool.scale", json!({}));
+ // The bottom right handle, from 24 px to 14 px about the top left corner.
+ for (kind, (x, y)) in
+ [(PointerKind::Down, (124.0, 24.0)), (PointerKind::Move, (119.0, 19.0)), (PointerKind::Move, (114.0, 14.0)), (PointerKind::Up, (114.0, 14.0))]
+ {
+ s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
+ }
+ assert!(near(node(&s, i).width, 14.0), "{}", node(&s, i).width);
+ assert_scaled(&s, copy_of(&s, i, v), v, "scaled with the tool");
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
+ s.undo().unwrap();
+ s.undo().unwrap();
+ let back = node(&s, copy_of(&s, i, v));
+ assert!(near(back.stroke.weight, 1.5) && back.overrides.is_empty(), "undo: {} {:?}", back.stroke.weight, back.overrides);
+ healthy(&s);
+}
+
+/// The report's case: an icon scaled inside a header that then becomes a component keeps its scale
+/// in the header and in the header's instances, through edits of both mains.
+#[test]
+fn a_scaled_nested_icon_keeps_its_scale_when_the_component_holding_it_changes() {
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ // The header holds the icon at 14 px, scaled with the Scale tool, then becomes a component.
+ let h = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 200, "height": 48})));
+ let hi = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 117, "parent": h})));
+ ex(&mut s, "transform.scale", json!({"ids": [hi], "factor": K}));
+ let header = idv(&ex(&mut s, "component.create", json!({"ids": [h]})));
+ let screen = idv(&ex(&mut s, "instance.create", json!({"component": header, "x": 0, "y": 300})));
+ // The icon's triangle as the header and as the instance of the header show it.
+ let both = |s: &Session| {
+ let in_header = copy_of(s, hi, v);
+ [in_header, copy_of(s, copy_of(s, screen, hi), in_header)]
+ };
+ let check = |s: &Session, why: &str| {
+ for (copy, where_) in both(s).into_iter().zip(["the header's icon", "the icon in an instance of the header"]) {
+ assert_scaled(s, copy, v, &format!("{why}: {where_}"));
+ }
+ };
+ check(&s, "made a component");
+ for (why, p) in main_edits(header, hi).into_iter().chain(main_edits(c, v)) {
+ ex(&mut s, "node.set", p);
+ check(&s, why);
+ }
+ ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 110, "width": 20, "height": 20, "parent": header}));
+ check(&s, "a layer added to the header");
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ check(&s, "a layer added to the icon");
+ healthy(&s);
+}
diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs
index 56bc4b35..00c7389f 100644
--- a/crates/engine/src/selection/selection_colors.rs
+++ b/crates/engine/src/selection/selection_colors.rs
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::rebind_paint_alpha;
-use crate::params::*;
+use crate::params::{get, paint_of, parse, req_string, targets};
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index 02abbfb7..c5852728 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -125,6 +125,8 @@ fn clicking_selects_by_the_documented_rules() {
((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"),
((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"),
((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"),
+ ((520.0, 20.0), NONE, vec![k.r, k.empty], vec![k.r], "a click on one of several selected layers selects just it"),
+ ((520.0, 20.0), CMD, vec![k.r, k.empty], vec![k.r, k.empty], "silent: Cmd/Ctrl-click on a selected layer keeps the whole selection (pinned)"),
((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"),
((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"),
((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"),
@@ -300,6 +302,18 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() {
assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None);
}
+#[test]
+fn dragging_one_of_several_selected_layers_moves_them_all() {
+ let mut s = Session::new();
+ let k = scene(&mut s);
+ let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
+ ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
+ drag(&mut s, (520.0, 20.0), (540.0, 20.0), NONE);
+ assert_eq!(sorted(s.selection()), sorted(vec![k.r, k.empty]), "a drag on one of several selected layers keeps them all selected");
+ assert_eq!(at(&s, k.r), Point::new(520.0, 0.0), "the dragged layer moves");
+ assert_eq!(at(&s, k.empty), Point::new(620.0, 0.0), "and so do the others");
+}
+
#[test]
fn shift_drag_moves_the_selection() {
let mut s = Session::new();
@@ -442,6 +456,21 @@ fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
}
+#[test]
+fn a_click_where_nothing_is_hit_inside_a_multiple_selection_keeps_it() {
+ let mut s = Session::new();
+ let a = rect(&mut s, 100.0, 300.0, None);
+ let b = rect(&mut s, 220.0, 300.0, None);
+ for i in [a, b] {
+ ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
+ }
+ ex(&mut s, "select.set", json!({"ids": [a, b]}));
+ // The press is inside `a` but its unfilled inside hits nothing: `a` is only the drag fallback,
+ // not a clicked layer, so a click keeps the selection.
+ click(&mut s, 120.0, 320.0, NONE);
+ assert_eq!(sorted(s.selection()), sorted(vec![a, b]));
+}
+
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs
index acafd229..389f7f29 100644
--- a/crates/engine/src/session.rs
+++ b/crates/engine/src/session.rs
@@ -2,7 +2,10 @@ use super::derived::normalize;
use super::doc_state::{DocState, ENDS_PREVIEW, HistoryEntry, Interaction, PREVIEW_READ_ONLY};
use super::error::{EngineError, Result, bad};
use super::prefs::{Clipboard, Prefs, UiRequest};
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, CommandSpec, Document, Node, NodeId, Session, Value, background, board, branches, cmd, components, events,
+ figcraft_collab, find_command, guard, json, library, live, params, plugins, textpreview, tools,
+};
mod access;
mod exec;
diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs
index af9fd7f5..05bc45d5 100644
--- a/crates/engine/src/session/access.rs
+++ b/crates/engine/src/session/access.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
impl Session {
pub fn ds(&self) -> Result<&DocState> {
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
index add12ac5..44af2a05 100644
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, CommandSpec, Document, ENDS_PREVIEW, EngineError, Node, NodeId, PREVIEW_READ_ONLY, Result, Session, Value, bad, board, branches, cmd,
+ components, events, find_command, guard, library, normalize, params, textpreview,
+};
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
@@ -14,6 +17,34 @@ fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
}
impl Session {
+ /// Append a live event with the active document's identity (nothing without one). Inside a
+ /// [`Session::grouped`] call the event is buffered and committed only when the group succeeds.
+ fn record_event(&mut self, kind: &str, data: Value) {
+ let (document, path) = match self.ds() {
+ Ok(d) => (d.doc.name.clone(), d.path.clone()),
+ Err(_) => (String::new(), None),
+ };
+ if self.group_depth > 0 {
+ self.pending_events.push((kind.to_string(), document, path, data));
+ } else {
+ self.events.push(kind, &document, path.as_deref(), data);
+ }
+ }
+
+ /// Restore the batch context an inner command had at entry when it panicked.
+ pub(crate) fn restore_after_internal_error(&mut self, group_depth: usize, pending_events: usize) {
+ self.group_depth = group_depth;
+ self.pending_events.truncate(pending_events);
+ }
+
+ /// Commit events buffered by a successful group.
+ pub(super) fn flush_pending_events(&mut self) {
+ let pending = std::mem::take(&mut self.pending_events);
+ for (kind, document, path, data) in pending {
+ self.events.push(&kind, &document, path.as_deref(), data);
+ }
+ }
+
/// Run a command by id. Mutations become one undo step; a failure or panic leaves the document
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result {
@@ -44,11 +75,22 @@ impl Session {
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
// Cut and copy write the clipboard before a later check can refuse the command.
let clipboard_before = self.clipboard.clone();
+ // An inner command of a batch can panic while the outer group is still open; remember the
+ // context so the panic does not clear the group's buffered events.
+ let group_depth_before = self.group_depth;
+ let pending_events_before = self.pending_events.len();
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
- .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
+ .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v))
+ // Normalising is part of the command: a panic in it rolls the edit back too (#1085).
+ .and_then(|v| {
+ guard::run(id, || {
+ self.normalize_edit(spec, &before);
+ Ok(v)
+ })
+ });
match &result {
Ok(_) => {
self.after_command(spec, before);
@@ -79,9 +121,29 @@ impl Session {
self.clipboard = clipboard_before;
}
}
+ self.after_comment_command(id, params, &result, group_depth_before, pending_events_before);
result
}
+ /// Record the live event a successful mutating `comment.*` command makes. A panic caught by the
+ /// guard can leave a group's event buffer open: restore the depth and buffer it had when the
+ /// command started (`group_depth`, `pending_events`), not the whole session's.
+ fn after_comment_command(&mut self, id: &str, params: &Value, result: &Result, group_depth: usize, pending_events: usize) {
+ match result {
+ Ok(v) => {
+ if let Some(kind) = events::comment_event_kind(id) {
+ let comment_id = v.get("id").and_then(Value::as_u64).or_else(|| params.get("id").and_then(params::id_of).map(|n| n.0));
+ self.record_event(
+ kind,
+ serde_json::json!({"command": id, "commentId": comment_id, "reply": v.get("reply").cloned().unwrap_or(Value::Null)}),
+ );
+ }
+ }
+ Err(EngineError::Internal { .. }) => self.restore_after_internal_error(group_depth, pending_events),
+ Err(_) => {}
+ }
+ }
+
/// A version shown in place of the document is read-only: refuse an edit (it is rolled back).
fn check_preview(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
@@ -138,7 +200,19 @@ impl Session {
.map_err(|m| EngineError::Disabled(id.to_string(), m))
}
- /// Bookkeeping after a successful command: normalise what changed, push undo, bump revision.
+ /// Normalise what a command changed in the document it ran on (text boxes, groups, instances,
+ /// auto layout…).
+ fn normalize_edit(&mut self, spec: &CommandSpec, before: &Option<(Arc, Vec, NodeId, u64)>) {
+ let Some((bdoc, _, _, uid)) = before else { return };
+ let Some(ds) = self.docs.iter_mut().find(|d| d.uid == *uid) else { return };
+ // Saving may adopt the chosen filename as the document title. The encoded snapshot is
+ // already final; normalising it again would detach it from the saved baseline.
+ if !Arc::ptr_eq(&ds.doc, bdoc) && !matches!(spec.id, "file.save" | "file.saveAs") {
+ normalize(ds.doc_mut(), bdoc);
+ }
+ }
+
+ /// Bookkeeping after a successful command (normalised already): push undo, bump revision.
fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc, Vec, NodeId, u64)>) {
let Some((bdoc, bsel, bpage, uid)) = before else {
if let Ok(ds) = self.ds_mut() {
@@ -147,13 +221,7 @@ impl Session {
return;
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
- let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
- // Saving may adopt the chosen filename as the document title. The encoded snapshot is
- // already final; normalising it again would detach it from the saved baseline.
- if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
- normalize(ds.doc_mut(), &bdoc);
- }
- let edit = changed && ds.interaction.is_none();
+ let edit = !Arc::ptr_eq(&ds.doc, &bdoc) && ds.interaction.is_none();
ds.clean_selection();
ds.revision += 1;
if edit {
diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs
index 8790c83b..677e2471 100644
--- a/crates/engine/src/session/new.rs
+++ b/crates/engine/src/session/new.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, Clipboard, DocState, Document, Prefs, Session, background, cmd, events, library, live, plugins, tools};
impl Session {
/// A session with one new document.
@@ -28,6 +28,8 @@ impl Session {
plugins: plugins::Runtime::default(),
live: live::Lives::default(),
background: background::Background::default(),
+ events: Arc::new(events::EventLog::default()),
+ pending_events: Vec::new(),
}
}
diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs
index a354ea27..3fca87b9 100644
--- a/crates/engine/src/session/undo.rs
+++ b/crates/engine/src/session/undo.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
+ Value, cmd, figcraft_collab, normalize, textpreview,
+};
impl Session {
/// How many undo and redo steps the active document has (the user's own in a live session).
@@ -202,6 +205,7 @@ impl Session {
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result {
self.ds()?;
+ let pending_mark = self.pending_events.len();
let before: Vec<_> =
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
// An edit inside clears the redo steps; a failed group has to bring them back.
@@ -219,11 +223,13 @@ impl Session {
self.group_depth += 1;
let result = f(self);
self.group_depth -= 1;
+ let at_root = self.group_depth == 0;
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
}
if result.is_err() {
+ self.pending_events.truncate(pending_mark);
// Tabs the group closed come back from the stash, and the group-start order returns.
let mut current = std::mem::take(&mut self.docs);
for (uid, ..) in &before {
@@ -250,8 +256,9 @@ impl Session {
}
return result;
}
- if self.group_depth == 0 {
+ if at_root {
self.closed_in_group.clear();
+ self.flush_pending_events();
}
let changed: Vec<_> = before
diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs
index 5b6008f8..9d851cab 100644
--- a/crates/engine/src/sharing/live/events.rs
+++ b/crates/engine/src/sharing/live/events.rs
@@ -1,6 +1,9 @@
use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
-use super::*;
+use super::{
+ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
+ ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
+};
impl Session {
/// The active document (`None`: no document is open).
diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs
index 685ab5c0..06f6df95 100644
--- a/crates/engine/src/sharing/live/host.rs
+++ b/crates/engine/src/sharing/live/host.rs
@@ -1,5 +1,8 @@
use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via};
-use super::*;
+use super::{
+ Access, Admin, AdminReply, ClientMsg, EngineError, HostInfo, IpAddr, Live, Result, Session, Value, bad, code, display_name, get, json,
+ req_string, string,
+};
/// What a server says about its hosting, made safe to show: no control characters, short, and a
/// terms address only when it is a web page (`http(s)://`).
diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs
index 972198ef..e08afcc6 100644
--- a/crates/engine/src/sharing/live/join.rs
+++ b/crates/engine/src/sharing/live/join.rs
@@ -1,6 +1,8 @@
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
use super::types::{Published, Role, SessionId, Share, Status, join_target};
-use super::*;
+use super::{
+ Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
+};
fn join(s: &mut Session, p: &Value) -> Result {
const CMD: &str = "collab.join";
diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs
index f2fd3c54..3920bdca 100644
--- a/crates/engine/src/sharing/live/sync.rs
+++ b/crates/engine/src/sharing/live/sync.rs
@@ -1,6 +1,9 @@
use super::host::hosting_json;
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
-use super::*;
+use super::{
+ Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
+ editing_of, json,
+};
impl Session {
/// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for
diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs
index 16a237d5..efeecac9 100644
--- a/crates/engine/src/sharing/live/types.rs
+++ b/crates/engine/src/sharing/live/types.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Access, Admin, AdminReply, Arc, ClientMsg, Code, Document, HostInfo, IpAddr, LibraryReply, LibraryReq, NO_TEAM_LIBRARIES, Peer, PluginReply,
+ PluginReq, Presence, Resume, Serialize, ServerMsg, Signal, code,
+};
/// The server files are shared through online unless Preferences name another: the only place
/// its address is written.
diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs
index cbf824d9..01a5db08 100644
--- a/crates/engine/src/slot_tests.rs
+++ b/crates/engine/src/slot_tests.rs
@@ -99,6 +99,28 @@ fn slots_are_frames_of_a_main_with_a_slot_property() {
healthy(&s);
}
+/// `instances_fill_slots_with_their_own_content`: resetting a slot, or the whole instance, restores the default content.
+fn reset_slot_content(s: &mut Session, [a, aslot, ph, ichip, chip]: [NodeId; 5]) {
+ // Reset: back to the main's default content.
+ ex(s, "instance.resetSlot", json!({"id": aslot}));
+ healthy(s);
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ assert_eq!(kids(s, aslot).len(), 2);
+ assert_eq!(fill(s, copy_of(s, a, ph)), "0000FF");
+ assert!(!s.doc().unwrap().contains(ichip));
+ s.undo().unwrap();
+ assert!(s.doc().unwrap().contains(ichip));
+ healthy(s);
+ // Reset all changes resets slots too; `replace` clears the default content first.
+ ex(s, "component.resetInstance", json!({"ids": [a]}));
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ let only = idv(&ex(s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
+ assert_eq!(kids(s, aslot), vec![only]);
+ ex(s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
+ assert_eq!(kids(s, aslot).len(), 2);
+ healthy(s);
+}
+
#[test]
fn instances_fill_slots_with_their_own_content() {
let mut s = Session::new();
@@ -158,23 +180,7 @@ fn instances_fill_slots_with_their_own_content() {
// Layers of the instance can't move into its slot either.
let atitle = copy_of(&s, a, title);
refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot}));
- // Reset: back to the main's default content.
- ex(&mut s, "instance.resetSlot", json!({"id": aslot}));
- healthy(&s);
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- assert_eq!(kids(&s, aslot).len(), 2);
- assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF");
- assert!(!s.doc().unwrap().contains(ichip));
- s.undo().unwrap();
- assert!(s.doc().unwrap().contains(ichip));
- healthy(&s);
- // Reset all changes resets slots too; `replace` clears the default content first.
- ex(&mut s, "component.resetInstance", json!({"ids": [a]}));
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
- assert_eq!(kids(&s, aslot), vec![only]);
- ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
- assert_eq!(kids(&s, aslot).len(), 2);
+ reset_slot_content(&mut s, [a, aslot, ph, ichip, chip]);
healthy(&s);
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 811724f2..b330b8e4 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -616,13 +616,11 @@ fn document_colors_are_counted_per_page() {
assert!(v["colors"].as_array().unwrap().is_empty());
}
+/// `guide.add`, `guide.move` and `guide.remove` edit the current page's guides by index.
#[test]
fn guides_add_move_remove() {
let mut s = sess();
- let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
- NodeKind::Page { guides, .. } => guides.clone(),
- _ => vec![],
- };
+ let guides = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().guides.clone();
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
assert_eq!(r["index"], json!(0));
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs
index 5172bb4b..1ebd78b0 100644
--- a/crates/engine/src/theme_pages_tests.rs
+++ b/crates/engine/src/theme_pages_tests.rs
@@ -183,6 +183,8 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
+/// `page.duplicate` copies the page's flows (pointing at the copied frames), prototype settings
+/// and guides.
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
@@ -195,9 +197,12 @@ fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let source = s.page().unwrap();
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
- let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
- NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
- _ => panic!("expected a page"),
+ let state = |s: &Session, id: NodeId| {
+ let n = s.doc().unwrap().node(id).unwrap();
+ match &n.kind {
+ NodeKind::Page { flows, prototype, .. } => (flows.clone(), prototype.clone(), n.guides.clone()),
+ _ => panic!("expected a page"),
+ }
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs
index 2afaf9a2..ef337af8 100644
--- a/crates/engine/src/vector_multi_behaviour_tests.rs
+++ b/crates/engine/src/vector_multi_behaviour_tests.rs
@@ -95,6 +95,14 @@ fn selected_in(s: &Session, id: NodeId) -> Vec {
#[test]
fn editing_several_vectors_at_once() {
+ several_vectors_enter_edit_mode();
+ let (mut s, a, b, atl, btl) = points_across_both_layers();
+ arrows_and_drags_move_every_layer(&mut s, a, b, atl, btl);
+ corner_radius_mirroring_and_delete(&mut s, a, b, atl, btl);
+ bend_select_all_pen_and_clicks(&mut s, a, b);
+}
+
+fn several_vectors_enter_edit_mode() {
// (how edit mode is entered with two shapes selected, derivation)
let rows: Vec<(&str, &str)> = vec![
("command", "the edit command takes every selected shape"),
@@ -135,8 +143,9 @@ fn editing_several_vectors_at_once() {
crate::vectoredit::enter_targets(s.doc().unwrap(), &[a, f]).is_none(),
"silent: a frame in the selection leaves Enter to select its children (pinned)"
);
+}
- // Points across both layers.
+fn points_across_both_layers() -> (Session, NodeId, NodeId, usize, usize) {
let mut s = Session::new();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0));
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
@@ -161,64 +170,74 @@ fn editing_several_vectors_at_once() {
sb.sort_unstable();
assert_eq!((editing(&s).unwrap().id, selected_in(&s, a), selected_in(&s, b)), (active, sa, sb), "{why}");
}
+ (s, a, b, atl, btl)
+}
+
+fn arrows_and_drags_move_every_layer(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Arrows nudge every selected point, one undo step for all layers.
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
- assert_eq!((selected_in(&s, a), selected_in(&s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert_eq!((selected_in(s, a), selected_in(s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
assert!(s.tool_key("Right", false, false, false).unwrap());
- assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
+ assert!(near(page_pos(s, a, atl), (1.0, 0.0)) && near(page_pos(s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "one undo step for every layer");
// Dragging one selected point drags them all (one on the transform box's edge: its corners
// scale, as with points of one layer).
- let atr = point_at(&s, a, (100.0, 0.0));
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
- drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
+ let atr = point_at(s, a, (100.0, 0.0));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
+ drag(s, (100.0, 0.0), (110.0, 20.0), NONE);
assert!(
- near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
+ near(page_pos(s, a, atl), (10.0, 20.0)) && near(page_pos(s, a, atr), (110.0, 20.0)) && near(page_pos(s, b, btl), (210.0, 20.0)),
"dragging one selected point moves them all"
);
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "the drag is one undo step");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+}
+
+fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Corner radius, mirroring and deleting reach every layer's selected points.
- ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
- assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
- ex(&mut s, "vector.edit.mirroring", json!({"mode": "angle"}));
+ ex(s, "vector.edit.cornerRadius", json!({"radius": 10}));
+ assert!(net(s, a).vertices[atl].radius == 10.0 && net(s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
+ ex(s, "vector.edit.mirroring", json!({"mode": "angle"}));
assert!(
- net(&s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
- && net(&s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
+ net(s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
+ && net(s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
"mirroring applies to every layer"
);
assert!(s.tool_key("Delete", false, false, false).unwrap());
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
s.undo().unwrap();
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (4, 4), "one undo step");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (4, 4), "one undo step");
+}
+
+fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
- drag(&mut s, (250.0, 100.0), (250.0, 130.0), CMD);
- assert!(net(&s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
- assert!(net(&s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
+ drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
+ assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
+ assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
// Select all takes every edited layer's points.
- let r = ex(&mut s, "vector.edit.selectAll", json!({}));
+ let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
// The pen draws in the active layer only.
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": []}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
- click(&mut s, (50.0, 300.0), NONE);
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
- ex(&mut s, "vector.edit.penEnd", json!({}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": []}));
+ ex(s, "vector.edit.tool", json!({"tool": "pen"}));
+ click(s, (50.0, 300.0), NONE);
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
+ ex(s, "vector.edit.penEnd", json!({}));
+ ex(s, "vector.edit.tool", json!({"tool": "move"}));
// A click on empty canvas inside an edited layer's area keeps edit mode; so does one far
// away, which only clears the points (a double-click or Escape leaves).
- click(&mut s, (250.0, 50.0), NONE);
- assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays");
- let st = ex(&mut s, "vector.edit.state", json!({}));
+ click(s, (250.0, 50.0), NONE);
+ assert!(editing(s).is_some(), "a click inside an edited layer clears the points and stays");
+ let st = ex(s, "vector.edit.state", json!({}));
assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer");
- click(&mut s, (900.0, 900.0), NONE);
- assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode");
+ click(s, (900.0, 900.0), NONE);
+ assert!(editing(s).is_some(), "a click away from every edited layer keeps edit mode");
s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap();
- assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode");
+ assert!(editing(s).is_none(), "a double-click away from every edited layer leaves edit mode");
}
#[test]
diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs
index e162f039..54102d6b 100644
--- a/crates/engine/src/view_state.rs
+++ b/crates/engine/src/view_state.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Deserialize, Serialize};
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs
index d81a5856..5b0bd77a 100644
--- a/crates/format/src/fig.rs
+++ b/crates/format/src/fig.rs
@@ -47,6 +47,7 @@ mod text_style_tests;
use std::collections::BTreeMap;
use std::io::Read;
+use std::sync::Arc;
use figcraft_doc::Document;
@@ -59,7 +60,7 @@ use kiwi::{Kv, Schema};
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest image member read.
-const MAX_IMAGE: u64 = 256 * 1024 * 1024;
+const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
@@ -109,9 +110,9 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
// The message goes before the layers are built, so the images it carries are copied out first.
- let embedded: BTreeMap> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
+ let embedded = embedded_copies(&msg);
drop(msg);
- let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
+ let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
// Both stages stop at the same cap and say so in the same words: one note (#981).
for w in more.into_iter().filter_map(map::adapt_warning) {
@@ -151,17 +152,17 @@ fn corrupt(msg: impl Into) -> FormatError {
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
-/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
-/// bytes stay borrowed from the message.
-fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap {
+/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`].
+/// Returns the index of each hash's blob.
+fn embedded_images(msg: &Kv) -> BTreeMap {
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
let mut found = BTreeMap::new();
let mut pending = vec![msg];
while let Some(value) = pending.pop() {
match value {
Kv::Object(fields) => {
- if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
- found.entry(hash).or_insert(bytes);
+ if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
+ found.entry(hash).or_insert(index);
}
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
}
@@ -172,15 +173,35 @@ fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap {
found
}
-/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
-fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
+/// The images that travel inside the message ([`embedded_images`]), copied out of it once per
+/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes
+/// at one large blob, and a copy for each would multiply it (#1059).
+fn embedded_copies(msg: &Kv) -> BTreeMap> {
+ let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
+ let mut copies: BTreeMap> = BTreeMap::new();
+ let mut out = BTreeMap::new();
+ for (hash, index) in embedded_images(msg) {
+ let Some(bytes) = blob(blobs, index) else { continue };
+ out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes))));
+ }
+ out
+}
+
+/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
+fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
- let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
+ let index = usize::try_from(*index).ok()?;
+ let bytes = blob(blobs, index)?;
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
return None;
}
- Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
+ Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index))
+}
+
+/// The bytes of blob `index`.
+fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
+ blobs.get(index)?.get("bytes").and_then(Kv::bytes)
}
/// The parts of a `.fig` archive the import reads.
diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs
index a271a908..cf115c8f 100644
--- a/crates/format/src/fig/assets_tests.rs
+++ b/crates/format/src/fig/assets_tests.rs
@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() {
#[test]
fn variables_are_written_as_nodes_on_a_hidden_page() {
let nodes = written(&themed());
- let hidden = named(&nodes, "Internal Only Canvas");
+ assert_hidden_theme(&nodes);
+ assert_variable_values(&nodes);
+ assert_variable_bindings(&nodes);
+}
+
+fn assert_hidden_theme(nodes: &[Value]) {
+ let hidden = named(nodes, "Internal Only Canvas");
assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));
- let set = named(&nodes, "Theme");
+ let set = named(nodes, "Theme");
assert_eq!(set["type"], "VARIABLE_SET");
assert_eq!(set["parentIndex"]["guid"], hidden["guid"]);
let modes = set["variableSetModes"].as_array().unwrap();
assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]);
- let bg = named(&nodes, "color/bg");
+}
+
+fn assert_variable_values(nodes: &[Value]) {
+ let set = named(nodes, "Theme");
+ let modes = set["variableSetModes"].as_array().unwrap();
+ let bg = named(nodes, "color/bg");
assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR")));
assert_eq!(bg["variableSetID"]["guid"], set["guid"]);
assert_eq!(bg["description"], "Behind everything");
assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));
- assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
+ assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
let values = bg["variableDataValues"]["entries"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values[1]["modeID"], modes[1]["id"]);
@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
(Some("COLOR"), Some(0.25))
);
// An alias names the variable's node; every value says the type it resolves to.
- let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];
+ let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"];
assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS");
assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");
- assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);
- assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
- assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
- assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+ assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);
+ assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
+ assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
+ assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+}
+
+fn assert_variable_bindings(nodes: &[Value]) {
+ let bg = named(nodes, "color/bg");
+ let set = named(nodes, "Theme");
+ let modes = set["variableSetModes"].as_array().unwrap();
// Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.
- let card = named(&nodes, "Card");
+ let card = named(nodes, "Card");
assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]);
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);
- assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);
+ assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]);
let fields: Vec<&str> =
card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect();
for f in
@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
{
assert!(fields.contains(&f), "{f} in {fields:?}");
}
- let swatch = named(&nodes, "Swatch");
+ let swatch = named(nodes, "Swatch");
assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none());
assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);
@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}");
}
// The page sets a mode too.
- assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);
+ assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]);
}
#[test]
diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs
index 00c84639..bd269556 100644
--- a/crates/format/src/fig/export/assets.rs
+++ b/crates/format/src/fig/export/assets.rs
@@ -1,5 +1,8 @@
use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};
-use super::*;
+use super::{
+ AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,
+ TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// GUIDs for every layer (pages first, then depth-first), component property definitions,
diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs
index 2853dd72..4a97eecb 100644
--- a/crates/format/src/fig/export/common.rs
+++ b/crates/format/src/fig/export/common.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,
+ PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,
+ s, xy,
+};
/// Longest kept description of such a layer (bytes of JSON).
const MAX_STASH: usize = 1 << 20;
diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs
index 7d88dcc8..2342386b 100644
--- a/crates/format/src/fig/export/component.rs
+++ b/crates/format/src/fig/export/component.rs
@@ -1,7 +1,7 @@
use super::common::{
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
};
-use super::*;
+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
impl<'d> Exporter<'d> {
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
index 9925042f..4a8604fc 100644
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -1,5 +1,9 @@
use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};
-use super::*;
+use super::{
+ AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,
+ Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,
+ guid, int, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.
@@ -15,11 +19,11 @@ impl<'d> Exporter<'d> {
("phase", e("CREATED")),
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
];
- let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
+ let children = if let NodeKind::Page { background, prototype, .. } = &n.kind {
f.extend([("type", e("CANVAS")), ("name", s(&n.name))]);
f.extend(root_fields());
f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]);
- if !guides.is_empty() {
+ if !n.guides.is_empty() {
self.warn("ruler guides were left out");
}
if *prototype != Default::default() {
diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs
index f0f40c18..26718e44 100644
--- a/crates/format/src/fig/export/paint.rs
+++ b/crates/format/src/fig/export/paint.rs
@@ -1,5 +1,8 @@
use super::common::one_sided;
-use super::*;
+use super::{
+ Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,
+ StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,
+};
impl<'d> Exporter<'d> {
/// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.
diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs
index 866b03c4..01c20db0 100644
--- a/crates/format/src/fig/export/prototype.rs
+++ b/crates/format/src/fig/export/prototype.rs
@@ -1,5 +1,8 @@
use super::common::key_codes;
-use super::*;
+use super::{
+ Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,
+ b, color, e, fl, int, o, position, s,
+};
impl<'d> Exporter<'d> {
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
index 7ef79a78..ba420527 100644
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -1,5 +1,9 @@
use super::common::{style_fields, unsupported_decoration_look};
-use super::*;
+use super::{
+ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
+ StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
+ join_name, network, o, path_blob, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
index 0f431082..82bdc6cd 100644
--- a/crates/format/src/fig/kiwi.rs
+++ b/crates/format/src/fig/kiwi.rs
@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> {
/// An encoder for the same format: the `.fig` export writes its schema and message with it (and
/// test fixtures are built with it). Values are checked against the schema; it never panics.
pub mod encode {
- use super::*;
+ use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};
#[derive(Default)]
pub struct Writer(pub Vec);
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
index abc2225e..227c18aa 100644
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -1,6 +1,6 @@
use super::layer::own_text;
use super::text::char_styles;
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs
index 172616dc..cd17680f 100644
--- a/crates/format/src/fig/map/connector.rs
+++ b/crates/format/src/fig/map/connector.rs
@@ -1,5 +1,5 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
impl<'a> Mapper<'a> {
/// A connector's end caps, and when it has no saved outline or network a straight line
diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs
index d31ef5c6..69f221c1 100644
--- a/crates/format/src/fig/map/document.rs
+++ b/crates/format/src/fig/map/document.rs
@@ -1,7 +1,7 @@
use super::component::{assigned, prop_guid};
use super::paint::color;
use super::variables::variable_value;
-use super::*;
+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};
impl<'a> Mapper<'a> {
/// The DOCUMENT with its pages.
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
index e4793d48..e750a5e4 100644
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
-use super::*;
+use super::{
+ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
+ size,
+};
/// How the layers of a resized container follow it when the file gives no derived layout for
/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
@@ -61,6 +64,22 @@ impl Fit {
}
}
+struct LayerChildren<'s, 'a> {
+ id: &'s str,
+ inst: Option<&'s Inst<'a>>,
+ base: &'s Kv<'a>,
+ child_space: Affine,
+ view: &'s View<'a>,
+ object: &'s mut Obj,
+ ty: &'s str,
+ rest_id: &'s str,
+ connector_snapshot: bool,
+ connector_terminal: bool,
+ depth: usize,
+ included: Option<&'s BTreeSet>,
+ slot_content: Option,
+}
+
impl<'a> Mapper<'a> {
/// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
/// parent's coordinates to the enclosing frame's (groups and booleans place their children in
@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> {
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
// A supported connector snapshot has its own note below instead of the generic stand-in one.
let stand = stand_in(own).filter(|_| !connector_snapshot);
- let ty = match own {
- "ROUNDED_RECTANGLE" => "RECTANGLE",
- "SYMBOL" => "COMPONENT",
- "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
- "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
- "VARIABLE" | "VARIABLE_SET" => return None,
- "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",
- "CONNECTOR" if connector_snapshot => "FRAME",
- t => stand.map_or(t, |s| s.0),
- };
+ let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
@@ -225,18 +235,59 @@ impl<'a> Mapper<'a> {
source.insert("slotOverride".into(), json!(true));
}
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
- let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };
+ let mut kids = self.map_layer_children(LayerChildren {
+ id,
+ inst,
+ base,
+ child_space,
+ view: &view,
+ object: &mut o,
+ ty,
+ rest_id: &rest_id,
+ connector_snapshot,
+ connector_terminal,
+ depth,
+ included,
+ slot_content,
+ });
+ if stand.is_some_and(|s| s.0 == "FRAME") {
+ kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
+ kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
+ }
+ if !kids.is_empty() {
+ o.insert("children".into(), Value::Array(kids));
+ }
+ Some(Value::Object(o))
+ }
+
+ fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec {
+ let LayerChildren {
+ id,
+ inst,
+ base,
+ child_space,
+ view,
+ object: o,
+ ty,
+ rest_id,
+ connector_snapshot,
+ connector_terminal,
+ depth,
+ included,
+ slot_content,
+ } = context;
+ let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() };
if connector_terminal {
// The saved vector is flattened into the connector node when no child slot remains.
} else if ty == "INSTANCE" {
- if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
+ if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
let main = self.nodes.get(&sym).map(|m| View::of(m));
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
// An instance that records no auto layout of its own has its main's.
let view = match main {
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
- self.auto_layout(&mut o, &layered);
+ self.auto_layout(o, &layered);
layered
}
_ => view.clone(),
@@ -252,7 +303,7 @@ impl<'a> Mapper<'a> {
content_inst.slot_content = true;
}
for c in self.children(&content) {
- kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
+ kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included));
}
} else {
// Inside an instance a layer's children are its main's: they follow it from the main's
@@ -263,17 +314,29 @@ impl<'a> Mapper<'a> {
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
};
for c in self.children(id) {
- kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));
+ kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
}
}
- if stand.is_some_and(|s| s.0 == "FRAME") {
- kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
- kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
+ kids
+ }
+
+ fn mapped_type<'s>(
+ own: &'s str,
+ view: &View<'a>,
+ connector_snapshot: bool,
+ connector_terminal: bool,
+ stand: Option<(&'static str, &'static str)>,
+ ) -> Option<&'s str> {
+ match own {
+ "ROUNDED_RECTANGLE" => Some("RECTANGLE"),
+ "SYMBOL" => Some("COMPONENT"),
+ "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),
+ "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),
+ "VARIABLE" | "VARIABLE_SET" => None,
+ "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),
+ "CONNECTOR" if connector_snapshot => Some("FRAME"),
+ t => Some(stand.map_or(t, |s| s.0)),
}
- if !kids.is_empty() {
- o.insert("children".into(), Value::Array(kids));
- }
- Some(Value::Object(o))
}
/// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its
diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs
index f4d50891..7900c453 100644
--- a/crates/format/src/fig/map/layout.rs
+++ b/crates/format/src/fig/map/layout.rs
@@ -1,5 +1,5 @@
use super::paint::{bound, color};
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
impl<'a> Mapper<'a> {
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index fe8cfa7b..808e6fc8 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -1,6 +1,6 @@
use super::layer::cap_name;
use super::variables::alias;
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
fn filter_number(value: &Kv) -> Option {
let number = match value {
diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs
index d3f5c022..9238cafb 100644
--- a/crates/format/src/fig/map/proto.rs
+++ b/crates/format/src/fig/map/proto.rs
@@ -1,6 +1,6 @@
use super::paint::color;
use super::variables::variable_data;
-use super::*;
+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
index 7431fbb5..6227c4d0 100644
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -1,5 +1,5 @@
use super::paint::{paint, style_link};
-use super::*;
+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
fn text_num(v: &View, key: &str) -> Option {
diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs
index d27c834b..8d891ded 100644
--- a/crates/format/src/fig/map/variables.rs
+++ b/crates/format/src/fig/map/variables.rs
@@ -1,5 +1,5 @@
use super::paint::color;
-use super::*;
+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs
index 927b0227..09ec0c60 100644
--- a/crates/format/src/fig/source_identity_tests.rs
+++ b/crates/format/src/fig/source_identity_tests.rs
@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) {
let copied_frame = child(doc, copied_body, 0);
assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame");
assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));
+ assert_external_slots(doc, frame, copied_frame, main);
+ assert_external_copy_state(doc, frame, copied_frame);
+}
+
+fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) {
for index in 0..2 {
let slot = child(doc, frame, index);
let copied_slot = child(doc, copied_frame, index);
@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) {
assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0)));
assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0);
}
+}
+
+fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) {
let old = doc.node(child(doc, frame, 0)).unwrap();
let current = doc.node(child(doc, copied_frame, 0)).unwrap();
assert!(!old.visible && old.is_mask && old.opacity < 1.0);
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
index 35d8d08a..c1253048 100644
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -630,63 +630,82 @@ fn imports_the_fixture() {
assert!(is_fig(&bytes));
let (doc, warnings) = import(&bytes, "fallback").unwrap();
assert_eq!(doc.name, "Fixture");
+ assert_fixture_layers(&doc);
+ assert_fixture_styles(&doc);
+ assert_fixture_variables(&doc);
+ assert_fixture_components(&doc);
+ assert_fixture_prototype(&doc, &warnings);
+}
+
+fn assert_fixture_layers(doc: &Document) {
// The internal page is skipped.
assert_eq!(doc.pages.len(), 1);
let page = doc.node(doc.pages[0]).unwrap();
assert_eq!(page.name, "Page A");
// Siblings by position: the frame comes first even though it's listed after the component.
assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
- let screen = named(&doc, "Screen");
+ let screen = named(doc, "Screen");
let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);
// Image fill from the archive.
- let photo = named(&doc, "Photo");
+ let photo = named(doc, "Photo");
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
// Text with a bold run over "the".
- let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
+ let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") };
assert_eq!(t.characters, "Hi there");
assert_eq!(t.style.font_size, 16.0);
assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
assert_eq!(t.runs.len(), 1);
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
// The group's children keep their place on the page (group at 100, 100).
- assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
- let tri = named(&doc, "Tri");
+ assert!(matches!(named(doc, "Group").kind, NodeKind::Group));
+ let tri = named(doc, "Tri");
assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
let abs = doc.world_transform(tri.id).translation();
assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
- assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
- // The linked style supplies the paint.
- let styled = named(&doc, "Styled");
+ assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));
+}
+
+fn assert_fixture_styles(doc: &Document) {
+ let styled = named(doc, "Styled");
assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
assert!(doc.styles.iter().any(|s| s.name == "Brand"));
let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
- // Variables, the fill binding and the explicit mode.
+}
+
+fn assert_fixture_variables(doc: &Document) {
+ let screen = named(doc, "Screen");
assert_eq!(doc.variables.collections.len(), 1);
assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
- assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
+ assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
- // The component keeps its property; the instance is expanded with the value applied.
- let button = named(&doc, "Button");
+}
+
+fn assert_fixture_components(doc: &Document) {
+ let button = named(doc, "Button");
assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
- let inst = named(&doc, "Button copy");
+ let inst = named(doc, "Button copy");
assert!(
matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
);
let icon = doc.node(inst.children[0]).unwrap();
assert_eq!(icon.name, "Renamed icon");
assert!(!icon.visible);
- // Prototype: the click navigates to the component; the flow starts at the screen.
- let it = &photo.interactions[0];
+}
+
+fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {
+ let button = named(doc, "Button");
+ let screen = named(doc, "Screen");
+ let it = &named(doc, "Photo").interactions[0];
assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
assert_eq!(it.duration_ms, 250);
let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
// The sticky note comes in as a frame, with a warning.
- assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));
+ assert!(matches!(named(doc, "Note").kind, NodeKind::Frame));
assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}");
}
@@ -758,6 +777,23 @@ fn a_data_blob_past_the_blobs_stays_a_placeholder() {
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
+/// A file can point any number of image hashes at one large blob; each used to get its own copy
+/// of it (#1059).
+#[test]
+fn hashes_naming_one_blob_share_one_copy_of_it() {
+ let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
+ let msg = o(vec![
+ ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])),
+ ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())),
+ ]);
+ let copies = embedded_copies(&msg);
+ assert_eq!(copies.len(), 51, "the hash naming no blob is left out");
+ let first = &copies[&"01".repeat(20)];
+ assert_eq!(**first, *png());
+ assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50);
+ assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
+}
+
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
diff --git a/crates/format/src/guides_tests.rs b/crates/format/src/guides_tests.rs
new file mode 100644
index 00000000..7fcbc8f1
--- /dev/null
+++ b/crates/format/src/guides_tests.rs
@@ -0,0 +1,45 @@
+//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a
+//! page's guides written twice open again (#1476).
+
+use figcraft_doc::{Document, Guide, NodeKind};
+
+use super::*;
+
+/// A guide: vertical (at an x offset) or horizontal (at a y offset).
+fn guide(vertical: bool, offset: f64) -> Guide {
+ Guide { vertical, offset }
+}
+
+/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again.
+#[test]
+fn page_and_frame_guides_survive_save_and_reopen() {
+ let mut d = Document::new("ruler");
+ let page = d.first_page().unwrap();
+ let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
+ d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]);
+ d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0));
+ let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
+ assert!(issues.is_empty(), "{issues:?}");
+ assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides());
+ assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides());
+ // Saving the reopened file and opening it again (where the file stopped opening).
+ let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap();
+ assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}");
+ let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap();
+ assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides());
+}
+
+/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens
+/// with both lists.
+#[test]
+fn a_file_with_a_page_guides_list_written_twice_opens() {
+ let mut d = Document::new("twice");
+ let page = d.first_page().unwrap();
+ d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0));
+ let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap();
+ // What saving a reopened page wrote: the guides added since, then the ones the file had.
+ let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1);
+ assert_eq!(broken.matches("\"guides\"").count(), 2);
+ let (back, _) = from_json(&broken).unwrap();
+ assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]);
+}
diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs
index e58a4ab1..23bbad0d 100644
--- a/crates/format/src/image_tests.rs
+++ b/crates/format/src/image_tests.rs
@@ -112,3 +112,51 @@ fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>());
let _ = hash;
}
+
+/// An unresolved `imageRef` from a hostile REST file never becomes the image's own key: the
+/// save writer names archive members after keys, so `../../evil` would inject an
+/// `images/../../evil` member into the next save. Such refs store the shared placeholder
+/// under its own key instead.
+#[test]
+fn a_path_like_rest_image_ref_never_names_an_archive_member() {
+ let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
+ "fills": [{"type": "IMAGE", "imageRef": "../../evil", "scaleMode": "FIT"}]});
+ let file = json!({"name": "Hostile", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
+ "children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
+ let (doc, _warnings) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
+ assert!(!doc.images.contains_key("../../evil"), "the raw imageRef must not name an image: {:?}", doc.images.keys().collect::>());
+ assert!(doc.images.contains_key("rest-image-missing"), "the shared placeholder stores it instead: {:?}", doc.images.keys().collect::>());
+ let bytes = save_bytes(&doc, None).unwrap();
+ let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
+ assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>());
+ assert!(
+ z.file_names().any(|n| n == "images/rest-image-missing"),
+ "the placeholder is saved under its own name: {:?}",
+ z.file_names().collect::>()
+ );
+}
+
+/// A key with `-` or `_` is a plain archive member name too: the REST importer's placeholder
+/// (`rest-image-missing`) comes back after a save and load instead of being dropped as damaged.
+#[test]
+fn an_image_keyed_with_dashes_comes_back_after_a_save() {
+ let mut d = Document::new("t");
+ d.images.insert("rest-image-missing".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(png()), width: 2, height: 2 });
+ let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
+ assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
+}
+
+/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash
+/// to it, which they don't: a live session would refuse that image. It shares the placeholder
+/// instead.
+#[test]
+fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() {
+ for image_ref in ["ab".repeat(32), "A".repeat(64)] {
+ let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
+ "fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]});
+ let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
+ "children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
+ let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
+ assert_eq!(doc.images.keys().collect::>(), ["rest-image-missing"], "{image_ref}");
+ }
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 30f6e548..a01aa78b 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -720,9 +720,9 @@ fn read_images(z: &mut zip::ZipArchive, own: Option<&BTreeSet
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
let mut unread = Vec::new();
- // Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back.
+ // Member names that can't be real image keys (`../x`, `a/b`) are never read, so never written back.
hashes.retain(|h| {
- let ok = library::is_valid_hash(h);
+ let ok = ImageData::is_valid_key(h);
if !ok {
unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string())));
}
@@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
+mod guides_tests;
+#[cfg(test)]
mod image_budget_tests;
#[cfg(test)]
mod image_tests;
diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs
index daec05e2..9fc55ebd 100644
--- a/crates/format/src/library.rs
+++ b/crates/format/src/library.rs
@@ -38,18 +38,13 @@ pub fn is_valid_library_id(id: &str) -> bool {
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
-/// Image hashes become archive member names: only plain hex-like names are looked up.
-pub(crate) fn is_valid_hash(h: &str) -> bool {
- (1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
-}
-
/// Write the published library's entries. `written` are the images already in `images/`.
pub(crate) fn write(z: &mut zip::ZipWriter, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
put_json(z, SNAPSHOT, &lib.doc)?;
for (hash, img) in &lib.doc.images {
- if !written.contains(hash) && is_valid_hash(hash) {
+ if !written.contains(hash) && ImageData::is_valid_key(hash) {
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
}
}
@@ -75,7 +70,7 @@ pub(crate) fn read(
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
let (mut doc, _) = from_json(&text)?;
for hash in used_images(&doc) {
- if !is_valid_hash(&hash) {
+ if !ImageData::is_valid_key(&hash) {
continue;
}
if let Some(img) = own.get(&hash) {
@@ -90,9 +85,13 @@ pub(crate) fn read(
}
// Keep what the snapshot really holds; clip what people see.
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
- manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
- manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
- manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
+ // Membership by set lookups: a long manifest against a large snapshot stays linear (#1078).
+ let styles: BTreeSet<&str> = doc.styles.iter().map(|x| x.id.as_str()).collect();
+ let collections: BTreeSet<&str> = doc.variables.collections.iter().map(|x| x.id.as_str()).collect();
+ let variables: BTreeSet<(&str, &str)> = doc.variables.variables.iter().map(|x| (x.id.as_str(), x.collection.as_str())).collect();
+ manifest.styles.retain(|s| styles.contains(s.id.as_str()));
+ manifest.collections.retain(|c| collections.contains(c.id.as_str()));
+ manifest.variables.retain(|v| variables.contains(&(v.id.as_str(), v.collection.as_str())));
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs
index 15fbce9d..f9b83691 100644
--- a/crates/format/src/rest.rs
+++ b/crates/format/src/rest.rs
@@ -947,7 +947,10 @@ impl<'a> Ctx<'a> {
}
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
- /// grey placeholder stored under the `imageRef` itself.
+ /// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
+ /// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
+ /// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
+ /// placeholder.
pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
if let Some(k) = self.image_keys.get(image_ref) {
return k.clone();
@@ -957,7 +960,13 @@ impl<'a> Ctx<'a> {
Some((hash, _, _)) => hash,
None => {
self.missing_images += 1;
- let key = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }.to_string();
+ // An `imageRef` arrives from an untrusted file, and keys become archive member
+ // names on save: a name that can't legitimately be one must not reach the map,
+ // nor one claiming a content hash the placeholder's bytes don't have.
+ let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) };
+ let named = ImageData::is_valid_key(candidate) && !ImageData::claims_content_hash(candidate);
+ let key = if named { candidate } else { MISSING_IMAGE };
+ let key = key.to_string();
if !self.doc.images.contains_key(&key)
&& let Some(png) = placeholder_png()
{
@@ -1018,7 +1027,7 @@ impl<'a> Ctx<'a> {
let n = self.missing_images;
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
self.warn(format!(
- "{n} image{s} without image data {verb} a grey placeholder named by its imageRef (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
+ "{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
));
}
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs
index cbaa3749..d2fdde9a 100644
--- a/crates/format/src/rest/proto_variable_tests.rs
+++ b/crates/format/src/rest/proto_variable_tests.rs
@@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() {
#[test]
fn interactions_triggers_actions_and_transitions() {
let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();
- let id = |n: &str| named(&doc, n).id;
- let button = named(&doc, "Button");
+ assert_interactions(&doc, &warnings);
+ assert_legacy_transition(&doc);
+ assert_interactions_without_variables();
+}
+
+fn assert_interactions(doc: &Document, warnings: &[String]) {
+ let id = |n: &str| named(doc, n).id;
+ let button = named(doc, "Button");
let its = &button.interactions;
assert_eq!(its.len(), 8, "{its:#?}");
@@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() {
assert_eq!(its[7].trigger, Trigger::MouseUp);
assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped");
- // The legacy single transition.
- let details = named(&doc, "Details");
+ assert!(!has(warnings, "modifier keys"), "{warnings:?}");
+ for w in ["video triggers", "without a destination"] {
+ assert!(has(warnings, w), "{w}: {warnings:?}");
+ }
+}
+
+fn assert_legacy_transition(doc: &Document) {
+ let id = |n: &str| named(doc, n).id;
+ let details = named(doc, "Details");
assert_eq!(details.interactions.len(), 1);
let legacy = &details.interactions[0];
assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));
assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));
+}
- assert!(!has(&warnings, "modifier keys"), "{warnings:?}");
- for w in ["video triggers", "without a destination"] {
- assert!(has(&warnings, w), "{w}: {warnings:?}");
- }
- // Without variables, set-variable actions have nothing to set.
+fn assert_interactions_without_variables() {
let (doc, warnings) = import(&file_json(), &no_images).unwrap();
assert_eq!(named(&doc, "Button").interactions.len(), 6);
assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");
diff --git a/crates/format/src/sketch.rs b/crates/format/src/sketch.rs
index 985541b1..8a35bb5e 100644
--- a/crates/format/src/sketch.rs
+++ b/crates/format/src/sketch.rs
@@ -66,7 +66,7 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
/// Largest image member read.
-const MAX_IMAGE: u64 = 256 * 1024 * 1024;
+const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most pages read from one file.
const MAX_PAGES: usize = 1000;
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 02e7bdaf..468a5536 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -333,6 +333,154 @@ struct Item {
base_off: f64,
}
+/// Split flow items into lines, preserving a single empty line when wrapping is off.
+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec> {
+ if !wraps {
+ return vec![(0..items.len()).collect()];
+ }
+ let mut lines = Vec::new();
+ let mut current = Vec::new();
+ let mut used = 0.0;
+ for (i, item) in items.iter().enumerate() {
+ let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };
+ let added = if current.is_empty() { size } else { gap.max(0.0) + size };
+ if !current.is_empty() && used + added > inner_main + 1e-6 {
+ lines.push(std::mem::take(&mut current));
+ used = size;
+ } else {
+ used += added;
+ }
+ current.push(i);
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ lines
+}
+
+/// Resolve each line's main extent and baseline-adjusted cross extent.
+fn flow_line_extents(items: &mut [Item], lines: &[Vec], gap: f64) -> Vec<(f64, f64)> {
+ let mut extents = Vec::with_capacity(lines.len());
+ for line in lines {
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);
+ for i in line {
+ if let Some(item) = items.get_mut(*i) {
+ item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);
+ }
+ }
+ let in_line = || line.iter().filter_map(|i| items.get(*i));
+ let main_used = in_line().map(|item| item.main).sum::() + gaps;
+ let all_fill = in_line().all(|item| item.fill_cross);
+ let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);
+ extents.push((main_used, cross));
+ }
+ extents
+}
+
+/// Resolve cross-axis line spacing and alignment before positioning the flow items.
+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {
+ if extents.len() <= 1 {
+ return (0.0, cross_gap);
+ }
+ let lines_cross: f64 = extents.iter().map(|line| line.1).sum();
+ let between = (extents.len() - 1) as f64;
+ let spread = (inner_cross - lines_cross) / between;
+ if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
+ return (0.0, spread);
+ }
+ let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };
+ let free = inner_cross - lines_cross - cross_gap * between;
+ if free > 0.0 && items.iter().all(|item| item.fill_cross) {
+ let extra = free / extents.len() as f64;
+ extents.iter_mut().for_each(|line| line.1 += extra);
+ return (0.0, cross_gap);
+ }
+ let offset = match al.cross_align {
+ CrossAlign::Min | CrossAlign::Baseline => 0.0,
+ CrossAlign::Center => free / 2.0,
+ CrossAlign::Max => free,
+ };
+ (offset, cross_gap)
+}
+
+/// Collect the size and sizing constraints of the children in flow order.
+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec- {
+ let mut items = Vec::with_capacity(kids.len());
+ for child in kids {
+ let Some(node) = doc.node(*child) else { continue };
+ let (w, h) = outer_size(doc, *child);
+ let cl = &node.child_layout;
+ let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
+ let (min_main, max_main, min_cross, max_cross) =
+ if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
+ items.push(Item {
+ id: *child,
+ main: if horizontal { w } else { h },
+ cross: if horizontal { h } else { w },
+ // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
+ fill_main: sm == Sizing::Fill && !hug_main,
+ fill_cross: sc == Sizing::Fill && !hug_cross,
+ min_main,
+ max_main,
+ min_cross,
+ max_cross,
+ baseline: None,
+ base_off: 0.0,
+ });
+ }
+ items
+}
+
+/// Position one resolved flow line, resizing wrapped fill children across the line.
+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {
+ let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;
+ let total = used - gap * line.len().saturating_sub(1) as f64;
+ let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
+ pos += main_start;
+ for i in line {
+ let Some(it) = items.get(*i) else { continue };
+ let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
+ let off = match al.cross_align {
+ CrossAlign::Min => 0.0,
+ // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
+ CrossAlign::Baseline => it.base_off,
+ CrossAlign::Center => (line_cross - cross_size) / 2.0,
+ CrossAlign::Max => line_cross - cross_size,
+ };
+ if it.fill_cross && wraps {
+ let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
+ resize_child(doc, it.id, w, h, m);
+ }
+ let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
+ place(doc, it.id, x, y);
+ pos += it.main + step_gap;
+ }
+}
+
+struct FlowLineGeometry {
+ horizontal: bool,
+ wraps: bool,
+ main_start: f64,
+ inner_main: f64,
+ cross_pos: f64,
+ line_cross: f64,
+ used: f64,
+ gap: f64,
+}
+
+/// Distribute a line's remaining main-axis space among its fill children.
+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec], inner_main: f64, gap: f64, auto_gap: bool) {
+ for line in lines {
+ let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum();
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect();
+ if !fills.is_empty() {
+ distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });
+ }
+ }
+}
+
/// Lay out one auto layout frame's children and size the frame if it hugs.
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let Some(_nested) = Nested::enter() else { return };
@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let hug_cross = if horizontal { hug_h } else { hug_w };
let baseline = horizontal && al.cross_align == CrossAlign::Baseline;
- let mut items: Vec
- = Vec::with_capacity(kids.len());
- for c in &kids {
- let Some(n) = doc.node(*c) else { continue };
- let (w, h) = outer_size(doc, *c);
- let cl = &n.child_layout;
- let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
- let (min_main, max_main, min_cross, max_cross) =
- if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
- items.push(Item {
- id: *c,
- main: if horizontal { w } else { h },
- cross: if horizontal { h } else { w },
- // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
- fill_main: sm == Sizing::Fill && !hug_main,
- fill_cross: sc == Sizing::Fill && !hug_cross,
- min_main,
- max_main,
- min_cross,
- max_cross,
- baseline: None,
- base_off: 0.0,
- });
- }
+ let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross);
let gap = finite_or_zero(al.gap);
let auto_gap = al.main_align.is_auto_gap();
@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).
- let mut lines: Vec> = Vec::new();
- if wraps {
- let mut cur: Vec = Vec::new();
- let mut used = 0.0;
- for (i, it) in items.iter().enumerate() {
- let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
- let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
- if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
- lines.push(std::mem::take(&mut cur));
- used = sz;
- } else {
- used += add;
- }
- cur.push(i);
- }
- if !cur.is_empty() {
- lines.push(cur);
- }
- } else {
- lines.push((0..items.len()).collect());
- }
+ let lines = flow_lines(&items, wraps, gap, inner_main_fixed);
// Share each line's free space among its fill children.
- for line in &lines {
- let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
- let gaps = gap * line.len().saturating_sub(1) as f64;
- let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
- if !fills.is_empty() {
- distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });
- }
- }
+ distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap);
// Fill children take their main size now, so their cross size (wrapped text, a hugging frame)
// is final before the lines and the frame are sized.
for it in items.iter_mut().filter(|it| it.fill_main) {
@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Line extents: (main used, cross size).
- let mut line_extents: Vec<(f64, f64)> = Vec::new();
- for line in &lines {
- let gaps = gap * line.len().saturating_sub(1) as f64;
- // Baseline alignment: the line's baseline sits at the deepest first baseline.
- let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);
- for i in line {
- if let Some(it) = items.get_mut(*i) {
- it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);
- }
- }
- let in_line = || line.iter().filter_map(|i| items.get(*i));
- let main_used: f64 = in_line().map(|it| it.main).sum::() + gaps;
- // A line of nothing but fill-across children keeps their own size.
- let all_fill = in_line().all(|it| it.fill_cross);
- let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);
- line_extents.push((main_used, cross));
- }
+ let mut line_extents = flow_line_extents(&mut items, &lines, gap);
// Container size (on a hugging axis fill children can't fill: they count with their own size).
let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
@@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
// Position. Several lines share the free space across the flow: packed lines sit where the
// cross alignment says (lines of nothing but fill children stretch over it instead), or the
// space goes between them.
- let mut cross_pos = if horizontal { pt } else { pl };
+ let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);
+ let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset;
let single = lines.len() == 1;
- let mut cross_gap = cross_gap;
- if lines.len() > 1 {
- let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();
- let between = (lines.len() - 1) as f64;
- let spread = (inner_cross - lines_cross) / between;
- if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
- cross_gap = spread;
- } else {
- if al.content_align == ContentAlign::SpaceBetween {
- cross_gap = 0.0;
- }
- let free = inner_cross - lines_cross - cross_gap * between;
- if free > 0.0 && items.iter().all(|it| it.fill_cross) {
- let extra = free / lines.len() as f64;
- line_extents.iter_mut().for_each(|l| l.1 += extra);
- } else {
- cross_pos += match al.cross_align {
- CrossAlign::Min | CrossAlign::Baseline => 0.0,
- CrossAlign::Center => free / 2.0,
- CrossAlign::Max => free,
- };
- }
- }
- }
for (li, line) in lines.iter().enumerate() {
let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
let line_cross = if single { inner_cross } else { line_cross };
- let total = used - gap * line.len().saturating_sub(1) as f64;
- let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
- pos += if horizontal { pl } else { pt };
- for i in line {
- let Some(it) = items.get(*i) else { continue };
- let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
- let off = match al.cross_align {
- CrossAlign::Min => 0.0,
- // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
- CrossAlign::Baseline => it.base_off,
- CrossAlign::Center => (line_cross - cross_size) / 2.0,
- CrossAlign::Max => line_cross - cross_size,
- };
- // Fill across a wrapped line: its size is known only now.
- if it.fill_cross && wraps {
- let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
- resize_child(doc, it.id, w, h, m);
- }
- let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
- place(doc, it.id, x, y);
- pos += it.main + step_gap;
- }
+ let geometry =
+ FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };
+ place_flow_line(doc, &items, line, &al, m, geometry);
cross_pos += line_cross + cross_gap;
}
}
diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs
index fcdb21a3..3a76064b 100644
--- a/crates/mcp/src/headless.rs
+++ b/crates/mcp/src/headless.rs
@@ -284,6 +284,14 @@ impl Backend for Headless {
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
}
}
+ "events.poll" => {
+ let after = p.get("after").and_then(Value::as_u64).unwrap_or(0);
+ let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
+ let topics = figcraft_engine::events::topics(p.get("topics"))?;
+ // Headless has no other writer to wait for.
+ let result = self.session.events.poll(after, &topics, limit, std::time::Duration::ZERO);
+ serde_json::to_value(result).map_err(|e| e.to_string())
+ }
"app.quit" => Ok(json!({"ok": true})),
other => Err(format!("unknown method `{other}`")),
}
diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs
index 9ca6e39f..5cf63c13 100644
--- a/crates/mcp/src/prompts.rs
+++ b/crates/mcp/src/prompts.rs
@@ -75,6 +75,40 @@ pub const PROMPTS: &[PromptDef] = &[
template: "Build a clickable prototype in FigCraft with these screens, in order: {screens}.\n\n1. If the frames don't exist, create one phone frame per screen (run_command frame.create {\"preset\": \"iPhone 16\", …}) side by side 100 units apart, each with a title and a \"Next\" button (a frame with text inside).\n2. For each screen except the last, add an interaction on its button: run_command proto.interaction.add {\"id\":