mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 00:41:06 +00:00
Merge main into #1636
This commit is contained in:
commit
027a0bbf92
57 files changed
+2357
-279
No files matched your search
+5
-2
@@ -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-30 · **Change:** minor (every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a jLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rLine truncated
|
||||
|
||||
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
|
||||
@@ -58,7 +58,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged.
|
||||
- **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.
|
||||
@@ -355,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. |
|
||||
| 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. |
|
||||
| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged |
|
||||
@@ -392,6 +394,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
|
||||
| 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) |
|
||||
| 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) |
|
||||
|
||||
@@ -103,10 +103,12 @@ impl Expr {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
|
||||
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
|
||||
Expr::Value(v) => return Some(v.clone()),
|
||||
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone()));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable)?;
|
||||
let c = vars.collection(&v.collection)?;
|
||||
let v = vars.get_by_id(variable)?;
|
||||
let c = vars.collection_by_id(&v.collection)?;
|
||||
let mut at = modes.clone();
|
||||
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
|
||||
return vars.resolve(&v.id, &at);
|
||||
@@ -143,20 +145,22 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
|
||||
Expr::Value(v) => v.display(),
|
||||
Expr::Mode { collection } => {
|
||||
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
format!("mode({})", name_arg(name))
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let var = vars.get(variable);
|
||||
let mode =
|
||||
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
|
||||
let var = vars.get_by_id(variable);
|
||||
let mode = var
|
||||
.and_then(|v| vars.collection_by_id(&v.collection))
|
||||
.and_then(|c| c.find_mode(mode))
|
||||
.map_or("(missing mode)", |m| m.name.as_str());
|
||||
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
|
||||
}
|
||||
Expr::Op { op, args } => {
|
||||
|
||||
@@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
|
||||
assert_eq!(mode_of("gone").eval(&vars, &light), None);
|
||||
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_expression_evaluation_display_and_types() {
|
||||
let mut vars = more_vars();
|
||||
let mut shadow = vars.get_by_id("s").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "n".into();
|
||||
vars.variables.insert(0, shadow);
|
||||
let mut shadow = vars.collection("cs").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
vars.collections.insert(0, shadow);
|
||||
let modes = BTreeMap::new();
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.type_of(&vars), Ok(VariableType::Number));
|
||||
assert!(e.display(&vars).starts_with("n-name"));
|
||||
}
|
||||
assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0)));
|
||||
assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0)));
|
||||
assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark")));
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into())));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(Theme)");
|
||||
|
||||
vars.variables.retain(|v| v.id != "n");
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.eval(&vars, &modes), None);
|
||||
assert_eq!(e.type_of(&vars), Err("No variable `n`".into()));
|
||||
assert!(e.display(&vars).contains("missing variable"));
|
||||
}
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), None);
|
||||
assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into()));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")");
|
||||
}
|
||||
@@ -88,15 +88,15 @@ impl Expr {
|
||||
return Err("The expression is nested too deeply".into());
|
||||
}
|
||||
let (op, args) = match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
|
||||
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
@@ -501,7 +501,7 @@ impl Parser<'_> {
|
||||
self.i += 1;
|
||||
let start = self.i;
|
||||
let name = self.argument(']', "mode")?;
|
||||
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
|
||||
let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection));
|
||||
match coll.and_then(|c| c.find_mode(&name)) {
|
||||
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
|
||||
None => {
|
||||
|
||||
@@ -278,13 +278,18 @@ impl Variables {
|
||||
self.collections.iter().find(|c| c.id == id || c.name == id)
|
||||
}
|
||||
|
||||
/// Look up a stored collection reference by exact id, without matching collection names.
|
||||
pub fn collection_by_id(&self, id: &str) -> Option<&Collection> {
|
||||
self.collections.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
|
||||
/// (bounded, cycles return None).
|
||||
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
|
||||
let mut cur = id.to_string();
|
||||
for _ in 0..32 {
|
||||
let var = self.get_by_id(&cur)?;
|
||||
let coll = self.collection(&var.collection)?;
|
||||
let coll = self.collection_by_id(&var.collection)?;
|
||||
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
|
||||
match var.values.get(mode).or_else(|| var.values.values().next())? {
|
||||
VariableValue::Alias(next) => cur = next.clone(),
|
||||
|
||||
@@ -708,3 +708,17 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let path = net.fill_path();
|
||||
assert!(!path.elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_their_exact_collection() {
|
||||
let mut vars = crate::proto_expr_tests::vars();
|
||||
// The wrong collection's modes force the old resolver's first-value fallback (dark).
|
||||
let mut shadow = vars.collections[0].clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }];
|
||||
vars.collections.insert(0, shadow);
|
||||
assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0)));
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(vars.resolve("n", &Default::default()), None);
|
||||
}
|
||||
@@ -240,7 +240,10 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
|
||||
.collections
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str()))
|
||||
pick(
|
||||
prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())),
|
||||
doc.variables.collection_by_id(c).map(|x| x.name.as_str()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -251,7 +254,7 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>)
|
||||
let mut out = BTreeSet::new();
|
||||
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(v) = vars.get(&cur) else { continue };
|
||||
let Some(v) = vars.get_by_id(&cur) else { continue };
|
||||
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -702,7 +705,7 @@ pub fn import_variables(
|
||||
) -> VariableMap {
|
||||
let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables);
|
||||
let vars = alias_closure(src, keys.iter().cloned());
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect();
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect();
|
||||
let mut out = VariableMap::default();
|
||||
// Collections first: the variables name theirs.
|
||||
for key in colls {
|
||||
@@ -758,7 +761,7 @@ pub fn import_variables(
|
||||
let val = match val {
|
||||
// An alias to a variable the library doesn't have resolves to nothing: dropped
|
||||
// (never pointed at a variable of this file).
|
||||
VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) {
|
||||
VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) {
|
||||
Some(l) => VariableValue::Alias(l.clone()),
|
||||
None => continue,
|
||||
},
|
||||
@@ -955,7 +958,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
|
||||
.filter(|r| r.library == m.id)
|
||||
.map(|r| r.key.as_str())
|
||||
.collect();
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some());
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some());
|
||||
variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord {
|
||||
library: m.id.clone(),
|
||||
key: v.id.clone(),
|
||||
|
||||
@@ -149,7 +149,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
out.styles = styles.len();
|
||||
|
||||
// Variables: by collection and variable name and type; collections and modes by name.
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
// Variables the document's own layers bind or its own variables alias.
|
||||
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
|
||||
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
|
||||
@@ -161,7 +161,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
|
||||
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &var_recs {
|
||||
let Some(v) = doc.variables.get(&r.local) else { continue };
|
||||
let Some(v) = doc.variables.get_by_id(&r.local) else { continue };
|
||||
let cname = coll_name(doc, &v.collection);
|
||||
let found = lib_coll(&cname).and_then(|c| {
|
||||
to.doc
|
||||
@@ -189,10 +189,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// A's collection → (B's, A's mode id → B's mode of the same name).
|
||||
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
|
||||
for (a, k) in &coll_keys {
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue };
|
||||
let modes = doc
|
||||
.variables
|
||||
.collection(b)
|
||||
.collection_by_id(b)
|
||||
.map(|bc| {
|
||||
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
|
||||
})
|
||||
|
||||
@@ -114,3 +114,35 @@ fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
|
||||
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
|
||||
let a = library(&pa, "#0000ff", &[]);
|
||||
let b = library(&pb, "#00ff00", &[]);
|
||||
let mut s = Session::new();
|
||||
let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"}));
|
||||
ex(&mut s, "library.add", json!({"path": pa}));
|
||||
ex(&mut s, "library.add", json!({"path": pb}));
|
||||
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
|
||||
ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let target = doc.libraries.variables[0].local.clone();
|
||||
let collection = doc.libraries.collections[0].local.clone();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection}));
|
||||
let result = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!(result["variables"], 1);
|
||||
assert_eq!(result["unmatched"], json!([]));
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(dot).unwrap();
|
||||
assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert!(!node.bound.is_empty());
|
||||
assert!(!node.var_modes.is_empty());
|
||||
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
|
||||
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
|
||||
}
|
||||
@@ -5,32 +5,188 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Top-level node keys accepted by `fields`, including the structure kept in every tree.
|
||||
pub const NODE_KEYS: &[&str] = &[
|
||||
"absolute",
|
||||
"absoluteBounds",
|
||||
"annotations",
|
||||
"arc",
|
||||
"background",
|
||||
"blend",
|
||||
"board",
|
||||
"booleanOperation",
|
||||
"boundVariables",
|
||||
"brush",
|
||||
"characters",
|
||||
"childCount",
|
||||
"children",
|
||||
"clip",
|
||||
"codeLinks",
|
||||
"componentProperties",
|
||||
"componentPropertyDefinitions",
|
||||
"constraints",
|
||||
"cornerRadius",
|
||||
"cornerSmoothing",
|
||||
"dashes",
|
||||
"description",
|
||||
"devChanged",
|
||||
"devNote",
|
||||
"devResources",
|
||||
"devStatus",
|
||||
"effects",
|
||||
"exports",
|
||||
"fills",
|
||||
"flows",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontStyle",
|
||||
"fontVariations",
|
||||
"gridCell",
|
||||
"grids",
|
||||
"hangingList",
|
||||
"hangingPunctuation",
|
||||
"height",
|
||||
"id",
|
||||
"innerRadius",
|
||||
"interactions",
|
||||
"isMask",
|
||||
"layout",
|
||||
"leadingTrim",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
"listSpacing",
|
||||
"listStartOffsets",
|
||||
"listStyle",
|
||||
"locked",
|
||||
"mainComponent",
|
||||
"mainComponentName",
|
||||
"maskType",
|
||||
"maxHeight",
|
||||
"maxLines",
|
||||
"maxWidth",
|
||||
"measurements",
|
||||
"minHeight",
|
||||
"minWidth",
|
||||
"miterAngle",
|
||||
"name",
|
||||
"opacity",
|
||||
"operandOperation",
|
||||
"overrides",
|
||||
"pluginData",
|
||||
"pointCount",
|
||||
"rotation",
|
||||
"sectionContentsHidden",
|
||||
"segments",
|
||||
"sizing",
|
||||
"slot",
|
||||
"strokeAlign",
|
||||
"strokeJoin",
|
||||
"strokeSides",
|
||||
"strokeWeight",
|
||||
"strokes",
|
||||
"styledRuns",
|
||||
"styles",
|
||||
"textAlign",
|
||||
"textAutoResize",
|
||||
"textDecoration",
|
||||
"textDecorationColor",
|
||||
"textDecorationOffset",
|
||||
"textDecorationSkipInk",
|
||||
"textDecorationStyle",
|
||||
"textDecorationThickness",
|
||||
"textPath",
|
||||
"textStyleRanges",
|
||||
"type",
|
||||
"vertices",
|
||||
"visible",
|
||||
"width",
|
||||
"widthProfile",
|
||||
"x",
|
||||
"y",
|
||||
];
|
||||
|
||||
/// Which layer properties to build. Missing options keep the full, sparse inspection output.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InspectOptions {
|
||||
fields: Option<std::collections::BTreeSet<&'static str>>,
|
||||
outline: bool,
|
||||
}
|
||||
|
||||
impl InspectOptions {
|
||||
/// Parse the same options for engine commands, control methods and MCP tools.
|
||||
pub fn parse(p: &Value) -> Result<Self, String> {
|
||||
let outline = match p.get("outline") {
|
||||
None => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
Some(_) => return Err("`outline` must be a boolean".into()),
|
||||
};
|
||||
if outline && p.get("fields").is_some() {
|
||||
return Err("`outline: true` cannot be combined with `fields`".into());
|
||||
}
|
||||
let fields = match p.get("fields") {
|
||||
None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()),
|
||||
None => None,
|
||||
Some(Value::Array(values)) => {
|
||||
let mut fields = std::collections::BTreeSet::new();
|
||||
for value in values {
|
||||
let key = value.as_str().ok_or("`fields` must be an array of strings")?;
|
||||
let key = NODE_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|k| *k == key)
|
||||
.ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?;
|
||||
fields.insert(key);
|
||||
}
|
||||
Some(fields)
|
||||
}
|
||||
Some(_) => return Err("`fields` must be an array of strings".into()),
|
||||
};
|
||||
Ok(Self { fields, outline })
|
||||
}
|
||||
|
||||
fn includes(&self, key: &str) -> bool {
|
||||
self.fields.as_ref().is_none_or(|fields| fields.contains(key))
|
||||
}
|
||||
|
||||
fn explicit(&self, key: &str) -> bool {
|
||||
self.fields.as_ref().is_some_and(|fields| fields.contains(key))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
node_json_with_options(doc, id, depth, &InspectOptions::default())
|
||||
}
|
||||
|
||||
/// A node's selected properties, applied at every level of its subtree.
|
||||
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
"type": n.kind.type_name(),
|
||||
});
|
||||
let mut v = json!({"id": n.id});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
insert_prop(o, options, "name", || json!(n.name));
|
||||
insert_prop(o, options, "type", || json!(n.kind.type_name()));
|
||||
if options.explicit("visible") {
|
||||
insert_prop(o, options, "visible", || json!(n.visible));
|
||||
}
|
||||
if !n.is_page() {
|
||||
base_props(doc, n, id, o);
|
||||
paint_props(n, o);
|
||||
layout_props(doc, n, o);
|
||||
dev_props(doc, n, o);
|
||||
base_props(doc, n, id, o, options);
|
||||
paint_props(n, o, options);
|
||||
layout_props(doc, n, o, options);
|
||||
dev_props(doc, n, o, options);
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
insert_prop(o, options, "pluginData", || json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => text_props(t, o),
|
||||
_ => kind_props(doc, n, o),
|
||||
NodeKind::Text(t) => text_props(t, o, options),
|
||||
_ => kind_props(doc, n, o, options),
|
||||
}
|
||||
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()));
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
@@ -38,272 +194,301 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
|
||||
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));
|
||||
/// Build a property only when it was requested (or no field list was given).
|
||||
fn insert_prop(o: &mut serde_json::Map<String, Value>, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) {
|
||||
if options.includes(key) {
|
||||
o.insert(key.into(), value());
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if options.includes("x") || options.includes("y") {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
insert_prop(o, options, "x", || json!(round(c[4])));
|
||||
insert_prop(o, options, "y", || json!(round(c[5])));
|
||||
}
|
||||
insert_prop(o, options, "width", || json!(round(n.width)));
|
||||
insert_prop(o, options, "height", || json!(round(n.height)));
|
||||
if options.includes("rotation") {
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
insert_prop(o, options, "rotation", || json!(round(rot)));
|
||||
}
|
||||
}
|
||||
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
|
||||
insert_prop(
|
||||
o,
|
||||
options,
|
||||
"absoluteBounds",
|
||||
|| json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}),
|
||||
);
|
||||
}
|
||||
if !n.visible {
|
||||
insert_prop(o, options, "visible", || json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
insert_prop(o, options, "locked", || json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
insert_prop(o, options, "opacity", || json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
insert_prop(o, options, "blend", || json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
insert_prop(o, options, "fills", || 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));
|
||||
insert_prop(o, options, "strokes", || paints(&n.strokes));
|
||||
insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight));
|
||||
insert_prop(o, options, "strokeAlign", || json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
insert_prop(o, options, "strokeSides", || json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
insert_prop(o, options, "strokeJoin", || 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));
|
||||
insert_prop(o, options, "miterAngle", || 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}));
|
||||
insert_prop(o, options, "widthProfile", || {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
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)));
|
||||
insert_prop(o, options, "brush", || 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)));
|
||||
insert_prop(o, options, "effects", || 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),
|
||||
},
|
||||
);
|
||||
insert_prop(o, options, "cornerRadius", || 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));
|
||||
insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "layout", || 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}));
|
||||
insert_prop(o, options, "sizing", || 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));
|
||||
insert_prop(o, options, "absolute", || 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));
|
||||
insert_prop(o, options, k, || json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
if let Some(cell) = (parent_al && options.includes("gridCell"))
|
||||
.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(),
|
||||
insert_prop(o, options, "gridCell", || {
|
||||
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}),
|
||||
);
|
||||
"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));
|
||||
insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
insert_prop(o, options, "clip", || json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
insert_prop(o, options, "devStatus", || json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
insert_prop(o, options, "devChanged", || json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
insert_prop(o, options, "devNote", || json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
insert_prop(o, options, "isMask", || json!(true));
|
||||
insert_prop(o, options, "maskType", || 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)));
|
||||
insert_prop(o, options, "grids", || 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));
|
||||
insert_prop(o, options, "exports", || 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));
|
||||
insert_prop(o, options, "board", || 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));
|
||||
insert_prop(o, options, "annotations", || 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)));
|
||||
insert_prop(o, options, "codeLinks", || 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));
|
||||
insert_prop(o, options, "measurements", || 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 options.includes("devResources") {
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
insert_prop(o, options, "devResources", || dev);
|
||||
}
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "interactions", || 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));
|
||||
insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
insert_prop(o, options, "boundVariables", || json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
insert_prop(o, options, "overrides", || json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
|
||||
insert_prop(o, options, "slot", || 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));
|
||||
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
insert_prop(o, options, "operandOperation", || json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
|
||||
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));
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
insert_prop(o, options, "characters", || {
|
||||
if options.outline {
|
||||
let mut chars = t.characters.chars();
|
||||
let mut text: String = chars.by_ref().take(120).collect();
|
||||
if chars.next().is_some() {
|
||||
text.push_str("...");
|
||||
}
|
||||
json!(text)
|
||||
} else {
|
||||
json!(t.characters)
|
||||
}
|
||||
});
|
||||
insert_prop(o, options, "fontFamily", || json!(t.style.font_family));
|
||||
insert_prop(o, options, "fontStyle", || json!(t.style.font_style));
|
||||
insert_prop(o, options, "fontSize", || json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
insert_prop(o, options, "fontVariations", || 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));
|
||||
insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label()));
|
||||
insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label()));
|
||||
insert_prop(o, options, "textAlign", || json!(t.align_h));
|
||||
insert_prop(o, options, "textAutoResize", || json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
insert_prop(o, options, "maxLines", || json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
insert_prop(o, options, "textPath", || json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
insert_prop(o, options, "textDecoration", || 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()))));
|
||||
insert_prop(o, options, "textDecorationStyle", || json!(l.style));
|
||||
insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label()));
|
||||
insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label()));
|
||||
insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink));
|
||||
insert_prop(o, options, "textDecorationColor", || 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));
|
||||
insert_prop(o, options, "listStyle", || 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());
|
||||
insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
insert_prop(o, options, "lineIndentations", || 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());
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
insert_prop(o, options, k, || json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "styledRuns", || 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<Value> = 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));
|
||||
if options.includes("textStyleRanges") {
|
||||
let linked: Vec<Value> = 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() {
|
||||
insert_prop(o, options, "textStyleRanges", || Value::Array(linked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
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}));
|
||||
insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
o.insert("innerRadius".into(), json!(inner_ratio));
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
insert_prop(o, options, "innerRadius", || json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
o.insert("booleanOperation".into(), json!(op));
|
||||
insert_prop(o, options, "booleanOperation", || json!(op));
|
||||
}
|
||||
NodeKind::Instance { component, props } => {
|
||||
o.insert("mainComponent".into(), json!(component));
|
||||
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
insert_prop(o, options, "mainComponent", || json!(component));
|
||||
insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
o.insert("componentProperties".into(), json!(props));
|
||||
insert_prop(o, options, "componentProperties", || json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props, .. } => {
|
||||
if !description.is_empty() {
|
||||
o.insert("description".into(), json!(description));
|
||||
insert_prop(o, options, "description", || json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
o.insert("vertices".into(), json!(network.vertices.len()));
|
||||
o.insert("segments".into(), json!(network.segments.len()));
|
||||
insert_prop(o, options, "vertices", || json!(network.vertices.len()));
|
||||
insert_prop(o, options, "segments", || json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
o.insert("background".into(), json!(background.to_css_hex()));
|
||||
insert_prop(o, options, "background", || json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -339,10 +524,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value {
|
||||
|
||||
/// The whole document (current page in depth, other pages summarised).
|
||||
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
document_with_options(s, depth, all_pages, &InspectOptions::default())
|
||||
}
|
||||
|
||||
/// The document metadata and a tree built with only the selected layer properties.
|
||||
pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value {
|
||||
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
|
||||
let doc = &ds.doc;
|
||||
let pages: Vec<Value> =
|
||||
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
|
||||
doc.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) }
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": ds.uid,
|
||||
"name": doc.name,
|
||||
|
||||
@@ -305,9 +305,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("node.set", "Set properties", super::props::KEYS, true, set),
|
||||
spec!("node.get", "Get layer", "{id}", false, |s, p| {
|
||||
spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| {
|
||||
let i = req_id("node.get", p, "id")?;
|
||||
Ok(crate::inspect::node_json(s.doc()?, i, 1))
|
||||
let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?;
|
||||
Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options))
|
||||
}),
|
||||
spec!(
|
||||
"node.find",
|
||||
|
||||
@@ -245,7 +245,7 @@ fn alias(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind);
|
||||
let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?;
|
||||
let (lib, key) = variable_arg(s, cmd, p, "alias")?;
|
||||
if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) {
|
||||
if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) {
|
||||
return Err(bad(cmd, "the library variable has another type"));
|
||||
}
|
||||
let local = import_variable(s, &lib, &key)?;
|
||||
@@ -474,7 +474,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn assets_json(lib: &PublishedLibrary) -> Value {
|
||||
let m = &lib.manifest;
|
||||
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
let modes =
|
||||
|c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
json!({
|
||||
"components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::<Vec<_>>(),
|
||||
"styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::<Vec<_>>(),
|
||||
@@ -520,7 +521,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
res["publishable"] = json!({
|
||||
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
|
||||
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
});
|
||||
}
|
||||
Ok(res)
|
||||
|
||||
@@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Variables, collections and modes that actions name by name, stored by id.
|
||||
fn canonical_ids(doc: &Document, it: &mut Interaction) {
|
||||
canonical_actions(&doc.variables, &mut it.actions);
|
||||
}
|
||||
|
||||
/// Variables (also those expressions read), collections and modes that `actions` name by name,
|
||||
/// stored by id.
|
||||
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
|
||||
walk_mut(actions, &mut |a| match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
*variable = v.id.clone();
|
||||
}
|
||||
if let Some(e) = expr {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
if let Some(c) = vars.collection(collection) {
|
||||
if let Some(m) = c.find_mode(mode) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*collection = c.id.clone();
|
||||
}
|
||||
}
|
||||
Action::Conditional { blocks } => {
|
||||
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Variables, collections and modes an expression names by name, stored by id.
|
||||
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
@@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*variable = v.id.clone();
|
||||
@@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut it = it;
|
||||
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
|
||||
settle(&mut it);
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
let mut index = 0;
|
||||
for i in req_targets(cmd, s, p)? {
|
||||
@@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if it.actions.is_empty() {
|
||||
return Err(bad(cmd, "an interaction needs an action"));
|
||||
}
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
|
||||
*slot = it;
|
||||
|
||||
@@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` with every `expression` text in it read: on a set variable action it becomes `expr`
|
||||
/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give
|
||||
/// true or false).
|
||||
/// `p` with incoming names stored as ids and every `expression` text read: on a set variable
|
||||
/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the
|
||||
/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never
|
||||
/// canonicalized again. Existing actions that a command leaves alone never pass through here.
|
||||
pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result<Value> {
|
||||
let mut v = p.clone();
|
||||
canonical_inputs(cmd, vars, &mut v, 0)?;
|
||||
resolve_expressions(cmd, vars, &mut v, 0)?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Visit input actions and conditional blocks, never literal values or parsed expressions.
|
||||
fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
canonical_action_names(vars, o);
|
||||
canonical_json_exprs(cmd, vars, o)?;
|
||||
for key in ["action", "actions", "interaction", "blocks"] {
|
||||
if let Some(child) = o.get_mut(key) {
|
||||
canonical_inputs(cmd, vars, child, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only command input accepts names: settle action targets before reading text expressions.
|
||||
fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map<String, Value>) {
|
||||
match o.get("type").and_then(Value::as_str) {
|
||||
Some("setVariable") => {
|
||||
if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) {
|
||||
o.insert("variable".into(), json!(v.id));
|
||||
}
|
||||
}
|
||||
Some("setVariableMode") => {
|
||||
if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) {
|
||||
if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) {
|
||||
o.insert("mode".into(), json!(m.id));
|
||||
}
|
||||
o.insert("collection".into(), json!(c.id));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON expressions accept names too; text replaces them and is parsed separately.
|
||||
fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
if o.get("expression").is_some_and(Value::is_string) {
|
||||
return Ok(());
|
||||
}
|
||||
for key in ["expr", "condition"] {
|
||||
if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) {
|
||||
let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?;
|
||||
super::proto::canonical_expr(vars, &mut e, 0);
|
||||
*value = expr_json(cmd, &e)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
@@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize)
|
||||
let var = o
|
||||
.get("variable")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|n| vars.get(n))
|
||||
.and_then(|n| vars.get_by_id(n))
|
||||
.ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?;
|
||||
e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?;
|
||||
o.insert("expr".into(), expr_json(cmd, &e)?);
|
||||
@@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
for x in walk(std::slice::from_ref(a)) {
|
||||
match x {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
if let Some(e) = expr {
|
||||
e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?;
|
||||
}
|
||||
@@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
fn action_param(cmd: &str, s: &Session, p: &Value) -> Result<Action> {
|
||||
let doc = s.doc()?;
|
||||
let p = with_expressions(cmd, &doc.variables, p)?;
|
||||
let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a));
|
||||
let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
check_action(cmd, &doc.variables, &a)?;
|
||||
super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?;
|
||||
Ok(a)
|
||||
|
||||
@@ -958,7 +958,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = req_string(cmd, p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
||||
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let mode = match string(p, "mode") {
|
||||
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
|
||||
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
|
||||
|
||||
@@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError {
|
||||
|
||||
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
|
||||
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
|
||||
match doc.variables.collection(collection) {
|
||||
match doc.variables.collection_by_id(collection) {
|
||||
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -304,7 +304,7 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
|
||||
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
|
||||
for (m, val) in &v.values {
|
||||
let VariableValue::Alias(a) = val else { continue };
|
||||
let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue };
|
||||
let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue };
|
||||
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
|
||||
if let Some(r) = vars.resolve(&t.id, &modes) {
|
||||
detached.push((v.id.clone(), m.clone(), r));
|
||||
@@ -333,11 +333,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = req_string(cmd, p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
||||
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
|
||||
let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned();
|
||||
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
|
||||
let modes: Vec<ModeId> = match string(p, "mode") {
|
||||
Some(m) => {
|
||||
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
|
||||
}
|
||||
None => var.overrides.iter().cloned().collect(),
|
||||
@@ -433,6 +433,12 @@ pub fn sync_extensions(doc: &mut Document) {
|
||||
/// lead back to `var`. Returns the target's id.
|
||||
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
|
||||
let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
|
||||
alias_target_by_id(vars, var, &t.id)
|
||||
}
|
||||
|
||||
/// Check an alias whose target is already a stored or remapped id, without matching names.
|
||||
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
|
||||
let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
|
||||
if t.kind != var.kind {
|
||||
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
|
||||
}
|
||||
@@ -445,9 +451,9 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu
|
||||
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
|
||||
continue;
|
||||
}
|
||||
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
if let VariableValue::Alias(a) = v
|
||||
&& let Some(next) = vars.get(a)
|
||||
&& let Some(next) = vars.get_by_id(a)
|
||||
{
|
||||
stack.push(next.id.clone());
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(VariableValue::Alias(a)) => match vars.get_by_id(a) {
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
@@ -330,7 +330,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
VariableValue::Alias(a) => {
|
||||
let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue };
|
||||
let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string());
|
||||
match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) {
|
||||
match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) {
|
||||
Ok(t) => VariableValue::Alias(t),
|
||||
Err(e) => {
|
||||
warnings.push(format!("`{}`: {e}", v.name));
|
||||
|
||||
@@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'
|
||||
_ => &[],
|
||||
};
|
||||
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
|
||||
found.and_then(|v| doc.variables.get(v))
|
||||
found.and_then(|v| doc.variables.get_by_id(v))
|
||||
}
|
||||
|
||||
/// The variable bound to `field` on `n`, by name.
|
||||
|
||||
@@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() {
|
||||
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_their_dev_inspect_token() {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap();
|
||||
let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
|
||||
let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap());
|
||||
s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap();
|
||||
|
||||
let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap();
|
||||
assert!(code["code"].as_str().unwrap().contains("var(--real-surface"));
|
||||
assert!(!code["code"].as_str().unwrap().contains("--shadow"));
|
||||
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
|
||||
let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap();
|
||||
assert_eq!(fills["rows"][0]["color"], "#0D99FF");
|
||||
assert_eq!(fills["rows"][0]["token"], "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_section_for_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(outline(s.doc().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_binding_identity() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})));
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"}));
|
||||
run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}}));
|
||||
run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}));
|
||||
run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap();
|
||||
let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap();
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
let style = back.styles.iter().find(|s| s.name == "Surface").unwrap();
|
||||
assert!(!style.bound.is_empty());
|
||||
for bound in style.bound.values() {
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Compact inspection keeps the tree useful without building every layer property.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, params: Value) -> NodeId {
|
||||
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn tree() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
|
||||
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
|
||||
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
|
||||
(s, frame, text)
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
|
||||
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap())
|
||||
}
|
||||
|
||||
fn keys(v: &Value, allowed: &[&str]) {
|
||||
for (key, value) in v.as_object().unwrap() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
|
||||
if key == "children" {
|
||||
for child in value.as_array().unwrap() {
|
||||
keys(child, allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_tree() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(
|
||||
&mut s,
|
||||
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
|
||||
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
|
||||
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
|
||||
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
|
||||
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
|
||||
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
|
||||
);
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
|
||||
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
(s, frame)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_options_keep_the_main_tree_and_document_output() {
|
||||
let (s, frame) = golden_tree();
|
||||
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the
|
||||
// document `id` that #1533 added. That id comes from a process-wide counter, so it is the
|
||||
// session's own.
|
||||
let expected = json!({
|
||||
"colorProfile": "srgb",
|
||||
"currentPage": 7,
|
||||
"dirty": true,
|
||||
"history": {
|
||||
"redo": 0,
|
||||
"undo": [
|
||||
"Add page",
|
||||
"Create instance",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer"
|
||||
]
|
||||
},
|
||||
"id": s.ds().unwrap().uid,
|
||||
"images": 0,
|
||||
"kind": "design",
|
||||
"name": "Untitled",
|
||||
"pages": [
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 200.0,
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 24.0,
|
||||
"width": 80.0,
|
||||
"x": 110.0,
|
||||
"y": 210.0
|
||||
},
|
||||
"characters": "Hello",
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FEDCBA",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"fontFamily": "Synthetic",
|
||||
"fontSize": 18.0,
|
||||
"fontStyle": "Bold",
|
||||
"height": 24.0,
|
||||
"id": 3,
|
||||
"letterSpacing": "1",
|
||||
"lineHeight": "24",
|
||||
"name": "Title",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "hug"
|
||||
},
|
||||
"textAlign": "center",
|
||||
"textAutoResize": "height",
|
||||
"textDecoration": "underline",
|
||||
"type": "TEXT",
|
||||
"width": 80.0,
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 30.0,
|
||||
"width": 20.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"fills": [
|
||||
{
|
||||
"color": "#654321",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 30.0,
|
||||
"id": 4,
|
||||
"locked": true,
|
||||
"name": "Hidden",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"type": "RECTANGLE",
|
||||
"visible": false,
|
||||
"width": 20.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
],
|
||||
"clip": true,
|
||||
"cornerRadius": 12.0,
|
||||
"effects": [
|
||||
{
|
||||
"blend": "normal",
|
||||
"color": {
|
||||
"a": 0.25,
|
||||
"b": 0.0,
|
||||
"g": 0.0,
|
||||
"r": 0.0
|
||||
},
|
||||
"offset": [
|
||||
2.0,
|
||||
3.0
|
||||
],
|
||||
"radius": 4.0,
|
||||
"showBehind": false,
|
||||
"spread": 0.0,
|
||||
"type": "dropShadow",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"fills": [
|
||||
{
|
||||
"color": "#123456",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 200.0,
|
||||
"id": 2,
|
||||
"layout": {
|
||||
"columnTracks": [],
|
||||
"contentAlign": "auto",
|
||||
"crossAlign": "min",
|
||||
"crossGap": 10.0,
|
||||
"gap": 4.0,
|
||||
"gridColumns": 2,
|
||||
"gridRows": 2,
|
||||
"mainAlign": "min",
|
||||
"mode": "horizontal",
|
||||
"padding": [
|
||||
8.0,
|
||||
8.0,
|
||||
8.0,
|
||||
8.0
|
||||
],
|
||||
"reverseZ": false,
|
||||
"rowTracks": [],
|
||||
"strokesIncluded": true,
|
||||
"wrap": false
|
||||
},
|
||||
"name": "Card",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"strokeAlign": "inside",
|
||||
"strokeWeight": 2.0,
|
||||
"strokes": [
|
||||
{
|
||||
"color": "#ABCDEF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"type": "FRAME",
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 5,
|
||||
"name": "Button",
|
||||
"type": "COMPONENT",
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 6,
|
||||
"mainComponent": 5,
|
||||
"mainComponentName": "Button",
|
||||
"name": "Button",
|
||||
"type": "INSTANCE",
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
}
|
||||
],
|
||||
"id": 1,
|
||||
"name": "Page 1",
|
||||
"type": "PAGE"
|
||||
},
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"id": 7,
|
||||
"name": "Second",
|
||||
"type": "PAGE"
|
||||
}
|
||||
],
|
||||
"path": null,
|
||||
"selection": [],
|
||||
"styles": [],
|
||||
"textEdit": null,
|
||||
"variables": 0
|
||||
});
|
||||
let expected_node = &expected["pages"][0]["children"][0];
|
||||
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
|
||||
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
|
||||
for params in [json!({}), json!({"outline": false})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
|
||||
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
|
||||
let (s, _, _) = tree();
|
||||
let before = document(&s, 4, true);
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
|
||||
let compact = document_with_options(&s, 4, true, &options);
|
||||
let mut metadata = before.clone();
|
||||
metadata["pages"] = compact["pages"].clone();
|
||||
assert_eq!(compact, metadata);
|
||||
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
|
||||
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
assert_eq!(document(&s, 4, true), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
|
||||
let (s, frame, _) = tree();
|
||||
let compact = node(&s, frame, 4, json!({"fields": []}));
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
|
||||
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
|
||||
let summary = document_with_options(&s, 0, false, &options);
|
||||
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_keys_are_independently_selectable() {
|
||||
let (s, frame, text) = tree();
|
||||
let fields = ["x", "y", "width", "height", "absoluteBounds"];
|
||||
let compact = node(&s, frame, 2, json!({"fields": fields}));
|
||||
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
|
||||
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
|
||||
assert_eq!(compact["children"][0]["x"], 10.0);
|
||||
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
|
||||
for key in fields {
|
||||
let one = node(&s, text, 0, json!({"fields": [key]}));
|
||||
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
|
||||
assert_eq!(one[key], compact["children"][0][key]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_truncates_only_after_120_unicode_scalars() {
|
||||
let (mut s, _, text) = tree();
|
||||
for unit in ["a", "界", "\u{1f980}"] {
|
||||
for len in [0, 119, 120, 121] {
|
||||
let content = unit.repeat(len);
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
|
||||
let outline = node(&s, text, 0, json!({"outline": true}));
|
||||
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
|
||||
assert_eq!(outline["characters"], want);
|
||||
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
|
||||
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
|
||||
let (s, frame, _) = tree();
|
||||
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
let tree = document_with_options(&s, 4, true, &options);
|
||||
assert_eq!(tree["pages"][0]["visible"], true);
|
||||
let card = &tree["pages"][0]["children"][0];
|
||||
assert_eq!(card["visible"], true);
|
||||
assert_eq!(card["children"][0]["visible"], true);
|
||||
assert_eq!(card["children"][1]["visible"], false);
|
||||
}
|
||||
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
|
||||
assert!(no_visible["children"][1].get("visible").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
|
||||
let mut s = Session::new();
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
|
||||
let compact = node(&s, instance, 0, params);
|
||||
assert_eq!(compact["mainComponentName"], "Button");
|
||||
assert!(compact.get("mainComponent").is_none());
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
|
||||
*component = NodeId(u64::MAX);
|
||||
}
|
||||
let compact = node(&s, instance, 0, json!({"outline": true}));
|
||||
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_properties_and_other_page_summaries_respect_fields() {
|
||||
let (mut s, _, _) = tree();
|
||||
let first = s.page().unwrap();
|
||||
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
|
||||
let compact = document_with_options(&s, 4, false, &options);
|
||||
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
|
||||
let page = &compact["pages"][0];
|
||||
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
|
||||
assert_eq!(page["pluginData"]["source"], "synthetic");
|
||||
assert_eq!(page["childCount"], 1);
|
||||
assert!(page["background"].is_string());
|
||||
assert!(page.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
|
||||
let (mut s, frame, text) = tree();
|
||||
run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}}));
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into();
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true;
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into());
|
||||
// Exercise each of the six property helpers, including their more expensive values.
|
||||
for (id, key, sibling) in [
|
||||
(frame, "absoluteBounds", "width"),
|
||||
(frame, "fills", "strokes"),
|
||||
(text, "gridCell", "sizing"),
|
||||
(frame, "devNote", "devChanged"),
|
||||
(text, "textStyleRanges", "fontFamily"),
|
||||
(instance, "mainComponentName", "mainComponent"),
|
||||
] {
|
||||
let full = node_json(s.doc().unwrap(), id, 0);
|
||||
assert!(full.get(key).is_some(), "fixture must emit {key}: {full}");
|
||||
assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}");
|
||||
let selected = node(&s, id, 0, json!({"fields": [key]}));
|
||||
assert_eq!(selected[key], full[key]);
|
||||
assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}");
|
||||
keys(&selected, &["id", "children", "childCount", key]);
|
||||
}
|
||||
let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3);
|
||||
fn check(s: &Session, full: &Value) {
|
||||
let id = crate::params::id_of(&full["id"]).unwrap();
|
||||
for (key, value) in full.as_object().unwrap() {
|
||||
let selected = node(s, id, 2, json!({"fields": [key]}));
|
||||
if key == "children" {
|
||||
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
|
||||
} else {
|
||||
assert_eq!(&selected[key], value, "{key}");
|
||||
}
|
||||
}
|
||||
for child in full["children"].as_array().into_iter().flatten() {
|
||||
check(s, child);
|
||||
}
|
||||
}
|
||||
check(&s, &full);
|
||||
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
|
||||
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
|
||||
for params in [
|
||||
json!({"fields": null}),
|
||||
json!({"fields": "name"}),
|
||||
json!({"fields": {"name": true}}),
|
||||
json!({"fields": [false]}),
|
||||
json!({"fields": [null]}),
|
||||
json!({"fields": ["name", 1]}),
|
||||
json!({"outline": null}),
|
||||
json!({"outline": "true"}),
|
||||
json!({"outline": 1}),
|
||||
json!({"outline": []}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
json!({"outline": true, "fields": ["name"]}),
|
||||
] {
|
||||
assert!(InspectOptions::parse(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
|
||||
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
|
||||
assert!(error.contains(key), "{error}");
|
||||
for valid in crate::inspect::NODE_KEYS {
|
||||
assert!(error.contains(valid), "missing {valid}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
|
||||
let (mut s, frame, _) = tree();
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let default = node_json(s.doc().unwrap(), frame, 1);
|
||||
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
|
||||
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []}));
|
||||
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
|
||||
let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown parameter `depth`"), "{error}");
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err());
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
}
|
||||
@@ -349,6 +349,8 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_fields_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_modes_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
|
||||
@@ -428,3 +428,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_alias_dependencies() {
|
||||
let mut s = Session::new();
|
||||
for name in ["Shadow", "Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]}));
|
||||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let selection = Selection { components: vec![chip], ..Default::default() };
|
||||
let lib = publish(doc, None, &selection, None, 0).unwrap();
|
||||
let target = surface["id"].as_str().unwrap();
|
||||
assert!(lib.doc.variables.get_by_id(target).is_some());
|
||||
assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none());
|
||||
assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
|
||||
// Import from a snapshot that retains both the dependency and its namesake.
|
||||
let lib = publish(doc, None, &publishable(doc), None, 1).unwrap();
|
||||
let mut dest = Document::new("consumer");
|
||||
let key = alias["id"].as_str().unwrap().to_string();
|
||||
let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false);
|
||||
assert!(map.variables.contains_key(target));
|
||||
let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap();
|
||||
assert!(!copy.values.is_empty());
|
||||
assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone())));
|
||||
assert!(dest.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
}
|
||||
@@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option<VariableValue> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose
|
||||
/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set.
|
||||
/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node`
|
||||
/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing
|
||||
/// could be set.
|
||||
pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool {
|
||||
let Some(var) = doc.variables.get(variable) else { return false };
|
||||
let Some(var) = doc.variables.get_by_id(variable) else { return false };
|
||||
let (id, kind) = (var.id.clone(), var.kind);
|
||||
let v = match expr {
|
||||
Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)),
|
||||
@@ -65,7 +66,14 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
|
||||
/// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still
|
||||
/// win), then re-apply bindings. False for an unknown collection or mode.
|
||||
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection(collection) else { return false };
|
||||
let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false };
|
||||
set_mode_by_id(doc, frame, &id, mode)
|
||||
}
|
||||
|
||||
/// Switch a stored collection id to `mode`, without matching another collection's name.
|
||||
/// Otherwise behaves like [`set_mode`].
|
||||
pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection_by_id(collection) else { return false };
|
||||
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -207,3 +207,140 @@ fn junk_conditional_actions_never_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_set_only_the_prototype_action_target() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let shadow = var_id(s.doc().unwrap(), "count");
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap();
|
||||
let target = target["id"].as_str().unwrap().to_string();
|
||||
s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
assert!(set_variable(&mut doc, frame, &target, &json!(9), None));
|
||||
assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0)));
|
||||
assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0)));
|
||||
doc.variables.variables.retain(|v| v.id != target);
|
||||
let before = doc.clone();
|
||||
assert!(!set_variable(&mut doc, frame, &target, &json!(11), None));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_survive_unrelated_interaction_edits() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "back"},
|
||||
{"type": "setVariable", "variable": "target", "expression": "target + 1"},
|
||||
{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let theme = s.doc().unwrap().variables.collections[0].id.clone();
|
||||
s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap();
|
||||
// Put the namesake before the referenced collection, as variable lookup already does.
|
||||
s.doc_mut().unwrap().variables.collections.swap(0, 1);
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone();
|
||||
for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] {
|
||||
let mut p = json!({"id": frame, "index": 0});
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
s.execute("proto.interaction.set", &p).unwrap();
|
||||
let it = &s.doc().unwrap().node(frame).unwrap().interactions[0];
|
||||
assert_eq!(it.duration_ms, 450);
|
||||
assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_from_text_are_not_resolved_twice() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"});
|
||||
s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap();
|
||||
for (cmd, patch) in [
|
||||
("proto.interaction.add", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"interaction": {"actions": [action]}})),
|
||||
("proto.action.add", json!({"path": [0], "action": action})),
|
||||
("proto.action.set", json!({"path": [0], "action": action})),
|
||||
] {
|
||||
let mut p = json!({"id": frame});
|
||||
if cmd != "proto.interaction.add" {
|
||||
p["index"] = json!(0);
|
||||
}
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 };
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else {
|
||||
panic!("{cmd}: no set variable expression")
|
||||
};
|
||||
assert_eq!(variable, &target, "{cmd}");
|
||||
assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let value = json!({"type": "setVariable", "variable": "target"});
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)},
|
||||
{"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [
|
||||
{"type": "setVariable", "variable": "label", "value": value}
|
||||
]}]}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let actions = &doc.node(frame).unwrap().interactions[0].actions;
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") };
|
||||
assert_eq!(variable, &target);
|
||||
// A JSON expression is command input: it still accepts names, even one that resembles an id.
|
||||
assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0)));
|
||||
let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") };
|
||||
assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true)));
|
||||
let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") };
|
||||
assert_eq!(stored, &value, "a literal value is not an action to canonicalize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_switch_only_the_prototype_collection() {
|
||||
let (s, frame, _, _, r) = setup();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let theme = doc.variables.collections[0].clone();
|
||||
let mut shadow = theme.clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = theme.id.clone();
|
||||
doc.variables.collections.insert(0, shadow);
|
||||
assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
|
||||
assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"));
|
||||
let page = doc.page_of(frame).unwrap();
|
||||
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
// The public name-accepting wrapper keeps its existing lookup behavior.
|
||||
assert!(set_mode(&mut doc, frame, &theme.id, "Light"));
|
||||
assert!(doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
doc.variables.collections.retain(|c| c.id != theme.id);
|
||||
let before = doc.clone();
|
||||
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
@@ -459,6 +459,12 @@ fn every_command_survives_junk_params() {
|
||||
// would write to it.)
|
||||
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
|
||||
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
|
||||
// Compact inspection: fields and outline with malformed types and unknown keys.
|
||||
json!({"id": 2, "fields": "name", "outline": true}),
|
||||
json!({"id": 2, "fields": ["name", 5]}),
|
||||
json!({"id": 2, "fields": ["Name", "noSuchField"]}),
|
||||
json!({"id": 2, "fields": null, "outline": []}),
|
||||
json!({"id": 2, "outline": "yes"}),
|
||||
// Layer search roots, filters and limits as junk.
|
||||
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
|
||||
json!({"root": 999999, "limit": 1e308}),
|
||||
|
||||
@@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
|
||||
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
|
||||
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_cannot_hide_an_alias_cycle() {
|
||||
for link in ["a", "b"] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let mut ids = std::collections::BTreeMap::new();
|
||||
for name in ["shadow", "a", "b"] {
|
||||
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1}));
|
||||
ids.insert(name, v["id"].clone());
|
||||
}
|
||||
run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}}));
|
||||
run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("refer back to itself"), "{link}: {error}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_detach_deleted_aliases_per_mode() {
|
||||
for collection in [false, true] {
|
||||
let mut s = Session::new();
|
||||
for name in ["Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}),
|
||||
);
|
||||
// Use a different collection only when deleting the whole target collection.
|
||||
let home = if collection { "Theme" } else { "Base" };
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let aid = alias["id"].as_str().unwrap();
|
||||
let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone();
|
||||
let expected: std::collections::BTreeMap<_, _> = before
|
||||
.values
|
||||
.keys()
|
||||
.map(|m| {
|
||||
let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]);
|
||||
(m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap())
|
||||
})
|
||||
.collect();
|
||||
if collection {
|
||||
ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.delete", json!({"variable": "surface"}));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() {
|
||||
let mut s = Session::new();
|
||||
let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]}));
|
||||
let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7}));
|
||||
let doc = s.doc().unwrap();
|
||||
let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap();
|
||||
let mode = coll.find_mode("Dark").unwrap().id.clone();
|
||||
let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]);
|
||||
assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0)));
|
||||
|
||||
// Mode commands resolve the user's collection argument before checking its stored id.
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone();
|
||||
extended.id = "extended".into();
|
||||
extended.name = "Extended".into();
|
||||
extended.extends = Some(theme["id"].as_str().unwrap().into());
|
||||
doc.variables.collections.push(extended);
|
||||
doc.variables.collections[0].name = "extended".into();
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
@@ -231,6 +231,39 @@ fn variables_export_and_import_round_trip() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_remapped_import_aliases() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow, "name": target}));
|
||||
let imported = json!({"meta": {
|
||||
"variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}},
|
||||
"variables": {
|
||||
"t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}},
|
||||
"a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT",
|
||||
"valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}}
|
||||
}
|
||||
}});
|
||||
let result = ex(&mut s, "variable.import", json!({"variables": imported}));
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
let alias = vars.get("alias").unwrap();
|
||||
assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone())));
|
||||
assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0)));
|
||||
// The command's alias argument remains name-accepting, unlike the import's remapped id.
|
||||
ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}}));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_checks_what_it_reads() {
|
||||
let mut s = Session::new();
|
||||
@@ -555,3 +588,53 @@ fn css_export_neutralises_every_css_newline_in_strings() {
|
||||
assert!(css.contains("\"a b c\""), "{css:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_css_alias_names() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}));
|
||||
assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
|
||||
for collection_shadow in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0}));
|
||||
let surface =
|
||||
ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}}));
|
||||
if collection_shadow {
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone();
|
||||
let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone();
|
||||
let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone());
|
||||
coll.extends = Some(coll.id.clone());
|
||||
coll.id = "extended".into();
|
||||
coll.name = "Extended".into();
|
||||
let mut inherited = parent.clone();
|
||||
inherited.id = "inherited".into();
|
||||
inherited.name = "inherited".into();
|
||||
inherited.collection = coll.id.clone();
|
||||
inherited.extends = Some(parent.id);
|
||||
inherited.overrides = inherited.values.keys().cloned().collect();
|
||||
inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0));
|
||||
doc.variables.collections.push(coll);
|
||||
doc.variables.variables.push(inherited);
|
||||
ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"}));
|
||||
let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap();
|
||||
assert_eq!(result.values[&light], VariableValue::Number(2.0));
|
||||
assert_eq!(result.values[&dark], VariableValue::Number(8.0));
|
||||
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ mod text_wrap_tests;
|
||||
#[cfg(test)]
|
||||
mod value_budget_tests;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
@@ -69,6 +70,13 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
/// 64-bit targets, lower on 32-bit targets to leave address space for the canvas, layers and
|
||||
/// decoded pixels.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_IMAGE_BYTES: u64 = crate::IMAGE_BUDGET.bytes;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
/// 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
|
||||
@@ -107,7 +115,12 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
|
||||
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
|
||||
/// approximated. `name`: the document name when the file doesn't carry one.
|
||||
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_with_image_limit(bytes, name, MAX_IMAGE_BYTES)
|
||||
}
|
||||
|
||||
fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let mut archive = Archive::open(bytes)?;
|
||||
archive.image_budget.remaining.set(image_limit);
|
||||
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
|
||||
// inflate too).
|
||||
let packed = archive.canvas.len().min(bytes.len());
|
||||
@@ -127,6 +140,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
let skipped = archive.image_budget.skipped.get();
|
||||
if skipped > 0 {
|
||||
warnings.push(format!("{skipped} ZIP image(s) skipped by the total ZIP image byte budget ({image_limit} bytes)"));
|
||||
}
|
||||
if board {
|
||||
doc.kind = FileKind::Board;
|
||||
}
|
||||
@@ -220,18 +237,27 @@ fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
|
||||
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
|
||||
}
|
||||
|
||||
/// Bytes left for ZIP image reads, including damaged images. The REST resolver caches references,
|
||||
/// including failures, so each referenced member is read or counted as skipped only once.
|
||||
struct ZipImageBudget {
|
||||
remaining: Cell<u64>,
|
||||
skipped: Cell<usize>,
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
struct Archive<'b> {
|
||||
canvas: Vec<u8>,
|
||||
file_name: Option<String>,
|
||||
/// `images/<hash>` members (or nothing for a bare canvas).
|
||||
zip: Option<zip::ZipArchive<std::io::Cursor<&'b [u8]>>>,
|
||||
image_budget: ZipImageBudget,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
fn open(bytes: &'b [u8]) -> Result<Self, FormatError> {
|
||||
let image_budget = ZipImageBudget { remaining: Cell::new(MAX_IMAGE_BYTES), skipped: Cell::new(0) };
|
||||
if bytes.starts_with(b"fig-") {
|
||||
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None });
|
||||
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, image_budget });
|
||||
}
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
|
||||
let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?;
|
||||
@@ -239,17 +265,51 @@ impl<'b> Archive<'b> {
|
||||
.ok()
|
||||
.and_then(|m| serde_json::from_slice::<serde_json::Value>(&m).ok())
|
||||
.and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string));
|
||||
Ok(Archive { canvas, file_name, zip: Some(z) })
|
||||
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
|
||||
}
|
||||
|
||||
/// Bytes of image `hash` (lowercase hex) from `images/`.
|
||||
fn image(&self, hash: &str) -> Option<Vec<u8>> {
|
||||
let budget = &self.image_budget;
|
||||
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
let mut z = self.zip.clone()?;
|
||||
crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok()
|
||||
let f = z.by_name(&format!("images/{hash}")).ok()?;
|
||||
let size = f.size();
|
||||
if size > MAX_IMAGE {
|
||||
return None;
|
||||
}
|
||||
let remaining = budget.remaining.get();
|
||||
if size > remaining {
|
||||
budget.skipped.set(budget.skipped.get() + 1);
|
||||
return None;
|
||||
}
|
||||
let checksum = f.crc32();
|
||||
let mut data = Vec::new();
|
||||
// Probe past the per-image cap only when the total budget has room. Even a dishonest
|
||||
// size cannot make the read exceed the remaining total; failed reads keep their charge.
|
||||
let limit = remaining.min(MAX_IMAGE + 1);
|
||||
let read = f.take(limit).read_to_end(&mut data);
|
||||
let used = data.len() as u64;
|
||||
budget.remaining.set(remaining - used);
|
||||
if used > size && used == remaining {
|
||||
budget.skipped.set(budget.skipped.get() + 1);
|
||||
}
|
||||
if read.is_err() || used != size {
|
||||
return None;
|
||||
}
|
||||
// `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC).
|
||||
// Check it here too, without spending an extra byte on an overflow probe.
|
||||
if used == limit {
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(&data);
|
||||
if crc.sum() != checksum {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,9 +251,9 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to variable `var` (its id or name) as variable data.
|
||||
/// A reference to variable `var` (its stored id) as variable data.
|
||||
fn alias(&mut self, var: &str) -> Option<Kv<'static>> {
|
||||
let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let Some((kind, local)) = found else {
|
||||
self.warn("bindings to variables that aren't in the document were written as their values");
|
||||
return None;
|
||||
|
||||
@@ -795,6 +795,233 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
|
||||
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
|
||||
}
|
||||
|
||||
mod image_budget {
|
||||
use super::*;
|
||||
|
||||
fn image(hash: u8) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn file(references: &[u8], members: &[(u8, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])];
|
||||
for (i, hash) in references.iter().enumerate() {
|
||||
nodes.push(node(
|
||||
i as u64 + 2,
|
||||
Some((1, &format!("{i:04}"))),
|
||||
"RECTANGLE",
|
||||
&format!("Image {i}"),
|
||||
vec![
|
||||
("size", xy(10.0, 10.0)),
|
||||
("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])),
|
||||
],
|
||||
));
|
||||
}
|
||||
let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("canvas.fig", deflated).unwrap();
|
||||
z.write_all(&canvas).unwrap();
|
||||
for (hash, bytes) in members {
|
||||
z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn bytes(doc: &Document, index: usize) -> &[u8] {
|
||||
let node = named(doc, &format!("Image {index}"));
|
||||
let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") };
|
||||
&doc.images[&fill.image].bytes
|
||||
}
|
||||
|
||||
fn budget_warnings(warnings: &[String]) -> Vec<&str> {
|
||||
warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect()
|
||||
}
|
||||
|
||||
fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) {
|
||||
let start = {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap();
|
||||
z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize
|
||||
};
|
||||
zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let limit = members.iter().map(|(_, b)| b.len() as u64).sum();
|
||||
let zip = file(&[1, 2], &members);
|
||||
for _ in 0..2 {
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let limit = members.iter().map(|(_, b)| b.len() as u64).sum::<u64>() - 1;
|
||||
assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit));
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1)));
|
||||
let notes = budget_warnings(&warnings);
|
||||
assert_eq!(notes.len(), 1, "{warnings:?}");
|
||||
assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_references_do_not_charge_or_count_a_rejection_twice() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap();
|
||||
for i in [0, 1, 4] {
|
||||
assert_eq!(bytes(&doc, i), members[0].1);
|
||||
}
|
||||
assert_eq!(bytes(&doc, 2), bytes(&doc, 3));
|
||||
assert_ne!(bytes(&doc, 2), members[1].1);
|
||||
let notes = budget_warnings(&warnings);
|
||||
assert_eq!(notes.len(), 1, "{warnings:?}");
|
||||
assert!(notes[0].contains("1 ZIP image"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_members_with_identical_contents_still_use_the_read_budget() {
|
||||
let png = image(1);
|
||||
let members = [(1, png.clone()), (2, png.clone())];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), png);
|
||||
assert_ne!(bytes(&doc, 1), png);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() {
|
||||
let png = image(1);
|
||||
let members = [(1, vec![0; png.len() + 1]), (2, png.clone())];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), png);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_crc_reads_still_consume_the_bytes_read() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 16, 0);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_deflate_read_keeps_its_partial_charge() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
// Shorten the first member's compressed size, cutting its deflate stream mid-image.
|
||||
change_member_field(&mut zip, 1, 20, 15);
|
||||
let limit = members[0].1.len() as u64;
|
||||
let archive = Archive::open(&zip).unwrap();
|
||||
archive.image_budget.remaining.set(limit);
|
||||
assert!(archive.image(&"01".repeat(20)).is_none());
|
||||
let remaining = archive.image_budget.remaining.get();
|
||||
assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes");
|
||||
assert!(archive.image(&"02".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), remaining);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_individual_image_cap_is_preserved_without_reading_a_large_member() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() {
|
||||
let members = [(1, image(1))];
|
||||
let mut zip = file(&[1], &members);
|
||||
change_member_field(&mut zip, 1, 16, 0);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_understated_size_cannot_read_past_the_remaining_budget() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 24, 1);
|
||||
let archive = Archive::open(&zip).unwrap();
|
||||
archive.image_budget.remaining.set(members[0].1.len() as u64 - 1);
|
||||
assert!(archive.image(&"01".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), 0);
|
||||
assert_eq!(archive.image_budget.skipped.get(), 1);
|
||||
assert!(archive.image(&"02".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), 0);
|
||||
assert_eq!(archive.image_budget.skipped.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_image_contents_are_charged_before_image_validation() {
|
||||
let members = [(1, vec![0; 8]), (2, image(2))];
|
||||
let limit = members[1].1.len() as u64 + 7;
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_unreferenced_members_do_not_consume_the_budget() {
|
||||
let members = [(1, image(1)), (2, vec![0; 128])];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap();
|
||||
assert_eq!(bytes(&doc, 1), members[0].1);
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_image_fallback_is_independent_of_the_zip_budget() {
|
||||
let canvas = canvas(&schema(), &message_with_image_in_blobs(0));
|
||||
for bytes in [&canvas, &zip_file(&canvas)] {
|
||||
let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap();
|
||||
let photo = named(&doc, "Photo");
|
||||
let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(*doc.images[&fill.image].bytes, png());
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1)));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let (doc, warnings) = import(&fixture(), "Fixture").unwrap();
|
||||
assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1)));
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_fig() {
|
||||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||||
|
||||
@@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> {
|
||||
if let Some(name) = self.token_names.get(variable) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
let var = self.doc.variables.get(variable)?;
|
||||
let var = self.doc.variables.get_by_id(variable)?;
|
||||
let name = if variable.starts_with("penpot:tokens:") {
|
||||
var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned())
|
||||
} else {
|
||||
|
||||
@@ -909,3 +909,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_penpot_token_identity() {
|
||||
for missing in [false, true] {
|
||||
let mut doc = Document::new("Token identity");
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "m".into(), name: "Default".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))]));
|
||||
if !missing {
|
||||
doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))]));
|
||||
}
|
||||
let p = page(&doc);
|
||||
let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0);
|
||||
doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into());
|
||||
let exported = entries(&export(&doc).unwrap().0);
|
||||
let width = &shape(&exported, "Bound")["appliedTokens"]["width"];
|
||||
let expected = if missing { Value::Null } else { json!("surface") };
|
||||
assert_eq!(width, &expected);
|
||||
}
|
||||
}
|
||||
@@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_imported_mode_lookup_identity() {
|
||||
for collection in [false, true] {
|
||||
let mut vars = variables_json();
|
||||
if collection {
|
||||
let mut shadow = vars["meta"]["variableCollections"][THEME].clone();
|
||||
shadow["id"] = json!("VariableCollectionId:0:0");
|
||||
shadow["name"] = json!(THEME);
|
||||
shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]);
|
||||
vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow;
|
||||
} else {
|
||||
let mut shadow = vars["meta"]["variables"][SPACE].clone();
|
||||
shadow["id"] = json!("VariableID:0:0");
|
||||
shadow["name"] = json!(LABEL);
|
||||
vars["meta"]["variables"]["VariableID:0:0"] = shadow;
|
||||
}
|
||||
let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]);
|
||||
let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap();
|
||||
let actions = &button(&doc).interactions[0].actions;
|
||||
assert_eq!(
|
||||
actions[0],
|
||||
Action::SetVariable {
|
||||
variable: LABEL.into(),
|
||||
value: Value::Null,
|
||||
expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }),
|
||||
},
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -442,8 +442,8 @@ impl Ctx<'_> {
|
||||
};
|
||||
let vars = &self.doc.variables;
|
||||
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
|
||||
let v = vars.get(id)?;
|
||||
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
|
||||
let v = vars.get_by_id(id)?;
|
||||
let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
|
||||
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -252,7 +252,8 @@ impl Headless {
|
||||
)),
|
||||
"document.inspect" => {
|
||||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
|
||||
Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages")))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
|
||||
Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options))
|
||||
}
|
||||
"node.get" => {
|
||||
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?;
|
||||
@@ -261,7 +262,8 @@ impl Headless {
|
||||
if !doc.contains(id) {
|
||||
return Err(format!("no such node {id}"));
|
||||
}
|
||||
Ok(figcraft_engine::inspect::node_json(doc, id, depth))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
|
||||
Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options))
|
||||
}
|
||||
"ui.inspect" => Ok(self.inspect_ui()),
|
||||
"ui.pointer" => self.pointer(&p),
|
||||
|
||||
+35
-8
@@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value {
|
||||
fn ids_schema(desc: &str) -> Value {
|
||||
json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}})
|
||||
}
|
||||
fn fields_schema() -> Value {
|
||||
json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS},
|
||||
"description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."})
|
||||
}
|
||||
fn outline_schema() -> Value {
|
||||
boolean(
|
||||
"Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.",
|
||||
)
|
||||
}
|
||||
fn obj(props: Value, required: &[&str]) -> Value {
|
||||
let mut o = json!({"type": "object", "properties": props});
|
||||
if !required.is_empty() {
|
||||
@@ -213,6 +222,8 @@ fn tools() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"depth": int("Tree depth for the current page (default 4)"),
|
||||
"fields": fields_schema(),
|
||||
"outline": outline_schema(),
|
||||
"allPages": boolean("Expand every page, not only the current one"),
|
||||
"maxBytes": int(
|
||||
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
|
||||
@@ -230,6 +241,8 @@ fn tools() -> Vec<Value> {
|
||||
json!({
|
||||
"id": id_schema("Layer id"),
|
||||
"depth": int("Child depth (default 1)"),
|
||||
"fields": fields_schema(),
|
||||
"outline": outline_schema(),
|
||||
"offset": int("First child to report (default 0), for reading a node with more children than fit in one message"),
|
||||
"maxBytes": int(
|
||||
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
|
||||
@@ -751,19 +764,31 @@ fn wire_size(value: &Value) -> usize {
|
||||
|
||||
/// The document at `depth`, and what its text block would take on the wire. The tree is sent as a
|
||||
/// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth.
|
||||
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> {
|
||||
let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?;
|
||||
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> {
|
||||
let mut params = options.clone();
|
||||
params["depth"] = json!(depth);
|
||||
params["allPages"] = json!(all);
|
||||
let value = b.call("document.inspect", params)?;
|
||||
let wire = wire_size(&value);
|
||||
Ok((value, wire))
|
||||
}
|
||||
|
||||
/// One layer's subtree at `depth`, and what its text block would take on the wire.
|
||||
fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> {
|
||||
let value = b.call("node.get", json!({"id": id, "depth": depth}))?;
|
||||
fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> {
|
||||
let mut params = options.clone();
|
||||
params["id"] = json!(id);
|
||||
params["depth"] = json!(depth);
|
||||
let value = b.call("node.get", params)?;
|
||||
let wire = wire_size(&value);
|
||||
Ok((value, wire))
|
||||
}
|
||||
|
||||
fn inspection_options(a: &Map<String, Value>) -> Result<Value, String> {
|
||||
let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect());
|
||||
figcraft_engine::inspect::InspectOptions::parse(&options)?;
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64;
|
||||
let all = match a.get("allPages") {
|
||||
@@ -771,9 +796,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
|
||||
Some(Value::Bool(x)) => *x,
|
||||
Some(_) => return Err("`allPages` must be a boolean".into()),
|
||||
};
|
||||
let options = inspection_options(a)?;
|
||||
let (max_bytes, budget) = budget(a)?;
|
||||
|
||||
let (value, wire) = inspect_at(b, depth, all)?;
|
||||
let (value, wire) = inspect_at(b, depth, all, &options)?;
|
||||
if wire <= budget {
|
||||
return Ok(ToolResult::json(&value));
|
||||
}
|
||||
@@ -791,7 +817,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
|
||||
let mut best: Option<(u64, Value, usize)> = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let (v, w) = inspect_at(b, mid, all)?;
|
||||
let (v, w) = inspect_at(b, mid, all, &options)?;
|
||||
if w + MARKER_RESERVE <= budget {
|
||||
best = Some((mid, v, w));
|
||||
if mid == hi {
|
||||
@@ -840,9 +866,10 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let id = id_arg(a.get("id").ok_or("`id` is required")?)?;
|
||||
let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64;
|
||||
let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize;
|
||||
let options = inspection_options(a)?;
|
||||
let (max_bytes, budget) = budget(a)?;
|
||||
|
||||
let (value, wire) = node_at(b, id, depth)?;
|
||||
let (value, wire) = node_at(b, id, depth, &options)?;
|
||||
if value.is_null() {
|
||||
return Err(format!("no such layer {id}"));
|
||||
}
|
||||
@@ -858,7 +885,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let (mut lo, mut hi) = (1u64, depth - 1);
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let (v, w) = node_at(b, id, mid)?;
|
||||
let (v, w) = node_at(b, id, mid, &options)?;
|
||||
if w + MARKER_RESERVE <= budget {
|
||||
(used, value, used_wire) = (mid, v, w);
|
||||
if mid == hi {
|
||||
|
||||
@@ -64,6 +64,24 @@ fn attached_backend_forwards_everything() {
|
||||
assert_eq!(err["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_inspection_forwards_compact_options_to_the_app() {
|
||||
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
|
||||
let created = call(&mut s, "create_node", json!({"type": "text", "text": "界".repeat(121), "name": "Title"}));
|
||||
let id = serde_json::from_str::<Value>(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
|
||||
let r = call(&mut s, "get_node", json!({"id": id, "outline": true}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(outline["characters"], format!("{}...", "界".repeat(120)));
|
||||
assert_eq!(outline["visible"], true);
|
||||
assert!(outline.get("fontSize").is_none());
|
||||
let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(compact["pages"][0]["children"][0], json!({"id": id}));
|
||||
assert!(compact["pages"][0].get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_agent_comments_under_its_own_name() {
|
||||
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Compact trees are built by the backend, including every size-budget retry.
|
||||
|
||||
use crate::common::Client;
|
||||
use figcraft_mcp::{Backend, Headless, call_tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tree(mut create: impl FnMut(Value) -> Value) -> Value {
|
||||
let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400}));
|
||||
for i in 0..24 {
|
||||
let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160,
|
||||
"props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}}));
|
||||
for j in 0..6 {
|
||||
create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8),
|
||||
"x": 12, "y": j * 20, "width": 240}));
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn compact_keys(node: &Value, outline: bool) {
|
||||
let allowed: &[&str] = if outline {
|
||||
&["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"]
|
||||
} else {
|
||||
&["id", "name", "children", "childCount", "truncated"]
|
||||
};
|
||||
for key in node.as_object().unwrap().keys() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}");
|
||||
}
|
||||
for child in node["children"].as_array().into_iter().flatten() {
|
||||
compact_keys(child, outline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schemas_describe_compact_inspection_options() {
|
||||
let mut c = Client::new();
|
||||
let tools = c.rpc("tools/list", json!({}));
|
||||
for name in ["inspect_document", "get_node"] {
|
||||
let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap();
|
||||
let props = &tool["inputSchema"]["properties"];
|
||||
assert_eq!(props["fields"]["type"], "array");
|
||||
assert_eq!(props["fields"]["items"]["type"], "string");
|
||||
assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds")));
|
||||
assert_eq!(props["outline"]["type"], "boolean");
|
||||
assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive"));
|
||||
assert!(props["outline"]["description"].as_str().unwrap().contains("120"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() {
|
||||
let mut c = Client::new();
|
||||
let root = tree(|params| c.create(params));
|
||||
for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] {
|
||||
let full = c.ok(tool, args.clone());
|
||||
let full_bytes = full.to_string().len();
|
||||
args["outline"] = json!(true);
|
||||
let outline = c.ok(tool, args.clone());
|
||||
assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len());
|
||||
args.as_object_mut().unwrap().remove("outline");
|
||||
args["fields"] = json!([]);
|
||||
let ids = c.ok(tool, args);
|
||||
assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len());
|
||||
if tool == "inspect_document" {
|
||||
assert_eq!(outline["history"], full["history"]);
|
||||
assert_eq!(ids["selection"], full["selection"]);
|
||||
compact_keys(&outline["pages"][0], true);
|
||||
} else {
|
||||
compact_keys(&outline, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Recording {
|
||||
inner: Headless,
|
||||
calls: Vec<(String, Value)>,
|
||||
}
|
||||
|
||||
impl Backend for Recording {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.calls.push((method.into(), params.clone()));
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"recording headless".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_and_outline_survive_every_depth_retry() {
|
||||
let mut backend = Recording { inner: Headless::new(), calls: Vec::new() };
|
||||
let root = tree(|params| {
|
||||
let result = call_tool(&mut backend, "create_node", ¶ms);
|
||||
assert!(!result.is_error, "{}", result.first_text());
|
||||
serde_json::from_str::<Value>(result.first_text()).unwrap()["id"].clone()
|
||||
});
|
||||
for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] {
|
||||
for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] {
|
||||
backend.calls.clear();
|
||||
let mut args = options.clone();
|
||||
if tool == "get_node" {
|
||||
args["id"] = root.clone();
|
||||
}
|
||||
args["depth"] = json!(6);
|
||||
args["maxBytes"] = json!(6000);
|
||||
let result = call_tool(&mut backend, tool, &args);
|
||||
assert!(!result.is_error, "{}", result.first_text());
|
||||
let value: Value = serde_json::from_str(result.first_text()).unwrap();
|
||||
assert!(value["truncated"].is_object(), "{tool}: {value}");
|
||||
assert!(backend.calls.len() > 1, "must exercise retries: {tool}");
|
||||
for (called, params) in &backend.calls {
|
||||
assert_eq!(called, method);
|
||||
for key in ["fields", "outline"] {
|
||||
assert_eq!(params.get(key), options.get(key), "{tool}: {params}");
|
||||
}
|
||||
}
|
||||
let outline = options["outline"] == true;
|
||||
if tool == "inspect_document" {
|
||||
compact_keys(&value["pages"][0], outline);
|
||||
} else {
|
||||
compact_keys(&value, outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_children_keep_window_markers_and_page_in_order() {
|
||||
let mut c = Client::new();
|
||||
let root = tree(|params| c.create(params));
|
||||
let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []}));
|
||||
let expected = full["children"].as_array().unwrap();
|
||||
let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len();
|
||||
let limit = wire + 1024 - 1;
|
||||
assert!(limit > 2048, "room for both the envelope and the marker");
|
||||
let mut seen = Vec::new();
|
||||
let mut offset = Some(0);
|
||||
let mut pages = 0;
|
||||
while let Some(at) = offset {
|
||||
let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit}));
|
||||
assert!(page["truncated"]["children"].is_object(), "{page}");
|
||||
let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len();
|
||||
assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}");
|
||||
assert_eq!(page["truncated"]["children"]["from"], at);
|
||||
seen.extend(page["children"].as_array().unwrap().iter().cloned());
|
||||
offset = page["truncated"]["children"]["nextOffset"].as_u64();
|
||||
pages += 1;
|
||||
assert!(pages <= expected.len());
|
||||
}
|
||||
assert!(pages > 1, "the fixture must require a child window");
|
||||
assert_eq!(&seen, expected);
|
||||
}
|
||||
|
||||
fn json_example(section: &str) -> &str {
|
||||
section.split("```json\n").nth(1).unwrap().split("```").next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_examples_run_verbatim_from_the_docs() {
|
||||
let mut c = Client::new();
|
||||
for _ in 0..2 {
|
||||
c.create(json!({"type": "rectangle"}));
|
||||
}
|
||||
let docs = include_str!("../../../../docs/mcp.md");
|
||||
let section = docs.split("### Compact layer trees\n").nth(1).unwrap();
|
||||
for line in json_example(section).lines() {
|
||||
let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap();
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
let mut h = Headless::new();
|
||||
for _ in 0..2 {
|
||||
h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
|
||||
}
|
||||
let docs = include_str!("../../../../docs/control-protocol.md");
|
||||
let section = docs.split("### `node.get ").nth(1).unwrap();
|
||||
for line in json_example(section).lines() {
|
||||
let request: Value = serde_json::from_str(line).unwrap();
|
||||
h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_compact_options_fail_in_tools_and_headless_control() {
|
||||
let mut c = Client::new();
|
||||
let id = c.create(json!({"type": "rectangle"}));
|
||||
let mut h = Headless::new();
|
||||
let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
|
||||
assert_eq!(created["id"], id);
|
||||
for mut args in [
|
||||
json!({"fields": [1]}),
|
||||
json!({"fields": null}),
|
||||
json!({"fields": ["Name"]}),
|
||||
json!({"outline": "yes"}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
] {
|
||||
args["id"] = id.clone();
|
||||
for tool in ["inspect_document", "get_node"] {
|
||||
assert!(!c.err(tool, args.clone()).is_empty());
|
||||
}
|
||||
for method in ["document.inspect", "node.get"] {
|
||||
assert!(h.call(method, args.clone()).is_err(), "{method}: {args}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod export_xml;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod garbage;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod inspect_fields;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod inspect_response_limit;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod overlay_anchor;
|
||||
|
||||
@@ -413,6 +413,19 @@ fn patch(last: Frame, doc: &Arc<Document>, page: NodeId, view: &View, px: (i64,
|
||||
} else if dy < 0 {
|
||||
damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach - dy });
|
||||
}
|
||||
// And at the edges that moved in: a backdrop there read what is now past the edge.
|
||||
if reach > 0 {
|
||||
if dx > 0 {
|
||||
damage.push(Px { x0: 0, y0: 0, x1: reach, y1: h });
|
||||
} else if dx < 0 {
|
||||
damage.push(Px { x0: w - reach, y0: 0, x1: w, y1: h });
|
||||
}
|
||||
if dy > 0 {
|
||||
damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach });
|
||||
} else if dy < 0 {
|
||||
damage.push(Px { x0: 0, y0: h - reach, x1: w, y1: h });
|
||||
}
|
||||
}
|
||||
// Layers that changed, where they were and where they are now.
|
||||
if !Arc::ptr_eq(&last.doc, doc) {
|
||||
let mut areas = Vec::new();
|
||||
@@ -528,18 +541,22 @@ struct FrameRender<'a> {
|
||||
opts: &'a Options,
|
||||
}
|
||||
|
||||
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`.
|
||||
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. The margin stops
|
||||
/// at the frame's edge: background blurs and glass read what is drawn under them, and a full
|
||||
/// render's backdrops end where the frame does.
|
||||
fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> {
|
||||
let FrameRender { doc, page, view, px, opts } = frame;
|
||||
let s = view.scale;
|
||||
let (ox, oy) = (d.x0 - margin, d.y0 - margin);
|
||||
let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?);
|
||||
let (w, h) = (i64::from(pix.width()), i64::from(pix.height()));
|
||||
let (ox, oy) = ((d.x0 - margin).max(0), (d.y0 - margin).max(0));
|
||||
let (ex, ey) = ((d.x1 + margin).min(w), (d.y1 + margin).min(h));
|
||||
let (rw, rh) = (u32::try_from(ex - ox).ok()?, u32::try_from(ey - oy).ok()?);
|
||||
// The same pixel grid as the frame: origin (px + offset) / scale.
|
||||
let sub = View { origin: ((px.0 + ox) as f64 / s, (px.1 + oy) as f64 / s), scale: s, width: rw, height: rh };
|
||||
let part = render_view(doc, page, &sub, opts)?;
|
||||
let inner = IntRect::from_xywh(
|
||||
i32::try_from(margin).ok()?,
|
||||
i32::try_from(margin).ok()?,
|
||||
i32::try_from(d.x0 - ox).ok()?,
|
||||
i32::try_from(d.y0 - oy).ok()?,
|
||||
u32::try_from(d.x1 - d.x0).ok()?,
|
||||
u32::try_from(d.y1 - d.y0).ok()?,
|
||||
)?;
|
||||
|
||||
@@ -456,3 +456,86 @@ fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) {
|
||||
let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
|
||||
assert!(worst <= tol, "{what}: differs by {worst} levels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backdrops_at_the_views_edge_patch_as_they_render() {
|
||||
fonts();
|
||||
let blur = |r: f64| {
|
||||
let mut e = Effect::background_blur();
|
||||
if let EffectKind::BackgroundBlur(b) = &mut e.kind {
|
||||
b.radius = r;
|
||||
}
|
||||
e
|
||||
};
|
||||
let mut glass = Effect::glass();
|
||||
if let EffectKind::Glass(g) = &mut glass.kind {
|
||||
(g.depth, g.refraction) = (30.0, 1.0);
|
||||
}
|
||||
// A layer reading its backdrop across the view's left edge, over stripes, and a square moved
|
||||
// beside it in the view and off it: what the patch reads past the edge a full render doesn't.
|
||||
for (why, fx) in [("a background blur of 60", blur(60.0)), ("glass", glass)] {
|
||||
for (from, by) in [((180.0, 100.0), (15.0, 10.0)), ((-150.0, 100.0), (20.0, 0.0))] {
|
||||
let mut d = Document::new("edge");
|
||||
let p = d.first_page().unwrap();
|
||||
for k in 0..20 {
|
||||
let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, 0.0, 10.0, 400.0).unwrap();
|
||||
solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" });
|
||||
}
|
||||
let e = d.create(p, NodeKind::Rectangle, "E", -60.0, 50.0, 160.0, 200.0).unwrap();
|
||||
let n = d.node_mut(e).unwrap();
|
||||
n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }];
|
||||
n.effects = vec![fx.clone()];
|
||||
let m = d.create(p, NodeKind::Rectangle, "M", from.0, from.1, 40.0, 40.0).unwrap();
|
||||
solid(d.node_mut(m).unwrap(), "00AA00");
|
||||
let before = Arc::new(d);
|
||||
let after = edit(&before, |d| d.node_mut(m).unwrap().transform = Affine::translate((from.0 + by.0, from.1 + by.1)));
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 };
|
||||
let mut r = Retained::default();
|
||||
r.render(&before, p, &v, &Options::default()).unwrap();
|
||||
let (shown, pix, work) = r.render(&after, p, &v, &Options::default()).unwrap();
|
||||
let pix = pix.clone();
|
||||
let full = render_view(&after, p, &shown, &Options::default()).unwrap();
|
||||
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert!(worst <= 2, "{why}, a square moved from {from:?}: {work:?} is {worst} levels off a full render");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pans_patch_backdrops_at_the_edges_that_moved_in() {
|
||||
fonts();
|
||||
let mut blur = Effect::background_blur();
|
||||
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
|
||||
b.radius = 10.0;
|
||||
}
|
||||
let mut glass = Effect::glass();
|
||||
if let EffectKind::Glass(g) = &mut glass.kind {
|
||||
(g.depth, g.refraction) = (30.0, 1.0);
|
||||
}
|
||||
// A layer reading its backdrop across all four edges of the view, over stripes: after a pan,
|
||||
// what it read past an edge that moved in is past the edge no more.
|
||||
for (why, fx) in [("a background blur of 10", blur), ("glass", glass)] {
|
||||
let mut d = Document::new("pan");
|
||||
let p = d.first_page().unwrap();
|
||||
for k in 0..40 {
|
||||
let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, -100.0, 10.0, 600.0).unwrap();
|
||||
solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" });
|
||||
}
|
||||
let e = d.create(p, NodeKind::Rectangle, "E", -60.0, -60.0, 500.0, 420.0).unwrap();
|
||||
let n = d.node_mut(e).unwrap();
|
||||
n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }];
|
||||
n.effects = vec![fx];
|
||||
let doc = Arc::new(d);
|
||||
for (dx, dy) in [(30.0, 0.0), (-30.0, 0.0), (0.0, 30.0), (0.0, -30.0)] {
|
||||
let mut r = Retained::default();
|
||||
r.render(&doc, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }, &Options::default()).unwrap();
|
||||
let v = View { origin: (dx, dy), scale: 1.0, width: 400, height: 300 };
|
||||
let (shown, pix, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
|
||||
let pix = pix.clone();
|
||||
let full = render_view(&doc, p, &shown, &Options::default()).unwrap();
|
||||
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert!(partial(work), "{why}, a pan by ({dx}, {dy}): {work:?}");
|
||||
assert!(worst <= 2, "{why}, a pan by ({dx}, {dy}): {work:?} is {worst} levels off a full render");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1075,7 @@ status = "draft — needs native review"
|
||||
"Remove row" = "Zeile entfernen"
|
||||
"Remove slot" = "Slot entfernen"
|
||||
"Remove starting point" = "Startpunkt entfernen"
|
||||
"Remove stop" = "Farbunterbrechung entfernen"
|
||||
"Remove stop" = "Verlaufsstopp entfernen"
|
||||
"Remove text from path" = "Text vom Pfad lösen"
|
||||
"Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen"
|
||||
"Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen"
|
||||
@@ -1288,7 +1288,7 @@ status = "draft — needs native review"
|
||||
"Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis"
|
||||
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
|
||||
"Start on path" = "Anfang auf dem Pfad"
|
||||
"Start on the Home screen" = "Mit Start beginnen"
|
||||
"Start on the Home screen" = "Mit dem Startbildschirm starten"
|
||||
"Start point" = "Startpunkt"
|
||||
"Start sharing" = "Teilen starten"
|
||||
"Start vote" = "Abstimmung starten"
|
||||
@@ -1305,7 +1305,7 @@ status = "draft — needs native review"
|
||||
"Stop sharing" = "Teilen beenden"
|
||||
"Stop spotlighting" = "Spotlight beenden"
|
||||
"Stop timer" = "Timer stoppen"
|
||||
"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}"
|
||||
"Stop {n} {hex}" = "Verlaufsstopp {n} {hex}"
|
||||
"Straight" = "Gerade"
|
||||
"Stretch" = "Strecken"
|
||||
"Strikethrough" = "Durchgestrichen"
|
||||
@@ -1316,11 +1316,11 @@ status = "draft — needs native review"
|
||||
"Strokes" = "Konturen"
|
||||
"Style" = "Stil"
|
||||
"Styles" = "Stile"
|
||||
"Stylistic set 1" = "Stilistischer Satz 1"
|
||||
"Stylistic set 2" = "Stilistischer Satz 2"
|
||||
"Stylistic set 3" = "Stilistischer Satz 3"
|
||||
"Stylistic set 4" = "Stilistischer Satz 4"
|
||||
"Stylistic set 5" = "Stilistischer Satz 5"
|
||||
"Stylistic set 1" = "Stilsatz 1"
|
||||
"Stylistic set 2" = "Stilsatz 2"
|
||||
"Stylistic set 3" = "Stilsatz 3"
|
||||
"Stylistic set 4" = "Stilsatz 4"
|
||||
"Stylistic set 5" = "Stilsatz 5"
|
||||
"Subtract" = "Subtrahieren"
|
||||
"Subtracts" = "Subtraktionen"
|
||||
"Suffix" = "Suffix"
|
||||
@@ -2051,7 +2051,7 @@ status = "draft — needs native review"
|
||||
"pref.reduceMotion" = "Bewegung reduzieren"
|
||||
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
|
||||
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
|
||||
"pref.startOnHome" = "Mit Start beginnen"
|
||||
"pref.startOnHome" = "Mit dem Startbildschirm starten"
|
||||
"pref.userName" = "Ihr Name"
|
||||
"proto.action.add" = "Prototyp-Aktion hinzufügen"
|
||||
"proto.action.move" = "Prototyp-Aktion neu anordnen"
|
||||
|
||||
@@ -1398,12 +1398,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
)),
|
||||
"document.inspect" => {
|
||||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
|
||||
in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages"))))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
|
||||
in_document(app, p, |s| Ok(figcraft_engine::inspect::document_with_options(s, depth, b(p, "allPages"), &options)))
|
||||
}
|
||||
"node.get" => {
|
||||
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?;
|
||||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
|
||||
in_document(app, p, |s| {
|
||||
s.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
"window.new" => {
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Attached control replies build compact layer trees before they leave the app.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn call(u: &mut Ui, method: &str, params: Value) -> Result<Value, String> {
|
||||
crate::control::handle(&mut u.app, &u.ctx.clone(), method, ¶ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_inspection_filters_nodes_and_pages_before_replying() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let frame = call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "frame", "name": "Card"}})).unwrap()["id"].clone();
|
||||
let text =
|
||||
call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "text", "parent": frame, "text": "界".repeat(121)}}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let default = call(&mut u, "document.inspect", json!({"depth": 3})).unwrap();
|
||||
let selection = u.app.session.selection();
|
||||
let history = u.app.session.undo_depth();
|
||||
let compact = call(&mut u, "document.inspect", json!({"depth": 3, "fields": []})).unwrap();
|
||||
let page = &compact["pages"][0];
|
||||
assert!(page.get("name").is_none());
|
||||
assert_eq!(page["children"][0]["children"][0], json!({"id": text}));
|
||||
let shallow = call(&mut u, "node.get", json!({"id": frame, "depth": 0, "fields": []})).unwrap();
|
||||
assert_eq!(shallow, json!({"id": frame, "childCount": 1}));
|
||||
let outline = call(&mut u, "node.get", json!({"id": frame, "depth": 1, "outline": true})).unwrap();
|
||||
assert_eq!(outline["children"][0]["characters"], format!("{}...", "界".repeat(120)));
|
||||
assert_eq!(outline["visible"], true);
|
||||
assert!(outline.get("fills").is_none());
|
||||
assert_eq!(u.app.session.selection(), selection);
|
||||
assert_eq!(u.app.session.undo_depth(), history);
|
||||
assert_eq!(call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(), default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_inspection_rejects_invalid_options_consistently() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
for method in ["document.inspect", "node.get"] {
|
||||
for mut params in [json!({"fields": ["Name"]}), json!({"fields": [2]}), json!({"outline": "true"}), json!({"outline": true, "fields": []})] {
|
||||
params["id"] = json!(u.app.session.page().unwrap());
|
||||
let error = call(&mut u, method, params).unwrap_err();
|
||||
assert!(error.contains("fields") || error.contains("field") || error.contains("outline"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@ mod ime_tests;
|
||||
#[cfg(test)]
|
||||
mod import_report_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_fields_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_drag_tests;
|
||||
|
||||
@@ -131,7 +131,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
|
||||
let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id));
|
||||
for c in library::published_collections(lib) {
|
||||
let local = copy(id, &c.id).map(|r| r.local.as_str());
|
||||
let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c);
|
||||
let coll = local.and_then(|l| doc.variables.collection_by_id(l)).unwrap_or(c);
|
||||
if several(coll) {
|
||||
out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local });
|
||||
}
|
||||
@@ -141,7 +141,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc<PublishedLibrary>]) -> Ve
|
||||
if out.iter().any(|x| x.local == Some(r.local.as_str())) {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) {
|
||||
if let Some(c) = doc.variables.collection_by_id(&r.local).filter(|c| several(c)) {
|
||||
out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ impl Presenter {
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
let Some(d) = self.design.as_mut() else { return false };
|
||||
let before = d.clone();
|
||||
let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode);
|
||||
let set = figcraft_engine::proto_run::set_mode_by_id(d, self.current, collection, mode);
|
||||
if set {
|
||||
self.settle(&before);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ fn counter_then_conditional_navigation() {
|
||||
#[test]
|
||||
fn set_variable_mode_recolours_bound_layers() {
|
||||
let (mut doc, a, _, button) = setup();
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]);
|
||||
let theme = doc.variables.collection("Theme").unwrap().id.clone();
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]);
|
||||
let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex());
|
||||
let mut p = Presenter::new(a);
|
||||
p.sync(&Arc::new(doc), 0.0);
|
||||
@@ -85,7 +86,7 @@ fn set_variable_mode_recolours_bound_layers() {
|
||||
assert_eq!(hex(&p).as_deref(), Some("000000"));
|
||||
// An unknown mode does nothing.
|
||||
let mut d2 = p.doc.clone().unwrap();
|
||||
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]);
|
||||
on_click(&mut d2, button, vec![Action::SetVariableMode { collection: theme, mode: "Sepia".into() }]);
|
||||
let mut p2 = Presenter::new(a);
|
||||
p2.sync(&Arc::new(d2), 0.0);
|
||||
assert!(!p2.fire(button, 1, None, 1.0));
|
||||
@@ -104,3 +105,29 @@ fn deeply_nested_conditionals_stop() {
|
||||
assert!(!p.fire(button, 0, None, 1.0));
|
||||
assert_eq!(p.current, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_player_collection_identity() {
|
||||
for missing in [false, true] {
|
||||
let (mut doc, a, _, button) = setup();
|
||||
let theme = doc.variables.collections[0].clone();
|
||||
let mut shadow = theme.clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = theme.id.clone();
|
||||
doc.variables.collections.insert(0, shadow);
|
||||
if missing {
|
||||
doc.variables.collections.retain(|c| c.id != theme.id);
|
||||
}
|
||||
on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]);
|
||||
let mut p = Presenter::new(a);
|
||||
p.sync(&Arc::new(doc), 0.0);
|
||||
assert_eq!(p.fire(button, 0, None, 1.0), !missing);
|
||||
let played = p.doc.as_ref().unwrap();
|
||||
let modes = &played.node(played.page_of(a).unwrap()).unwrap().var_modes;
|
||||
assert!(!modes.contains_key("shadow"));
|
||||
if !missing {
|
||||
let fill = played.node(a).unwrap().fills[0].solid_color().unwrap();
|
||||
assert_eq!(fill.to_hex(), "000000");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,34 @@ fn brand_library(tag: &str) -> (String, String) {
|
||||
(path, lid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_mode_picker_rows() {
|
||||
let (path, lid) = brand_library("stored-ids");
|
||||
let mut s = Session::new();
|
||||
let f = id(&s.execute("frame.create", &json!({"width": 100, "height": 100})).unwrap());
|
||||
s.execute("variable.createCollection", &json!({"name": "shadow"})).unwrap();
|
||||
s.execute("library.add", &json!({"path": path})).unwrap();
|
||||
s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})).unwrap();
|
||||
let local = s.doc().unwrap().libraries.collections[0].local.clone();
|
||||
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": local})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let libs = crate::variable_ui::libraries(&u.app, doc);
|
||||
// Both a read library and an unavailable one keep the actual copy's modes, not its namesake's.
|
||||
for available in [libs.as_slice(), &[]] {
|
||||
let rows = crate::variable_ui::mode_rows(doc, available);
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!(rows[0].coll.id, local);
|
||||
assert_eq!(rows[0].coll.name, "Brand");
|
||||
assert_eq!(rows[0].coll.modes.len(), 2);
|
||||
assert_eq!(rows[0].local, Some(local.as_str()));
|
||||
assert_eq!(rows[0].library.unwrap().0, lid);
|
||||
}
|
||||
u.run("select.set", json!({"ids": [f]}));
|
||||
u.frames(2, Vec::new);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_collections_show_in_the_mode_picker_under_their_library() {
|
||||
let (path, lid) = brand_library("picker");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -68,7 +68,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
|
||||
| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
|
||||
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
|
||||
| Explicit modes per frame, library collections' modes | done | |
|
||||
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` |
|
||||
|
||||
@@ -163,14 +163,15 @@ against the shared log, not by the UI thread, so it does not freeze the app, use
|
||||
need a drawn frame: it works while the window is minimised. Other requests on the same connection
|
||||
wait until the poll finishes (at most 25 s); use a separate connection to keep editing meanwhile.
|
||||
|
||||
### `document.inspect {depth?, allPages?}`
|
||||
### `document.inspect {depth?, allPages?, fields?, outline?}`
|
||||
|
||||
The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's
|
||||
layer tree to `depth`, default 8; others summarised unless `allPages`), styles, variable count,
|
||||
images, the colour profile (`colorProfile`: `srgb` or `displayP3`), the text being edited and the
|
||||
undo history.
|
||||
undo history. `fields` and `outline` select layer properties at every level, pages included, without
|
||||
changing document metadata (see below).
|
||||
|
||||
### `node.get {id, depth?}`
|
||||
### `node.get {id, depth?, fields?, outline?}`
|
||||
|
||||
One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation,
|
||||
absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius,
|
||||
@@ -187,6 +188,25 @@ starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written wi
|
||||
only when there is some). The auto layout props
|
||||
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
|
||||
|
||||
Both inspection methods accept `fields: [string]`: exact, case-sensitive top-level node keys such
|
||||
as `name`, `type`, `x`, `fills`, `characters` or `pluginData`, applied recursively to pages too.
|
||||
`[]` keeps only ids and structure. `id`, `children` and depth-boundary `childCount` are always kept.
|
||||
An unknown key is an error that lists the valid keys; a `fields` that is not an array of strings,
|
||||
or an `outline` that is not a boolean, is an error too.
|
||||
|
||||
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
|
||||
structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended
|
||||
only when longer. When `visible` is selected (by either option), every node has an explicit
|
||||
boolean; without options the output stays sparse. `outline: true` with `fields` is an error;
|
||||
`outline: false` with `fields` is allowed. Document metadata is unchanged. Through
|
||||
`engine.execute`, the engine command `node.get` takes the same options but no `depth`; it always
|
||||
returns one level of children.
|
||||
|
||||
```json
|
||||
{"id":5,"method":"document.inspect","params":{"depth":6,"outline":true}}
|
||||
{"id":6,"method":"node.get","params":{"id":3,"depth":2,"fields":["name","width","height"]}}
|
||||
```
|
||||
|
||||
### `ui.inspect {}`
|
||||
|
||||
UI state: panels and tabs (`panels`: the `left` and `right` panels' widths as asked for and whether the left one is closed, `leftCollapsed`), the cursor the canvas shows (`cursor`: a CSS cursor name such as `default`, `crosshair`, `ew-resize`, `grab`, `copy`, `not-allowed`, `zoom-in`, or one FigCraft draws: `rotate <degrees>`, `pen`, `pen-close`, `pen-add`, `pen-bend`, `eyedropper`, `comment`; `null` while the pointer is elsewhere or over something with a cursor of its own, a ruler guide or a gradient handle; the engine's own hint is `tool.overlay.cursor`), current tool (`tool`: its id, overlay hints and, for a selected
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -19,6 +19,8 @@ and prototype interactions are still unchecked there.
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA |
|
||||
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcaseLine truncated
|
||||
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors, with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
|
||||
@@ -186,6 +188,7 @@ source paint coordinates.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
|
||||
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
|
||||
@@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
|
||||
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
|
||||
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
|
||||
|
||||
+24
-2
@@ -353,8 +353,8 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). |
|
||||
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
|
||||
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
|
||||
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||||
@@ -366,6 +366,28 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
|
||||
| `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). |
|
||||
| `wait_for_changes {after?, timeoutMs?, topics?, limit?}` | Wait for new comment events in the attached app; returns pending events at once, else waits up to `timeoutMs` (default 20 s, max 25 s). Pass the returned `cursor` back to keep watching. Headless returns immediately. |
|
||||
|
||||
### Compact layer trees
|
||||
|
||||
`inspect_document` and `get_node` accept `fields`, an array of exact, case-sensitive top-level node
|
||||
keys, applied to every layer and page. For example, `fields: ["name", "type", "width", "height"]`
|
||||
leaves out paints and text properties; `fields: []` keeps only ids and structure. `id`, `children`,
|
||||
depth-boundary `childCount` and the transport's `truncated` marker are always kept (`truncated` is
|
||||
not a selectable field). An unknown key is an error that lists the valid keys; a `fields` that is
|
||||
not an array of strings, or an `outline` that is not a boolean, is an error too. Document metadata
|
||||
stays unchanged.
|
||||
|
||||
`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus
|
||||
structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended
|
||||
only when longer. Selecting `visible`, explicitly or through outline, reports a boolean even for
|
||||
visible layers and pages;
|
||||
without these options the output stays sparse. `outline: true` cannot be combined with `fields`;
|
||||
`outline: false` can. Both options keep the existing depth and response-size limits.
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inspect_document","arguments":{"depth":6,"outline":true}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_node","arguments":{"id":3,"depth":2,"fields":["name","width","height"]}}}
|
||||
```
|
||||
|
||||
### Starter templates
|
||||
|
||||
An agent can start from a template instead of an empty page. `run_command {command: "template.list"}`
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a jLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and commenLine truncated
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -48,15 +48,15 @@ blockers.
|
||||
| Milestone | Status | Left | Hours |
|
||||
|---|---|---|---|
|
||||
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — |
|
||||
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — |
|
||||
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform). Added since: compact inspection with `fields` and `outline` (part of #996) | — | — |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass |
|
||||
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
|
||||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
|
||||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11; stored references keep exact variable and collection ids) | real libraries, component playground | 7–14 |
|
||||
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 |
|
||||
| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 |
|
||||
| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 |
|
||||
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
|
||||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (`.fig` ZIP image reads bounded in total, #1076) (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide; the server key compared by its hash (#1130) | accounts, teams | 45–90 |
|
||||
| M10 Localization | ~39%: the 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; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 |
|
||||
@@ -75,6 +75,8 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1114 (the rest): documents, placed images and CLI scripts read by path are regular files within caps (16 GiB, 256 MiB, 1 GiB); folders, devices and pipes are refused before anything is read. Tests: `path_read_caps_tests` |
|
||||
| 2026-10-30 | minor | M8: a component set's property rows fit the 240 px right panel (values wrap; + and − stay in view). Tests: `component_set_narrow_tests` |
|
||||
| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged |
|
||||
@@ -147,6 +149,7 @@ blockers.
|
||||
| 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged |
|
||||
| 2026-10-27 | minor | Variables in props (M3, #1000): `node.create` and `node.set` take `{$var: variable id or name}` for `fill` and `stroke`, including unambiguous `Collection/name` lookups; bindings apply after literal values and style links and clear the paint's style link, with the same type checks, alpha and modes as `variable.bind`, in one undo step. Regression tests cover lookups, ordering, batch references, rollback and undo/redo. Numbers unchanged |
|
||||
| 2026-10-27 | minor | Finding layers without selecting them (M0.9, part of #996): `node.find` returns ids and a total with AND filters for name, canonical type, text and main component name, under a root or on the current page; ids are limited, the count is not. The selection, page and undo history stay unchanged. Available through `run_command` and batch step references. Regression tests in `crates/engine/src/node_find_tests.rs` and `crates/mcp/tests/it/agent_tasks.rs`. Numbers unchanged |
|
||||
| 2026-10-27 | minor | Compact layer trees (M0.9, part of #996): `fields` selects node properties recursively and `outline` keeps names, types, short text, main component names and visibility, plus ids and structure, through the engine, control channel and MCP. Default output and response-size limits unchanged. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
|
||||
|
||||
Reference in new issue
Block a user